codeconsole commented on code in PR #16292:
URL: https://github.com/apache/grails-core/pull/16292#discussion_r3929274419


##########
grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java:
##########
@@ -878,6 +890,118 @@ private void processBeanStatement(ClassNode classNode, 
MethodCallExpression oute
         classNode.addMethod(beanMethod);
     }
 
+    // The methods this block just generated, in declaration order: everything 
on the host that was
+    // not there before the two processing loops ran. MethodNode does not 
override equals, so the
+    // removal is by identity and cannot drop a same-signature method the user 
wrote.
+    private List<MethodNode> generatedMembers(ClassNode host, List<MethodNode> 
preExisting) {
+        List<MethodNode> generated = new ArrayList<>(host.getMethods());
+        generated.removeAll(preExisting);
+        return generated;
+    }
+
+    /**
+     * Rejects a call from one generated method to another generated {@code 
@Bean} method, on a host
+     * whose bean methods Spring does not proxy.
+     *
+     * <p>Calling a sibling {@code @Bean} method and getting the singleton 
back is a CGLIB trick, and
+     * Spring only plays it for a full {@code @Configuration} class. On a 
<i>lite</i> configuration
+     * source the same call is a plain Java call that constructs a second 
instance - and lite is the
+     * common case for this DSL: {@code @AutoConfiguration} is
+     * {@code @Configuration(proxyBeanMethods = false)}, the sibling generated 
for a plugin descriptor
+     * carries exactly that, and a Grails {@code Application} class is a 
configuration source without
+     * being annotated {@code @Configuration} at all.</p>
+     *
+     * <p>Nothing about that failure is visible at runtime. The context 
starts, every bean exists, and
+     * two objects live where the author meant one - so a listener registers 
on the wrong instance, or
+     * configuration applied to one is missing from the other. It is also the 
exact mistake a
+     * migration invites, since moving bean methods off a real {@code 
@Configuration} class into this
+     * DSL silently changes what those calls mean.</p>
+     */
+    private void rejectUnproxiedSiblingBeanCalls(ClassNode host, 
List<MethodNode> generated, SourceUnit source) {
+        Map<String, MethodNode> beanMethodsByName = new LinkedHashMap<>();
+        for (MethodNode method : generated) {
+            if 
(!method.getAnnotations(ClassHelper.make(Bean.class)).isEmpty()) {
+                beanMethodsByName.put(method.getName(), method);
+            }
+        }
+        if (beanMethodsByName.isEmpty() || beanMethodsAreProxied(host)) {

Review Comment:
   Valid, fixed in c2768dd.
   
   Confirmed the premise behaviourally rather than taking it on trust: a 
hand-written `@Configuration` with a static `@Bean` and a sibling call hands 
out two distinct instances. That test is now in the spec, with no DSL involved 
— it is the premise the whole rule rests on, so it should fail loudly if Spring 
ever changes it.
   
   Took your suggested shape: the bean-method map is built first and narrowed 
rather than abandoned. On a proxied host it keeps only the static bean methods; 
on any other host, all of them. A non-static sibling call on a full 
`@Configuration` is still allowed, which is the exemption that host genuinely 
earns.
   
   The message now names the actual cause in the static case, since "is not a 
proxied @Configuration class" would be untrue there:
   
   > `"greeter(...)" is another bean declared in this block, and is declared 
.staticMethod(). A static @Bean method is never intercepted by the container - 
not even on a proxied @Configuration class like ProxiedStaticSiblingBeans - 
because that interception is CGLIB subclassing, which cannot override a static 
method, so this call does not return the bean Spring registered - it constructs 
a second instance. …`



##########
grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java:
##########
@@ -878,6 +890,118 @@ private void processBeanStatement(ClassNode classNode, 
MethodCallExpression oute
         classNode.addMethod(beanMethod);
     }
 
+    // The methods this block just generated, in declaration order: everything 
on the host that was
+    // not there before the two processing loops ran. MethodNode does not 
override equals, so the
+    // removal is by identity and cannot drop a same-signature method the user 
wrote.
+    private List<MethodNode> generatedMembers(ClassNode host, List<MethodNode> 
preExisting) {
+        List<MethodNode> generated = new ArrayList<>(host.getMethods());
+        generated.removeAll(preExisting);
+        return generated;
+    }
+
+    /**
+     * Rejects a call from one generated method to another generated {@code 
@Bean} method, on a host
+     * whose bean methods Spring does not proxy.
+     *
+     * <p>Calling a sibling {@code @Bean} method and getting the singleton 
back is a CGLIB trick, and
+     * Spring only plays it for a full {@code @Configuration} class. On a 
<i>lite</i> configuration
+     * source the same call is a plain Java call that constructs a second 
instance - and lite is the
+     * common case for this DSL: {@code @AutoConfiguration} is
+     * {@code @Configuration(proxyBeanMethods = false)}, the sibling generated 
for a plugin descriptor
+     * carries exactly that, and a Grails {@code Application} class is a 
configuration source without
+     * being annotated {@code @Configuration} at all.</p>
+     *
+     * <p>Nothing about that failure is visible at runtime. The context 
starts, every bean exists, and
+     * two objects live where the author meant one - so a listener registers 
on the wrong instance, or
+     * configuration applied to one is missing from the other. It is also the 
exact mistake a
+     * migration invites, since moving bean methods off a real {@code 
@Configuration} class into this
+     * DSL silently changes what those calls mean.</p>
+     */
+    private void rejectUnproxiedSiblingBeanCalls(ClassNode host, 
List<MethodNode> generated, SourceUnit source) {
+        Map<String, MethodNode> beanMethodsByName = new LinkedHashMap<>();
+        for (MethodNode method : generated) {
+            if 
(!method.getAnnotations(ClassHelper.make(Bean.class)).isEmpty()) {
+                beanMethodsByName.put(method.getName(), method);
+            }
+        }
+        if (beanMethodsByName.isEmpty() || beanMethodsAreProxied(host)) {
+            return;
+        }
+        for (MethodNode method : generated) {
+            if (method.getCode() == null) {
+                continue;
+            }
+            MethodNode caller = method;
+            method.getCode().visit(new CodeVisitorSupport() {
+                // Deliberately not descending. Inside a closure an 
unqualified call is resolved
+                // against the delegate first, so `new Registry().tap { 
initialize() }` calls the
+                // registry - not this class - even though the AST records 
implicit-this either way.
+                // Reading that as a sibling bean call would reject working 
code, which is a far
+                // worse trade than missing the rare bean call written inside 
a nested closure.
+                @Override
+                public void visitClosureExpression(ClosureExpression 
expression) {
+                }
+
+                @Override
+                public void visitMethodCallExpression(MethodCallExpression 
call) {
+                    super.visitMethodCallExpression(call);
+                    if (!isSelfCall(call)) {
+                        return;
+                    }
+                    MethodNode target = 
beanMethodsByName.get(call.getMethodAsString());
+                    if (target == null || target == caller) {
+                        return;
+                    }
+                    addError(call, source, "\"" + call.getMethodAsString() + 
"(...)\" is another bean declared " +
+                            "in this block, and " + 
host.getNameWithoutPackage() + " is not a proxied " +
+                            "@Configuration class, so this call does not 
return the bean Spring registered - it " +
+                            "constructs a second instance. Inject it instead, 
by declaring it as a parameter of " +
+                            "this closure; if what you want is shared logic 
rather than the bean, move it into a " +
+                            "method(...) declaration.");
+                }
+            });
+        }
+    }
+
+    // An unqualified call, or one written against this. Anything with a real 
receiver is somebody
+    // else's method that happens to share the name.
+    private boolean isSelfCall(MethodCallExpression call) {
+        return call.isImplicitThis() ||
+                (call.getObjectExpression() instanceof VariableExpression &&
+                        ((VariableExpression) 
call.getObjectExpression()).isThisExpression());
+    }
+
+    // Whether Spring will CGLIB-proxy this host's @Bean methods: true only 
when @Configuration is
+    // reachable from the class's own annotations without passing through one 
that sets
+    // proxyBeanMethods = false. @AutoConfiguration answers false through that 
second clause - its
+    // meta-annotation is @Configuration(proxyBeanMethods = false) - and a 
Grails Application class
+    // answers false by carrying no @Configuration at all.
+    private boolean beanMethodsAreProxied(ClassNode host) {
+        return proxiesBeanMethods(host.getAnnotations(), new HashSet<>());
+    }
+
+    private boolean proxiesBeanMethods(List<AnnotationNode> annotations, 
Set<String> visited) {
+        for (AnnotationNode annotation : annotations) {
+            ClassNode type = annotation.getClassNode();
+            if (type.getName().startsWith("java.lang.annotation.") || 
!visited.add(type.getName())) {

Review Comment:
   Valid, fixed in 6fbfd28.
   
   You are right that it is wrong in principle regardless of how exotic the 
triggering pair is: `visited` exists to stop a cycle in the meta-annotation 
graph, and that only needs recording on the path that actually recurses. 
Memoizing a branch that was pruned records an answer that was never computed.
   
   Restructured exactly as you describe — the `@Configuration` test and the 
`proxyBeanMethods = false` prune both happen before anything is recorded, and 
only the descent records:
   
   ```java
   if (type.getName().startsWith("java.lang.annotation.")) continue;
   if (isFalseConstant(annotation.getMember(PROXY_BEAN_METHODS_MEMBER))) 
continue;
   if (Configuration.class.getName().equals(type.getName())) return true;
   if (visited.add(type.getName()) && proxiesBeanMethods(type.getAnnotations(), 
visited)) return true;
   ```
   
   Regression test uses your `@AutoConfiguration @Configuration` pairing. I 
reverted the walk to the old form and reran it to confirm it actually fails 
without the fix, rather than assuming it does.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to