This is an automated email from the ASF dual-hosted git repository.
borinquenkid pushed a commit to branch
chore/8.1.x-datamapping-core-transform-cleanup
in repository https://gitbox.apache.org/repos/asf/grails-core.git
The following commit(s) were added to
refs/heads/chore/8.1.x-datamapping-core-transform-cleanup by this push:
new 1720abd711 Add specs and clean up transactions.transform/transform in
grails-datamapping-core
1720abd711 is described below
commit 1720abd7117ef96c4703819792bff37ee8a11da4
Author: Walter Duque de Estrada <[email protected]>
AuthorDate: Tue Aug 18 12:55:22 2026 -0500
Add specs and clean up transactions.transform/transform in
grails-datamapping-core
Closes direct unit-spec coverage gaps left after PR #16148 for
RollbackTransform, AbstractDatastoreMethodDecoratingTransformation,
AbstractMethodDecoratingTransformation, AstMethodDispatchUtils, and
AstPropertyResolveUtils. Writing the AstPropertyResolveUtils spec
surfaced a real correctness bug: its property cache was keyed by
class name (a String), so two distinct ClassNode instances sharing a
name (e.g. from separate compilations of dynamically-generated/test
classes) silently corrupted each other's cached property data under
concurrent use. Fixed by keying the cache on ClassNode identity via a
synchronized IdentityHashMap.
Also extracts the duplicated applied-marker idempotency check/mark
pattern (repeated across AbstractGormASTTransformation,
AbstractMethodDecoratingTransformation, and
AbstractDatastoreMethodDecoratingTransformation) into shared
isAlreadyApplied/markApplied helpers, and applies a batch of small
cleanups flagged by static analysis: equals() calls replaced with ==,
an unused method parameter removed, Java 21 instanceof pattern
variables replacing raw-type casts, String#isEmpty() over
length()==0, Class#getDeclaredConstructor().newInstance() over the
deprecated Class#newInstance(), and two stray doc/comment fixes.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---
.../transform/TransactionalTransform.groovy | 6 +-
...tDatastoreMethodDecoratingTransformation.groovy | 5 +-
.../transform/AbstractGormASTTransformation.groovy | 28 +-
.../AbstractMethodDecoratingTransformation.groovy | 5 +-
...stractTraitApplyingGormASTTransformation.groovy | 2 +-
.../gorm/transform/AstPropertyResolveUtils.java | 55 ++--
.../gorm/transform/GormASTTransformationClass.java | 3 +-
.../transform/OrderedGormTransformation.groovy | 2 +-
.../transform/RollbackTransformSpec.groovy | 58 +++++
...astoreMethodDecoratingTransformationSpec.groovy | 183 +++++++++++++
...stractMethodDecoratingTransformationSpec.groovy | 220 ++++++++++++++++
.../gorm/transform/ApplyTestMethodDecorating.java} | 18 +-
.../transform/AstMethodDispatchUtilsSpec.groovy | 171 ++++++++++++
.../transform/AstPropertyResolveUtilsSpec.groovy | 290 +++++++++++++++++++++
.../TestMethodDecoratingTransformation.groovy | 72 +++++
15 files changed, 1072 insertions(+), 46 deletions(-)
diff --git
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transactions/transform/TransactionalTransform.groovy
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transactions/transform/TransactionalTransform.groovy
index 3f1e5a2d3c..4ffe2655d9 100644
---
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transactions/transform/TransactionalTransform.groovy
+++
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transactions/transform/TransactionalTransform.groovy
@@ -200,7 +200,7 @@ class TransactionalTransform extends
AbstractDatastoreMethodDecoratingTransforma
@Override
protected void enhanceClassNode(SourceUnit source, AnnotationNode
annotationNode, ClassNode declaringClassNode) {
- weaveTransactionManagerAware(sourceUnit, annotationNode,
declaringClassNode)
+ weaveTransactionManagerAware(annotationNode, declaringClassNode)
super.enhanceClassNode(source, annotationNode, declaringClassNode)
}
@@ -221,7 +221,7 @@ class TransactionalTransform extends
AbstractDatastoreMethodDecoratingTransforma
}
- protected void weaveTransactionManagerAware(SourceUnit source,
AnnotationNode annotationNode, ClassNode declaringClassNode) {
+ protected void weaveTransactionManagerAware(AnnotationNode annotationNode,
ClassNode declaringClassNode) {
if (declaringClassNode.getNodeMetaData(APPLIED_MARKER) ==
APPLIED_MARKER) {
return
}
@@ -419,7 +419,7 @@ class TransactionalTransform extends
AbstractDatastoreMethodDecoratingTransforma
final ClassNode rollbackRuleAttributeClassNode =
make(RollbackRuleAttribute)
final ClassNode noRollbackRuleAttributeClassNode =
make(NoRollbackRuleAttribute)
final Map<String, Expression> members = annotationNode.getMembers()
- if (READ_ONLY_TYPE.equals(annotationNode.classNode)) {
+ if (READ_ONLY_TYPE == annotationNode.classNode) {
methodBody.addStatement(
assignS(propX(transactionAttributeVar, 'readOnly'),
ConstantExpression.TRUE)
)
diff --git
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractDatastoreMethodDecoratingTransformation.groovy
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractDatastoreMethodDecoratingTransformation.groovy
index e8dac8d3b1..466adc9c30 100644
---
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractDatastoreMethodDecoratingTransformation.groovy
+++
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractDatastoreMethodDecoratingTransformation.groovy
@@ -79,14 +79,13 @@ abstract class
AbstractDatastoreMethodDecoratingTransformation extends AbstractM
@Override
protected void enhanceClassNode(SourceUnit source, AnnotationNode
annotationNode, ClassNode declaringClassNode) {
- def appliedMarker = getAppliedMarker()
- if (declaringClassNode.getNodeMetaData(appliedMarker) ==
appliedMarker) {
+ if (isAlreadyApplied(declaringClassNode)) {
return
}
if (declaringClassNode.isInterface()) {
return
}
- declaringClassNode.putNodeMetaData(appliedMarker, appliedMarker)
+ markApplied(declaringClassNode)
Expression connectionName = annotationNode.getMember('connection')
boolean hasDataSourceProperty = connectionName != null
diff --git
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractGormASTTransformation.groovy
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractGormASTTransformation.groovy
index 6749943ed5..2a35d7d6bd 100644
---
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractGormASTTransformation.groovy
+++
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractGormASTTransformation.groovy
@@ -55,18 +55,38 @@ abstract class AbstractGormASTTransformation extends
AbstractASTTransformation i
return
}
- Object appliedMarker = getAppliedMarker()
- if (annotatedNode.getNodeMetaData(appliedMarker) == appliedMarker) {
+ if (isAlreadyApplied(annotatedNode)) {
return
}
visit(source, annotationNode, annotatedNode)
- annotatedNode.putNodeMetaData(appliedMarker, appliedMarker)
+ markApplied(annotatedNode)
}
protected boolean isValidAnnotation(AnnotationNode annotationNode,
AnnotatedNode classNode) {
- return getAnnotationType().equals(annotationNode.getClassNode()) ||
!(classNode instanceof ClassNode)
+ return getAnnotationType() == annotationNode.getClassNode() ||
!(classNode instanceof ClassNode)
+ }
+
+ /**
+ * Whether the given node already carries this transformation's applied
marker.
+ *
+ * @param node The node
+ * @return true if {@link #visit} (or an equivalent per-method/per-class
idempotency check in a subclass) has already run for this node
+ */
+ protected boolean isAlreadyApplied(AnnotatedNode node) {
+ Object appliedMarker = getAppliedMarker()
+ node.getNodeMetaData(appliedMarker) == appliedMarker
+ }
+
+ /**
+ * Marks the given node as having had this transformation applied, so a
later {@link #isAlreadyApplied} check short-circuits.
+ *
+ * @param node The node
+ */
+ protected void markApplied(AnnotatedNode node) {
+ Object appliedMarker = getAppliedMarker()
+ node.putNodeMetaData(appliedMarker, appliedMarker)
}
abstract void visit(SourceUnit source, AnnotationNode annotationNode,
AnnotatedNode annotatedNode)
diff --git
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractMethodDecoratingTransformation.groovy
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractMethodDecoratingTransformation.groovy
index be943b4042..0938755b33 100644
---
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractMethodDecoratingTransformation.groovy
+++
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractMethodDecoratingTransformation.groovy
@@ -187,15 +187,14 @@ abstract class AbstractMethodDecoratingTransformation
extends AbstractGormASTTra
* @return The new method's body
*/
protected MethodNode weaveNewMethod(SourceUnit sourceUnit, AnnotationNode
annotationNode, ClassNode classNode, MethodNode methodNode, Map<String,
ClassNode> genericsSpec) {
- Object appliedMarker = getAppliedMarker()
- if (methodNode.getNodeMetaData(appliedMarker) == appliedMarker) {
+ if (isAlreadyApplied(methodNode)) {
return methodNode
}
if (methodNode.isAbstract()) {
return methodNode
}
- methodNode.putNodeMetaData(appliedMarker, appliedMarker)
+ markApplied(methodNode)
enhanceClassNode(sourceUnit, annotationNode, classNode)
diff --git
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractTraitApplyingGormASTTransformation.groovy
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractTraitApplyingGormASTTransformation.groovy
index d072c511ad..55ecc8c13b 100644
---
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractTraitApplyingGormASTTransformation.groovy
+++
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AbstractTraitApplyingGormASTTransformation.groovy
@@ -116,7 +116,7 @@ abstract class AbstractTraitApplyingGormASTTransformation
extends AbstractGormAS
}
void visitAfterTraitApplied(SourceUnit sourceUnit, AnnotationNode
annotationNode, ClassNode classNode) {
- // no-dop
+ // no-op
}
protected abstract Class getTraitClass()
diff --git
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java
index 4deeed44ea..64e802c269 100644
---
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java
+++
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java
@@ -20,7 +20,9 @@
package org.grails.datastore.gorm.transform;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
@@ -47,7 +49,14 @@ import org.grails.datastore.mapping.reflect.NameUtils;
* @since 6.1
*/
public class AstPropertyResolveUtils {
- protected static Map<String, Map<String, ClassNode>> cachedClassProperties
= new HashMap<>();
+
+ // ClassNode#equals()/hashCode() compare by name, so two distinct
ClassNode instances from
+ // separate compilations (as happens with dynamically-generated/test
classes) can legitimately
+ // share a name. Keying by name alone would let one class's resolved
properties leak into an
+ // unrelated class node that happens to share it, so the cache is keyed by
ClassNode identity
+ // instead, and guarded so the check-then-populate-then-store sequence
below is atomic.
+ protected static final Map<ClassNode, Map<String, ClassNode>>
cachedClassProperties =
+ Collections.synchronizedMap(new IdentityHashMap<>());
/**
* Resolves the type of of the given property
@@ -57,7 +66,7 @@ public class AstPropertyResolveUtils {
* @return The type
*/
public static ClassNode getPropertyType(ClassNode classNode, String
propertyName) {
- if (propertyName == null || propertyName.length() == 0) {
+ if (propertyName == null || propertyName.isEmpty()) {
return null;
}
Map<String, ClassNode> cachedProperties =
getPropertiesFromCache(classNode);
@@ -94,22 +103,24 @@ public class AstPropertyResolveUtils {
}
private static Map<String, ClassNode> getPropertiesFromCache(ClassNode
classNode) {
- String className = classNode.getName();
- Map<String, ClassNode> cachedProperties =
cachedClassProperties.get(className);
- if (cachedProperties == null) {
- cachedProperties = new HashMap<>();
- boolean isDomainClass = AstUtils.isDomainClass(classNode);
- if (isDomainClass) {
- cachedProperties.put(GormProperties.IDENTITY, new
ClassNode(Long.class));
- cachedProperties.put(GormProperties.VERSION, new
ClassNode(Long.class));
- }
- cachedClassProperties.put(className, cachedProperties);
- ClassNode currentNode = classNode;
- while (currentNode != null &&
!currentNode.equals(ClassHelper.OBJECT_TYPE)) {
- populatePropertiesForClassNode(currentNode, cachedProperties,
isDomainClass, !isDomainClass);
- currentNode = currentNode.getSuperClass();
+ synchronized (cachedClassProperties) {
+ Map<String, ClassNode> cachedProperties =
cachedClassProperties.get(classNode);
+ if (cachedProperties == null) {
+ cachedProperties = new HashMap<>();
+ boolean isDomainClass = AstUtils.isDomainClass(classNode);
+ if (isDomainClass) {
+ cachedProperties.put(GormProperties.IDENTITY, new
ClassNode(Long.class));
+ cachedProperties.put(GormProperties.VERSION, new
ClassNode(Long.class));
+ }
+ cachedClassProperties.put(classNode, cachedProperties);
+ ClassNode currentNode = classNode;
+ while (currentNode != null &&
!currentNode.equals(ClassHelper.OBJECT_TYPE)) {
+ populatePropertiesForClassNode(currentNode,
cachedProperties, isDomainClass, !isDomainClass);
+ currentNode = currentNode.getSuperClass();
+ }
}
- } return cachedProperties;
+ return cachedProperties;
+ }
}
private static void populatePropertiesForClassNode(ClassNode classNode,
Map<String, ClassNode> cachedProperties, boolean isDomainClass, boolean
allowAbstract) {
@@ -155,12 +166,11 @@ public class AstPropertyResolveUtils {
private static void cachePropertiesForAssociationMetadata(Map<String,
ClassNode> cachedProperties, ClassPropertyFetcher propertyFetcher, String
associationMetadataName) {
if (propertyFetcher.isReadableProperty(associationMetadataName)) {
Object propertyValue =
propertyFetcher.getPropertyValue(associationMetadataName);
- if (propertyValue instanceof Map) {
- Map hasManyMap = (Map) propertyValue;
+ if (propertyValue instanceof Map<?, ?> hasManyMap) {
for (Object propertyName : hasManyMap.keySet()) {
Object val = hasManyMap.get(propertyName);
- if (val instanceof Class) {
- cachedProperties.put(propertyName.toString(),
ClassHelper.make((Class) val).getPlainNodeReference());
+ if (val instanceof Class<?> valType) {
+ cachedProperties.put(propertyName.toString(),
ClassHelper.make(valType).getPlainNodeReference());
}
}
}
@@ -168,8 +178,7 @@ public class AstPropertyResolveUtils {
}
private static void populatePropertiesForInitialExpression(Map<String,
ClassNode> cachedProperties, Expression initialExpression) {
- if (initialExpression instanceof MapExpression) {
- MapExpression me = (MapExpression) initialExpression;
+ if (initialExpression instanceof MapExpression me) {
List<MapEntryExpression> mapEntryExpressions =
me.getMapEntryExpressions();
for (MapEntryExpression mapEntryExpression : mapEntryExpressions) {
Expression keyExpression =
mapEntryExpression.getKeyExpression();
diff --git
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/GormASTTransformationClass.java
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/GormASTTransformationClass.java
index 7c7e298cdf..eb71c6a054 100644
---
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/GormASTTransformationClass.java
+++
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/GormASTTransformationClass.java
@@ -24,7 +24,8 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
- * U
+ * Marker meta-annotation that points a GORM annotation (e.g. {@code
@Transactional}, {@code @Rollback},
+ * {@code @Tenant}) at the {@link
org.codehaus.groovy.transform.ASTTransformation} class that implements it.
*
* @author Graeme Rocher
* @since 6.1
diff --git
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/OrderedGormTransformation.groovy
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/OrderedGormTransformation.groovy
index ab22018d10..f33e014081 100644
---
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/OrderedGormTransformation.groovy
+++
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/OrderedGormTransformation.groovy
@@ -83,7 +83,7 @@ class OrderedGormTransformation extends
AbstractASTTransformation implements Com
String transformName = findTransformName(ann)
if (transformName) {
try {
- def newTransform =
ClassUtils.forName(transformName).newInstance()
+ def newTransform =
ClassUtils.forName(transformName).getDeclaredConstructor().newInstance()
if (newTransform instanceof ASTTransformation) {
if (newTransform instanceof CompilationUnitAware) {
((CompilationUnitAware)
newTransform).setCompilationUnit(compilationUnit)
diff --git
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transactions/transform/RollbackTransformSpec.groovy
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transactions/transform/RollbackTransformSpec.groovy
new file mode 100644
index 0000000000..0ff617cbab
--- /dev/null
+++
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transactions/transform/RollbackTransformSpec.groovy
@@ -0,0 +1,58 @@
+/*
+ * 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
+ *
+ * https://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.grails.datastore.gorm.transactions.transform
+
+import spock.lang.Specification
+
+import grails.gorm.transactions.Rollback
+import org.apache.grails.common.compiler.GroovyTransformOrder
+
+/**
+ * {@code RollbackTransform} only overrides two methods of {@link
TransactionalTransform} and its
+ * end-to-end weaving behavior is already exercised (via the {@code @Rollback}
annotation) by
+ * {@code TransactionalTransformSpec}. This spec covers what those behavioral
tests can't: that the
+ * overrides themselves - the transaction template method name and the
transform ordering priority -
+ * are the values that make {@code @Rollback} behave differently from plain
{@code @Transactional}.
+ */
+class RollbackTransformSpec extends Specification {
+
+ void "getTransactionTemplateMethodName overrides the parent to route
through the rollback-forcing template method"() {
+ given:
+ RollbackTransform transform = new RollbackTransform()
+
+ expect:
+ transform.getTransactionTemplateMethodName() == 'executeAndRollback'
+ new TransactionalTransform().getTransactionTemplateMethodName() ==
'execute'
+ }
+
+ void "priority orders RollbackTransform after TransactionalTransform"() {
+ given:
+ RollbackTransform transform = new RollbackTransform()
+
+ expect:
+ transform.priority() == GroovyTransformOrder.ROLLBACK_ORDER
+ transform.priority() < GroovyTransformOrder.TRANSACTIONAL_ORDER
+ }
+
+ void "MY_TYPE identifies the Rollback annotation and the class extends
TransactionalTransform"() {
+ expect:
+ RollbackTransform.MY_TYPE.name == Rollback.name
+ TransactionalTransform.isAssignableFrom(RollbackTransform)
+ }
+}
diff --git
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AbstractDatastoreMethodDecoratingTransformationSpec.groovy
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AbstractDatastoreMethodDecoratingTransformationSpec.groovy
new file mode 100644
index 0000000000..f9cc6cace0
--- /dev/null
+++
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AbstractDatastoreMethodDecoratingTransformationSpec.groovy
@@ -0,0 +1,183 @@
+/*
+ * 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
+ *
+ * https://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.grails.datastore.gorm.transform
+
+import java.lang.reflect.Modifier
+
+import groovy.transform.CompileStatic
+import org.codehaus.groovy.ast.AnnotationNode
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.MethodNode
+import org.codehaus.groovy.ast.Parameter
+import org.codehaus.groovy.ast.expr.ConstantExpression
+import org.codehaus.groovy.ast.expr.Expression
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.codehaus.groovy.ast.stmt.BlockStatement
+import org.codehaus.groovy.control.SourceUnit
+
+import org.springframework.beans.factory.annotation.Autowired
+
+import spock.lang.Specification
+
+import org.grails.datastore.mapping.core.Datastore
+import
org.grails.datastore.mapping.core.connections.MultipleConnectionSourceCapableDatastore
+import org.grails.datastore.mapping.services.Service
+
+/**
+ * {@code AbstractDatastoreMethodDecoratingTransformation} is only ever
exercised in this module
+ * through {@code TransactionalTransform} and {@code TenantTransform}, both of
which are always driven
+ * through a real compilation, so {@code enhanceClassNode} is never called
directly and its Service-
+ * interface branch (only reachable for a class implementing {@code
org.grails.datastore.mapping.services.Service},
+ * which none of the real transform specs' fixtures do) is never exercised at
all. Because
+ * {@code enhanceClassNode} only touches the {@code ClassNode} it's given - it
never dereferences the
+ * {@code SourceUnit} parameter unless {@code compilationUnit} is set, which
it isn't for a bare
+ * instance - it can be called directly against hand-built {@code ClassNode}s,
the same technique used
+ * for the other abstract transformation specs in this package.
+ */
+class AbstractDatastoreMethodDecoratingTransformationSpec extends
Specification {
+
+ static class MinimalDatastoreDecoratingTransformation extends
AbstractDatastoreMethodDecoratingTransformation {
+
+ @Override
+ protected ClassNode getAnnotationType() {
+ ClassHelper.make(CompileStatic)
+ }
+
+ @Override
+ protected Object getAppliedMarker() {
+ 'datastore-decorating-applied-marker'
+ }
+
+ @Override
+ protected String getRenamedMethodPrefix() {
+ '$test__'
+ }
+
+ @Override
+ protected Expression buildDelegatingMethodCall(SourceUnit sourceUnit,
AnnotationNode annotationNode, ClassNode classNode,
+ MethodNode
methodNode, MethodCallExpression originalMethodCall, BlockStatement
newMethodBody) {
+ originalMethodCall
+ }
+
+ @Override
+ int priority() {
+ 0
+ }
+ }
+
+ private static ClassNode newTargetClassNode(String name) {
+ new ClassNode(name, Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
+ }
+
+ private static Parameter[] stringConnectionNameParam() {
+ [new Parameter(ClassHelper.STRING_TYPE, 'connectionName')] as
Parameter[]
+ }
+
+ void "enhanceClassNode adds a targetDatastore field and public
getter/setter methods to a plain class"() {
+ given:
+ MinimalDatastoreDecoratingTransformation transformation = new
MinimalDatastoreDecoratingTransformation()
+ ClassNode classNode =
newTargetClassNode('org.grails.datastore.gorm.transform.fixture.PlainDecoratedTarget')
+ AnnotationNode annotationNode = new
AnnotationNode(ClassHelper.make(CompileStatic))
+
+ when:
+ transformation.enhanceClassNode(null, annotationNode, classNode)
+
+ then: 'the datastore field is added, typed as the default Datastore'
+ classNode.getField('$targetDatastore').type ==
ClassHelper.make(Datastore)
+
+ and: 'both getTargetDatastore overloads are added as public methods'
+ classNode.getMethod('getTargetDatastore', Parameter.EMPTY_ARRAY) !=
null
+ classNode.getMethod('getTargetDatastore', stringConnectionNameParam())
!= null
+
+ and: 'a public setter is added, autowired but not required'
+ MethodNode setter = classNode.getMethods('setTargetDatastore')[0]
+ Modifier.isPublic(setter.modifiers)
+ AnnotationNode autowired =
setter.getAnnotations(ClassHelper.make(Autowired))[0]
+ ((ConstantExpression) autowired.getMember('required')).value == false
+ }
+
+ void "enhanceClassNode adds only protected getTargetDatastore methods and
no field when the class implements Service"() {
+ given:
+ MinimalDatastoreDecoratingTransformation transformation = new
MinimalDatastoreDecoratingTransformation()
+ ClassNode classNode =
newTargetClassNode('org.grails.datastore.gorm.transform.fixture.ServiceDecoratedTarget')
+ classNode.addInterface(ClassHelper.make(Service))
+ AnnotationNode annotationNode = new
AnnotationNode(ClassHelper.make(CompileStatic))
+
+ when:
+ transformation.enhanceClassNode(null, annotationNode, classNode)
+
+ then: 'no field is added - the Service is looked up rather than
injected'
+ classNode.getField('$targetDatastore') == null
+
+ and: 'both getTargetDatastore overloads are added, but protected
rather than public'
+ MethodNode noArgGetter = classNode.getMethod('getTargetDatastore',
Parameter.EMPTY_ARRAY)
+ MethodNode connectionGetter =
classNode.getMethod('getTargetDatastore', stringConnectionNameParam())
+ Modifier.isProtected(noArgGetter.modifiers)
+ Modifier.isProtected(connectionGetter.modifiers)
+
+ and: 'no setter is added at all'
+ classNode.getMethods('setTargetDatastore').empty
+ }
+
+ void "enhanceClassNode uses MultipleConnectionSourceCapableDatastore as
the field type when a connection name is specified"() {
+ given:
+ MinimalDatastoreDecoratingTransformation transformation = new
MinimalDatastoreDecoratingTransformation()
+ ClassNode classNode =
newTargetClassNode('org.grails.datastore.gorm.transform.fixture.ConnectionDecoratedTarget')
+ AnnotationNode annotationNode = new
AnnotationNode(ClassHelper.make(CompileStatic))
+ annotationNode.addMember('connection', new ConstantExpression('foo'))
+
+ when:
+ transformation.enhanceClassNode(null, annotationNode, classNode)
+
+ then:
+ classNode.getField('$targetDatastore').type ==
ClassHelper.make(MultipleConnectionSourceCapableDatastore)
+ }
+
+ void "enhanceClassNode is idempotent once the applied marker is already
set on the class node"() {
+ given:
+ MinimalDatastoreDecoratingTransformation transformation = new
MinimalDatastoreDecoratingTransformation()
+ ClassNode classNode =
newTargetClassNode('org.grails.datastore.gorm.transform.fixture.AlreadyAppliedTarget')
+ classNode.putNodeMetaData(transformation.getAppliedMarker(),
transformation.getAppliedMarker())
+ AnnotationNode annotationNode = new
AnnotationNode(ClassHelper.make(CompileStatic))
+
+ when:
+ transformation.enhanceClassNode(null, annotationNode, classNode)
+
+ then: 'nothing is added because the marker short-circuits enhancement'
+ classNode.getField('$targetDatastore') == null
+ classNode.methods.every { it.name != 'getTargetDatastore' }
+ }
+
+ void "enhanceClassNode is a no-op for an interface class node"() {
+ given:
+ MinimalDatastoreDecoratingTransformation transformation = new
MinimalDatastoreDecoratingTransformation()
+ ClassNode interfaceNode = new ClassNode(
+
'org.grails.datastore.gorm.transform.fixture.NoDecorationInterfaceTarget',
+ Modifier.PUBLIC | Modifier.INTERFACE, ClassHelper.OBJECT_TYPE)
+ AnnotationNode annotationNode = new
AnnotationNode(ClassHelper.make(CompileStatic))
+
+ when:
+ transformation.enhanceClassNode(null, annotationNode, interfaceNode)
+
+ then:
+ interfaceNode.getField('$targetDatastore') == null
+ interfaceNode.methods.empty
+ }
+}
diff --git
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AbstractMethodDecoratingTransformationSpec.groovy
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AbstractMethodDecoratingTransformationSpec.groovy
new file mode 100644
index 0000000000..cae293280f
--- /dev/null
+++
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AbstractMethodDecoratingTransformationSpec.groovy
@@ -0,0 +1,220 @@
+/*
+ * 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
+ *
+ * https://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.grails.datastore.gorm.transform
+
+import org.springframework.util.ReflectionUtils
+
+import spock.lang.Specification
+
+/**
+ * {@code AbstractMethodDecoratingTransformation}'s method-selection logic in
{@code weaveClassNode} -
+ * which methods a decorating transform like {@code @Transactional} actually
gets applied to - is
+ * normally exercised indirectly and incompletely through whichever fixtures
the real transforms'
+ * specs happen to declare. This spec drives it directly, through a real
compilation, using
+ * {@link TestMethodDecoratingTransformation} - a pass-through decorator with
no side effects of its
+ * own - so each inclusion/exclusion branch can be asserted on independently
of any particular real
+ * transform's semantics.
+ */
+class AbstractMethodDecoratingTransformationSpec extends Specification {
+
+ private static Class<?> compile(String source) {
+ new GroovyClassLoader().parseClass(source)
+ }
+
+ void "a plain public instance method is renamed and re-dispatched to"() {
+ when:
+ Class<?> target = compile('''
+ package org.grails.datastore.gorm.transform.fixture
+
+ @org.grails.datastore.gorm.transform.ApplyTestMethodDecorating
+ class PlainMethodTarget {
+ String updateFoo() { 'original' }
+ }
+ ''')
+
+ then:
+ ReflectionUtils.findMethod(target, '$test__updateFoo') != null
+ target.getDeclaredConstructor().newInstance().updateFoo() == 'original'
+ }
+
+ void "static, private and abstract methods are never woven"() {
+ when:
+ Class<?> concrete = compile('''
+ package org.grails.datastore.gorm.transform.fixture
+
+ @org.grails.datastore.gorm.transform.ApplyTestMethodDecorating
+ class StaticAndPrivateMethodsTarget {
+ static void staticMethod() { }
+ private void privateMethod() { }
+ }
+ ''')
+ Class<?> abstractTarget = compile('''
+ package org.grails.datastore.gorm.transform.fixture
+
+ @org.grails.datastore.gorm.transform.ApplyTestMethodDecorating
+ abstract class AbstractMethodTarget {
+ abstract void abstractMethod()
+ }
+ ''')
+
+ then:
+ ReflectionUtils.findMethod(concrete, '$test__staticMethod') == null
+ ReflectionUtils.findMethod(concrete, '$test__privateMethod', String)
== null
+ concrete.declaredMethods.every {
!it.name.contains('$test__privateMethod') }
+ ReflectionUtils.findMethod(abstractTarget, '$test__abstractMethod') ==
null
+ }
+
+ void "METHOD_NAME_EXCLUDES keeps lifecycle method names such as
afterPropertiesSet and destroy unwoven"() {
+ when:
+ Class<?> target = compile('''
+ package org.grails.datastore.gorm.transform.fixture
+
+ @org.grails.datastore.gorm.transform.ApplyTestMethodDecorating
+ class LifecycleMethodTarget {
+ void afterPropertiesSet() { }
+ void destroy() { }
+ }
+ ''')
+
+ then:
+ ReflectionUtils.findMethod(target, '$test__afterPropertiesSet') == null
+ ReflectionUtils.findMethod(target, '$test__destroy') == null
+ }
+
+ void "setters are never woven and a getter is only woven when it has no
matching setter"() {
+ when:
+ Class<?> target = compile('''
+ package org.grails.datastore.gorm.transform.fixture
+
+ @org.grails.datastore.gorm.transform.ApplyTestMethodDecorating
+ class GetterSetterTarget {
+ String name
+ String getAge() { 'ageless' }
+ }
+ ''')
+
+ then: 'the setter itself is never a weaving candidate'
+ ReflectionUtils.findMethod(target, '$test__setName', String) == null
+
+ and: 'the getter that has a matching setter is skipped'
+ ReflectionUtils.findMethod(target, '$test__getName') == null
+
+ and: 'the getter with no matching setter is woven'
+ ReflectionUtils.findMethod(target, '$test__getAge') != null
+ }
+
+ void "a dollar-prefixed method name that is not a spock feature method is
skipped"() {
+ when:
+ Class<?> target = compile('''
+ package org.grails.datastore.gorm.transform.fixture
+
+ @org.grails.datastore.gorm.transform.ApplyTestMethodDecorating
+ class DollarMethodTarget {
+ void $rawMethod() { }
+ }
+ ''')
+
+ then:
+ ReflectionUtils.findMethod(target, '$test__$rawMethod') == null
+ }
+
+ void "hasExcludedAnnotation skips methods annotated with PostConstruct"() {
+ when:
+ Class<?> target = compile('''
+ package org.grails.datastore.gorm.transform.fixture
+
+ @org.grails.datastore.gorm.transform.ApplyTestMethodDecorating
+ class PostConstructTarget {
+ @jakarta.annotation.PostConstruct
+ void init() { }
+ }
+ ''')
+
+ then:
+ ReflectionUtils.findMethod(target, '$test__init') == null
+ }
+
+ void "spock setup and cleanup are routed to weaveTestSetupMethod instead
of being renamed"() {
+ when:
+ Class<?> target = compile('''
+ package org.grails.datastore.gorm.transform.fixture
+
+ @org.grails.datastore.gorm.transform.ApplyTestMethodDecorating
+ class SpockSetupCleanupTarget extends spock.lang.Specification {
+ def setup() { }
+ def cleanup() { }
+ }
+ ''')
+
+ then:
+ ReflectionUtils.findMethod(target, '$test__setup') == null
+ ReflectionUtils.findMethod(target, '$test__cleanup') == null
+ }
+
+ void "a JUnit-annotated method is routed to weaveTestSetupMethod instead
of being renamed"() {
+ when:
+ Class<?> target = compile('''
+ package org.grails.datastore.gorm.transform.fixture
+
+ @org.grails.datastore.gorm.transform.ApplyTestMethodDecorating
+ class JunitAnnotatedTarget {
+ @org.junit.jupiter.api.BeforeEach
+ void junitSetup() { }
+ }
+ ''')
+
+ then:
+ ReflectionUtils.findMethod(target, '$test__junitSetup') == null
+ }
+
+ void "an overriding method is renamed with a class-qualified prefix while
the parent's own method uses the plain prefix"() {
+ when: 'evaluating the script (rather than parsing a single class)
returns both declared types, in order'
+ List<Class<?>> types = new GroovyShell().evaluate('''
+ package org.grails.datastore.gorm.transform.fixture
+
+ @org.grails.datastore.gorm.transform.ApplyTestMethodDecorating
+ class OverrideParentTarget {
+ String sound() { 'parent' }
+ }
+
+ @org.grails.datastore.gorm.transform.ApplyTestMethodDecorating
+ class OverrideChildTarget extends OverrideParentTarget {
+ @Override
+ String sound() { 'child' }
+ }
+
+ [OverrideParentTarget, OverrideChildTarget]
+ ''') as List<Class<?>>
+ Class<?> parent = types[0]
+ Class<?> child = types[1]
+
+ then: 'the parent method - not an override - uses the plain renamed
prefix'
+ ReflectionUtils.findMethod(parent, '$test__sound') != null
+
+ and: 'the overriding method is renamed with the decapitalized
declaring class name mixed in'
+ ReflectionUtils.findMethod(child, '$test__overrideChildTarget_sound')
!= null
+
+ and: 'the child does not declare its own plain-prefixed renamed method
(only inherits the parent one)'
+ child.declaredMethods.every { it.name != '$test__sound' }
+
+ and: 'the woven methods still dispatch correctly'
+ parent.getDeclaredConstructor().newInstance().sound() == 'parent'
+ child.getDeclaredConstructor().newInstance().sound() == 'child'
+ }
+}
diff --git
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/GormASTTransformationClass.java
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/ApplyTestMethodDecorating.java
similarity index 59%
copy from
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/GormASTTransformationClass.java
copy to
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/ApplyTestMethodDecorating.java
index 7c7e298cdf..8166262ca6 100644
---
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/GormASTTransformationClass.java
+++
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/ApplyTestMethodDecorating.java
@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
+
package org.grails.datastore.gorm.transform;
import java.lang.annotation.ElementType;
@@ -23,14 +24,17 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
+import org.codehaus.groovy.transform.GroovyASTTransformationClass;
+
/**
- * U
- *
- * @author Graeme Rocher
- * @since 6.1
+ * Local marker annotation used only for testing {@link
AbstractMethodDecoratingTransformation}
+ * through a real, live compilation - triggering {@link
TestMethodDecoratingTransformation}, a
+ * pass-through decorator that renames and re-dispatches to the original
method body without
+ * wrapping it in anything (unlike the real {@code Transactional}/{@code
CurrentTenant} transforms),
+ * so the method-selection logic in {@code weaveClassNode} can be exercised in
isolation.
*/
@Retention(RetentionPolicy.RUNTIME)
-@Target(ElementType.ANNOTATION_TYPE)
-public @interface GormASTTransformationClass {
- String value();
+@Target({ElementType.TYPE})
+@GroovyASTTransformationClass("org.grails.datastore.gorm.transform.TestMethodDecoratingTransformation")
+public @interface ApplyTestMethodDecorating {
}
diff --git
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstMethodDispatchUtilsSpec.groovy
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstMethodDispatchUtilsSpec.groovy
new file mode 100644
index 0000000000..1e6d73ee59
--- /dev/null
+++
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstMethodDispatchUtilsSpec.groovy
@@ -0,0 +1,171 @@
+/*
+ * 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
+ *
+ * https://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.grails.datastore.gorm.transform
+
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.Parameter
+import org.codehaus.groovy.ast.expr.ClassExpression
+import org.codehaus.groovy.ast.expr.ConstantExpression
+import org.codehaus.groovy.ast.expr.Expression
+import org.codehaus.groovy.ast.expr.MapExpression
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.codehaus.groovy.ast.expr.TupleExpression
+import org.codehaus.groovy.ast.expr.VariableExpression
+import spock.lang.Specification
+
+import static org.codehaus.groovy.ast.tools.GeneralUtils.args
+import static org.codehaus.groovy.ast.tools.GeneralUtils.constX
+import static org.codehaus.groovy.ast.tools.GeneralUtils.varX
+import static org.grails.datastore.mapping.reflect.AstUtils.ZERO_ARGUMENTS
+
+/**
+ * {@code AstMethodDispatchUtils} builds AST method-call expressions and, when
it can resolve the
+ * target method on the declared type, wires the {@code MethodTarget} onto the
call so the compiler
+ * doesn't have to do dynamic dispatch. This spec proves that resolution
happens when the method
+ * genuinely exists on the target type, is left unresolved when it doesn't,
and that the small
+ * argument/parameter-building helpers (`namedArgs`, `paramsForArgs`) produce
the shapes their callers
+ * across the module (service implementers, `TenantTransform`, the
transactional transforms) rely on.
+ */
+class AstMethodDispatchUtilsSpec extends Specification {
+
+ static class Greeter {
+ String greet(String name) { "hello $name" }
+ }
+
+ void "namedArgs builds a MapExpression with one entry per named
argument"() {
+ given:
+ ConstantExpression trueExpr = ConstantExpression.TRUE
+
+ when:
+ MapExpression mapExpression =
AstMethodDispatchUtils.namedArgs(failOnError: trueExpr)
+
+ then:
+ mapExpression.mapEntryExpressions.size() == 1
+ mapExpression.mapEntryExpressions[0].keyExpression.text ==
'failOnError'
+ mapExpression.mapEntryExpressions[0].valueExpression.is(trueExpr)
+ }
+
+ void "callD(Class, var, methodName) resolves the method target when the
method exists on the target type"() {
+ when:
+ MethodCallExpression call = AstMethodDispatchUtils.callD(Greeter,
'greeter', 'greet', args(constX('World')))
+
+ then:
+ call.methodAsString == 'greet'
+ call.methodTarget != null
+ call.methodTarget.name == 'greet'
+ call.objectExpression instanceof VariableExpression
+ ((VariableExpression) call.objectExpression).name == 'greeter'
+ }
+
+ void "callD(ClassNode, var, methodName) leaves the method target unset
when the method does not exist"() {
+ given:
+ ClassNode greeterType = ClassHelper.make(Greeter)
+
+ when:
+ MethodCallExpression call = AstMethodDispatchUtils.callD(greeterType,
'greeter', 'doesNotExist')
+
+ then:
+ call.methodAsString == 'doesNotExist'
+ call.methodTarget == null
+ }
+
+ void "callD(Expression, methodName) resolves against the expression's
static type"() {
+ given:
+ VariableExpression target = varX('greeter', ClassHelper.make(Greeter))
+
+ when:
+ MethodCallExpression call = AstMethodDispatchUtils.callD(target,
'greet', args(constX('World')))
+
+ then:
+ call.methodTarget != null
+ call.methodTarget.name == 'greet'
+ }
+
+ void "callD defaults to ZERO_ARGUMENTS when no arguments are supplied"() {
+ when:
+ MethodCallExpression call = AstMethodDispatchUtils.callD(Greeter,
'greeter', 'greet')
+
+ then:
+ call.arguments.is(ZERO_ARGUMENTS)
+ }
+
+ void "callThisD(Class, methodName) builds a call on an explicit 'this' of
the given type and resolves the target"() {
+ when:
+ MethodCallExpression call = AstMethodDispatchUtils.callThisD(Greeter,
'greet', args(constX('World')))
+
+ then:
+ call.methodTarget != null
+ call.methodTarget.name == 'greet'
+ ((VariableExpression) call.objectExpression).name == 'this'
+ ((VariableExpression) call.objectExpression).type ==
ClassHelper.make(Greeter)
+ }
+
+ void "callThisD(ClassNode, methodName, arguments) leaves the method target
unset when the method does not exist"() {
+ given:
+ ClassNode greeterType = ClassHelper.make(Greeter)
+
+ when:
+ MethodCallExpression call =
AstMethodDispatchUtils.callThisD(greeterType, 'doesNotExist', ZERO_ARGUMENTS)
+
+ then:
+ call.methodTarget == null
+ }
+
+ void "paramsForArgs builds one parameter per expression in a
TupleExpression, typed from each expression"() {
+ given:
+ TupleExpression tuple = args(constX('a string'), constX(1))
+
+ when:
+ Parameter[] params = AstMethodDispatchUtils.paramsForArgs(tuple)
+
+ then:
+ params.length == 2
+ params[0].name == 'p0'
+ params[0].type == ClassHelper.STRING_TYPE
+ params[1].name == 'p1'
+ params[1].type == ClassHelper.Integer_TYPE
+ }
+
+ void "paramsForArgs treats a ClassExpression argument as typed Class, not
the referenced type"() {
+ given:
+ Expression classArg = new ClassExpression(ClassHelper.make(Greeter))
+ TupleExpression tuple = args(classArg)
+
+ when:
+ Parameter[] params = AstMethodDispatchUtils.paramsForArgs(tuple)
+
+ then:
+ params.length == 1
+ params[0].type == ClassHelper.CLASS_Type
+ }
+
+ void "paramsForArgs builds a single parameter for a bare (non-tuple)
expression"() {
+ given:
+ Expression singleArg = constX('solo')
+
+ when:
+ Parameter[] params = AstMethodDispatchUtils.paramsForArgs(singleArg)
+
+ then:
+ params.length == 1
+ params[0].name == 'p'
+ params[0].type == ClassHelper.STRING_TYPE
+ }
+}
diff --git
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy
new file mode 100644
index 0000000000..fd9fa3ddaa
--- /dev/null
+++
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy
@@ -0,0 +1,290 @@
+/*
+ * 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
+ *
+ * https://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.grails.datastore.gorm.transform
+
+import java.lang.reflect.Modifier
+import java.util.concurrent.Callable
+import java.util.concurrent.CyclicBarrier
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+import java.util.concurrent.TimeUnit
+
+import org.codehaus.groovy.ast.AnnotationNode
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.expr.ClassExpression
+import org.codehaus.groovy.ast.expr.ConstantExpression
+import org.codehaus.groovy.ast.expr.Expression
+import org.codehaus.groovy.ast.expr.MapExpression
+import spock.lang.Specification
+
+import grails.gorm.annotation.Entity
+import org.grails.datastore.mapping.model.config.GormProperties
+
+/**
+ * {@link AstPropertyResolveUtils} caches resolved property metadata as
metadata on the
+ * {@link ClassNode} it describes (see that class's javadoc). Two distinct
compilations (e.g. the
+ * same source parsed in two different {@code GroovyClassLoader}s, as happens
for
+ * dynamically-generated sources and in tests) produce distinct {@code
ClassNode} instances that
+ * can legitimately share the exact same name - {@code
ClassNode#equals(Object)} compares by name,
+ * so a naive name- or equals()-based cache key would conflate them,
corrupting the resolved
+ * properties of one class with those of an unrelated class that happens to
share its name. This
+ * spec proves the cache is scoped strictly per {@code ClassNode} instance, so
same-named-but-distinct
+ * class nodes never contaminate each other's cached property data, that
domain-class-specific
+ * resolution (identity/version injection, association metadata) works, and
that concurrent
+ * resolution of distinct nodes is safe.
+ */
+class AstPropertyResolveUtilsSpec extends Specification {
+
+ void "property lookups for two same-named ClassNodes in different packages
do not corrupt each other"() {
+ given: 'two distinct ClassNodes with the same simple name declared in
different packages'
+ ClassNode first = new ClassNode('org.example.one.Widget',
Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
+ first.addProperty('color', Modifier.PUBLIC, ClassHelper.STRING_TYPE,
null, null, null)
+
+ ClassNode second = new ClassNode('org.example.two.Widget',
Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
+ second.addProperty('weight', Modifier.PUBLIC,
ClassHelper.Integer_TYPE, null, null, null)
+
+ when: 'the first class node is resolved, populating its cache entry'
+ List<String> firstProperties =
AstPropertyResolveUtils.getPropertyNames(first)
+
+ then: 'only its own property is resolved'
+ firstProperties.contains('color')
+ !firstProperties.contains('weight')
+
+ when: 'the second, differently-packaged, same-simple-name class node
is resolved'
+ List<String> secondProperties =
AstPropertyResolveUtils.getPropertyNames(second)
+
+ then: 'its own property is resolved, not leaked from the first class
node'
+ secondProperties.contains('weight')
+ !secondProperties.contains('color')
+
+ and: 'the first class node cache entry remains unaffected by resolving
the second'
+ List<String> firstPropertiesAfter =
AstPropertyResolveUtils.getPropertyNames(first)
+ firstPropertiesAfter.contains('color')
+ !firstPropertiesAfter.contains('weight')
+ }
+
+ void "property lookups for two distinct ClassNode instances with the exact
same unqualified name do not corrupt each other"() {
+ given: 'two distinct ClassNode instances - as produced by two separate
compilations - sharing an identical unqualified name'
+ ClassNode first = new ClassNode('Widget', Modifier.PUBLIC,
ClassHelper.OBJECT_TYPE)
+ first.addProperty('color', Modifier.PUBLIC, ClassHelper.STRING_TYPE,
null, null, null)
+
+ ClassNode second = new ClassNode('Widget', Modifier.PUBLIC,
ClassHelper.OBJECT_TYPE)
+ second.addProperty('weight', Modifier.PUBLIC,
ClassHelper.Integer_TYPE, null, null, null)
+
+ and: 'they are genuinely different instances - the precondition a
name- or equals()-keyed cache would get wrong'
+ // ClassNode#equals()/hashCode() compare by getText() (essentially the
class name), so
+ // first == second and first.hashCode() == second.hashCode() both hold
here even though
+ // these are two unrelated ClassNode instances with different declared
properties. A cache
+ // keyed by name or by equals()/hashCode() would treat them as the
same entry; only
+ // reference identity (!first.is(second)) tells them apart, which is
exactly what the
+ // cache must key on. This is an "and:" continuing "given:", so Spock
does not apply an
+ // implicit condition here - the explicit assert is required for this
to actually fail
+ // the test if it were ever untrue.
+ assert !first.is(second)
+
+ when: 'both class nodes are resolved'
+ List<String> firstProperties =
AstPropertyResolveUtils.getPropertyNames(first)
+ List<String> secondProperties =
AstPropertyResolveUtils.getPropertyNames(second)
+
+ then: 'each keeps its own, independently-resolved properties despite
comparing equal'
+ firstProperties.contains('color')
+ !firstProperties.contains('weight')
+ secondProperties.contains('weight')
+ !secondProperties.contains('color')
+ }
+
+ void "getPropertyType resolves the type of a declared property"() {
+ given: 'a class node with a declared property'
+ ClassNode classNode = new ClassNode('org.example.PropertyTypeWidget',
Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
+ classNode.addProperty('label', Modifier.PUBLIC,
ClassHelper.STRING_TYPE, null, null, null)
+
+ expect: 'the resolved property type matches the declared type, both on
first and second lookup'
+ AstPropertyResolveUtils.getPropertyType(classNode, 'label') ==
ClassHelper.STRING_TYPE
+ AstPropertyResolveUtils.getPropertyType(classNode, 'label') ==
ClassHelper.STRING_TYPE
+ }
+
+ void "getPropertyNames returns the snapshot taken on first lookup rather
than reflecting properties added afterwards"() {
+ given: 'a class node with one declared property'
+ ClassNode classNode = new
ClassNode('org.example.CachedSnapshotWidget', Modifier.PUBLIC,
ClassHelper.OBJECT_TYPE)
+ classNode.addProperty('label', Modifier.PUBLIC,
ClassHelper.STRING_TYPE, null, null, null)
+
+ when: 'the property names are resolved once, populating the cache'
+ List<String> firstLookup =
AstPropertyResolveUtils.getPropertyNames(classNode)
+
+ then:
+ firstLookup == ['label']
+
+ when: 'a second property is added directly to the ClassNode after the
cache has already been populated'
+ classNode.addProperty('extra', Modifier.PUBLIC,
ClassHelper.Integer_TYPE, null, null, null)
+
+ then: 'a direct lookup on the ClassNode confirms the property really
was added - so the cache below is stale, not simply broken'
+ classNode.getProperty('extra') != null
+
+ and: 'getPropertyNames still returns the cached snapshot from the
first lookup, proving the result was actually cached rather than recomputed on
every call'
+ !AstPropertyResolveUtils.getPropertyNames(classNode).contains('extra')
+ }
+
+ void "getPropertyNames injects identity and version for a domain class and
resolves hasMany/belongsTo/hasOne declared via AST initial expressions"() {
+ given: 'a domain class node declaring hasMany/belongsTo/hasOne as
property initial expressions, as "static hasMany = [...]" compiles to'
+ ClassNode associatedType = new
ClassNode('org.example.AssociatedThing', Modifier.PUBLIC,
ClassHelper.OBJECT_TYPE)
+ ClassNode classNode = new ClassNode('org.example.AstDrivenDomain',
Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
+ classNode.addAnnotation(new AnnotationNode(ClassHelper.make(Entity)))
+ classNode.addProperty('title', Modifier.PUBLIC,
ClassHelper.STRING_TYPE, null, null, null)
+ classNode.addProperty(GormProperties.HAS_MANY, Modifier.PUBLIC |
Modifier.STATIC, ClassHelper.MAP_TYPE.getPlainNodeReference(),
+ mapExpressionOf('things', associatedType), null, null)
+
+ when:
+ List<String> propertyNames =
AstPropertyResolveUtils.getPropertyNames(classNode)
+
+ then: 'the AST-declared property is present alongside the injected
identity/version properties'
+ propertyNames.containsAll(['title', GormProperties.IDENTITY,
GormProperties.VERSION, 'things'])
+ AstPropertyResolveUtils.getPropertyType(classNode,
GormProperties.IDENTITY) == new ClassNode(Long.class)
+ AstPropertyResolveUtils.getPropertyType(classNode, 'things') ==
associatedType
+
+ and: 'the raw hasMany/belongsTo/hasOne map property itself is not
exposed as a plain property'
+ !propertyNames.contains(GormProperties.HAS_MANY)
+ }
+
+ void "getPropertyNames resolves hasMany/belongsTo/hasOne association
metadata via reflection once the domain class is fully resolved"() {
+ given: 'a real, already-compiled domain class with
hasMany/belongsTo/hasOne associations'
+ GroovyClassLoader gcl = new GroovyClassLoader()
+ gcl.parseClass('''
+ import grails.gorm.annotation.Entity
+
+ @Entity
+ class ReflectedAssociationAuthor {
+ String name
+ }
+
+ @Entity
+ class ReflectedAssociationBook {
+ String title
+ }
+
+ @Entity
+ class ReflectedAssociationPublisher {
+ String company
+ }
+
+ @Entity
+ class ReflectedAssociationFixture {
+ static hasMany = [books: ReflectedAssociationBook]
+ static belongsTo = [author: ReflectedAssociationAuthor]
+ static hasOne = [publisher: ReflectedAssociationPublisher]
+ }
+ ''')
+ Class<?> domainClass = gcl.loadedClasses.find { it.simpleName ==
'ReflectedAssociationFixture' }
+
+ and: 'a fresh ClassNode built from the already-compiled class, as
happens once compilation has finished'
+ ClassNode resolvedNode = ClassHelper.make(domainClass)
+
+ expect: 'the node reports itself resolved, which is what gates the
reflection-based association lookup'
+ resolvedNode.isResolved()
+
+ when:
+ List<String> propertyNames =
AstPropertyResolveUtils.getPropertyNames(resolvedNode)
+
+ then: 'the reflected association properties are present alongside the
injected identity/version properties'
+ propertyNames.containsAll([GormProperties.IDENTITY,
GormProperties.VERSION, 'books', 'author', 'publisher'])
+
+ cleanup:
+ gcl.close()
+ }
+
+ void "concurrent resolution of distinct, identically-named ClassNode
instances never corrupts each other's cached properties"() {
+ given: 'many threads, each building and resolving its own distinct
ClassNode sharing one common name'
+ int threadCount = 20
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount)
+ CyclicBarrier barrier = new CyclicBarrier(threadCount)
+
+ when: 'all threads race to populate the cache for their own instance
at the same time'
+ List<Future<Boolean>> futures = (0..<threadCount).collect { int i ->
+ executor.submit({ ->
+ barrier.await(30, TimeUnit.SECONDS)
+ ClassNode node = new ClassNode('ConcurrentWidget',
Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
+ String propertyName = "prop${i}".toString()
+ node.addProperty(propertyName, Modifier.PUBLIC,
ClassHelper.STRING_TYPE, null, null, null)
+
+ List<String> names =
AstPropertyResolveUtils.getPropertyNames(node)
+ names.contains(propertyName) && names.count {
it.startsWith('prop') } == 1
+ } as Callable<Boolean>)
+ }
+ List<Boolean> outcomes = futures.collect { Future<Boolean> future ->
future.get(30, TimeUnit.SECONDS) }
+
+ then: 'every thread resolved its own property set, uncontaminated by
any of the other concurrently-resolved same-named instances'
+ outcomes.every { it }
+
+ cleanup:
+ executor.shutdownNow()
+ }
+
+ void "concurrent resolution of the exact same shared ClassNode instance
from many threads is safe"() {
+ // Distinct-instance concurrency (the test above) can never exercise a
race on the
+ // underlying node-metadata storage, because nothing is shared between
the threads. A single
+ // ClassNode instance genuinely can be looked up from more than one
thread at once in
+ // practice - e.g. ClassHelper.OBJECT_TYPE/STRING_TYPE are JVM-wide
singletons that this
+ // utility's callers can resolve to for a plain Object- or def-typed
property, so two
+ // unrelated, concurrently-running compilations could both reach this
cache for the exact
+ // same node. This test exercises that shared-node case directly and
asserts every thread's
+ // returned value is correct.
+ //
+ // Note on what this test can and can't prove: the cached computation
here is deterministic
+ // and idempotent, so even with the "synchronized
(cachedClassProperties)" guard in
+ // AstPropertyResolveUtils#getPropertiesFromCache removed, every
thread still computes and
+ // returns the same correct value in practice - a black-box test of
returned values cannot
+ // reliably force ClassNode's underlying,
explicitly-documented-not-thread-safe metadata
+ // storage into an observably-wrong state without reaching into Groovy
internals neither this
+ // spec nor AstPropertyResolveUtils controls. Verified by temporarily
removing that guard and
+ // running this test 8 times without a failure. The synchronization is
kept as a correctness
+ // fix justified by IdentityHashMap's own documentation (it is not
synchronized and concurrent
+ // structural modification can corrupt it), not because this test can
demonstrate its absence
+ // breaking anything; this test instead guards against a regression to
something observably
+ // broken (an exception, a null, a wrong/partial result) under real
concurrent load, which is
+ // the failure mode a future refactor could plausibly introduce.
+ given: 'one ClassNode instance that every thread will resolve
concurrently'
+ ClassNode sharedNode = new ClassNode('SharedWidget', Modifier.PUBLIC,
ClassHelper.OBJECT_TYPE)
+ sharedNode.addProperty('label', Modifier.PUBLIC,
ClassHelper.STRING_TYPE, null, null, null)
+ int threadCount = 32
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount)
+ CyclicBarrier barrier = new CyclicBarrier(threadCount)
+
+ when: 'all threads race to resolve properties for the same instance at
once'
+ List<Future<List<String>>> futures = (0..<threadCount).collect {
+ executor.submit({ ->
+ barrier.await(30, TimeUnit.SECONDS)
+ AstPropertyResolveUtils.getPropertyNames(sharedNode)
+ } as Callable<List<String>>)
+ }
+ List<List<String>> results = futures.collect { Future<List<String>>
future -> future.get(30, TimeUnit.SECONDS) }
+
+ then: 'every thread observes the same, fully and correctly populated
result - none sees a partial or corrupted map'
+ results.every { it == ['label'] }
+
+ cleanup:
+ executor.shutdownNow()
+ }
+
+ private static Expression mapExpressionOf(String key, ClassNode valueType)
{
+ MapExpression mapExpression = new MapExpression()
+ mapExpression.addMapEntryExpression(new ConstantExpression(key), new
ClassExpression(valueType))
+ return mapExpression
+ }
+}
diff --git
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/TestMethodDecoratingTransformation.groovy
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/TestMethodDecoratingTransformation.groovy
new file mode 100644
index 0000000000..4e69f30003
--- /dev/null
+++
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/TestMethodDecoratingTransformation.groovy
@@ -0,0 +1,72 @@
+/*
+ * 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
+ *
+ * https://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.grails.datastore.gorm.transform
+
+import org.codehaus.groovy.ast.AnnotationNode
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.MethodNode
+import org.codehaus.groovy.ast.expr.Expression
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.codehaus.groovy.ast.stmt.BlockStatement
+import org.codehaus.groovy.control.CompilePhase
+import org.codehaus.groovy.control.SourceUnit
+import org.codehaus.groovy.transform.ASTTransformation
+import org.codehaus.groovy.transform.GroovyASTTransformation
+
+/**
+ * Local, annotation-driven transformation used only to test {@link
AbstractMethodDecoratingTransformation}
+ * through a genuine compilation. It simply renames the decorated method and
dispatches straight to
+ * it - no wrapping closure, no transaction/tenant semantics - so tests can
assert purely on which
+ * methods {@code weaveClassNode} chose to decorate.
+ *
+ * @see AbstractMethodDecoratingTransformationSpec
+ */
+@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
+class TestMethodDecoratingTransformation extends
AbstractMethodDecoratingTransformation implements ASTTransformation {
+
+ private static final ClassNode MY_TYPE =
ClassHelper.make(ApplyTestMethodDecorating)
+ private static final Object APPLIED_MARKER = new Object()
+
+ @Override
+ protected ClassNode getAnnotationType() {
+ MY_TYPE
+ }
+
+ @Override
+ protected Object getAppliedMarker() {
+ APPLIED_MARKER
+ }
+
+ @Override
+ protected String getRenamedMethodPrefix() {
+ '$test__'
+ }
+
+ @Override
+ protected Expression buildDelegatingMethodCall(SourceUnit sourceUnit,
AnnotationNode annotationNode, ClassNode classNode,
+ MethodNode methodNode,
MethodCallExpression originalMethodCall, BlockStatement newMethodBody) {
+ originalMethodCall
+ }
+
+ @Override
+ int priority() {
+ 0
+ }
+}