codeconsole commented on code in PR #16292:
URL: https://github.com/apache/grails-core/pull/16292#discussion_r3972533969
##########
grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java:
##########
@@ -913,35 +1660,221 @@ private Statement synthesizedConstruction(ClassNode
beanType, Parameter[] parame
return returnStatement;
}
- private String syntheticBeanMethodName(ClassNode beanType, Set<String>
usedNames) {
- String base = decapitalize(beanType.getNameWithoutPackage());
- String candidate;
- int index = 0;
- do {
- candidate = base + "$" + index;
- index++;
- }
- while (usedNames.contains(candidate));
- return candidate;
+ /**
+ * Whether {@code candidate} is a {@code target}. Used for the
implementation type, where
+ * checking it here rather than leaving it to the generated {@code return
new Impl()} means the
+ * failure names both types and points at the {@code bean(...)} statement
instead of surfacing as
+ * an assignment error inside a body the author never wrote.
+ */
+ private boolean isSubtypeOf(ClassNode candidate, ClassNode target) {
+ ClassNode resolved = target.redirect();
+ return candidate.redirect().equals(resolved) ||
candidate.isDerivedFrom(resolved) ||
+ candidate.implementsInterface(resolved);
}
- private void processFieldStatement(ClassNode classNode,
MethodCallExpression baseCall,
- List<MethodCallExpression> qualifierCalls, SourceUnit source,
Set<String> usedNames) {
- List<Expression> baseArgs = flatten(baseCall.getArguments());
- TypeAndName typeAndName = parseNameAndType(baseArgs, baseCall, source,
FIELD_CALL, true);
- if (typeAndName == null) {
- return;
+ private boolean hasExplicitTypeArguments(List<MethodCallExpression>
qualifierCalls) {
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ if (TYPE_ARGUMENTS_CALL.equals(qualifierCall.getMethodAsString()))
{
+ return true;
+ }
}
- if (!registerName(typeAndName.name, baseCall, source, usedNames,
+ return false;
+ }
+
+ /**
+ * The type a factory closure constructs, when its body is exactly that
and nothing else: a last
+ * statement that is a {@code new ...} expression, which in Groovy is the
closure's return value.
+ * Anything else - a local, a method call, a conditional - is not evidence
of anything, and this
+ * returns null rather than guess.
+ */
+ private ClassNode constructedTypeFromBody(ClosureExpression factory) {
+ if (factory == null || !(factory.getCode() instanceof BlockStatement))
{
+ return null;
+ }
+ List<Statement> statements = ((BlockStatement)
factory.getCode()).getStatements();
+ if (statements.isEmpty()) {
+ return null;
+ }
+ Statement last = statements.get(statements.size() - 1);
+ Expression expression = null;
+ if (last instanceof ReturnStatement) {
+ expression = ((ReturnStatement) last).getExpression();
+ }
+ else if (last instanceof ExpressionStatement) {
+ expression = ((ExpressionStatement) last).getExpression();
+ }
+ return expression instanceof ConstructorCallExpression ?
expression.getType() : null;
+ }
+
+ /**
+ * The declared type parameterized by what {@code evidence} binds it to,
or null when that cannot
+ * be answered concretely - {@code evidence} is unrelated, the declared
type is not generic, or
+ * the binding is itself a type variable ({@code class Box<T> implements
Holder<T>} proves
+ * nothing about a {@code Holder} bean). Inference only ever adds
information the compiler could
+ * already see; where it cannot, the raw type stands exactly as before and
+ * {@code .typeArguments(...)} remains the way to say it.
+ */
+ private ClassNode inferTypeArguments(ClassNode declaredRaw, ClassNode
evidence) {
+ GenericsType[] declared = declaredRaw.redirect().getGenericsTypes();
+ if (evidence == null || declared == null || declared.length == 0) {
+ return null;
+ }
+ if (!isSubtypeOf(evidence, declaredRaw)) {
+ return null;
+ }
+ // A raw construction of a generic type proves nothing: Groovy
resolves its parameters to
+ // their bounds, so new GenericBox() would infer Holder<Object> - not
merely uninformative
+ // but wrong, since a bean typed Holder<Object> no longer matches a
Holder<String> injection
+ // point it previously did as a raw Holder.
+ GenericsType[] evidenceParameters =
evidence.redirect().getGenericsTypes();
+ if (evidenceParameters != null && evidenceParameters.length > 0 &&
+ (evidence.getGenericsTypes() == null ||
evidence.getGenericsTypes().length == 0)) {
+ return null;
+ }
+ ClassNode parameterized;
+ try {
+ parameterized = GenericsUtils.parameterizeType(evidence,
declaredRaw.redirect());
+ }
+ catch (RuntimeException ignored) {
+ // parameterizeType is best-effort on partially resolved
hierarchies; an unusable answer
+ // is the same as no answer.
+ return null;
+ }
+ GenericsType[] resolved = parameterized == null ? null :
parameterized.getGenericsTypes();
+ if (resolved == null || resolved.length != declared.length) {
+ return null;
+ }
+ for (GenericsType candidate : resolved) {
+ if (candidate.isPlaceholder() || candidate.isWildcard() ||
candidate.getType() == null ||
+ candidate.getType().isGenericsPlaceHolder()) {
+ return null;
+ }
+ }
+ return GenericsUtils.makeClassSafeWithGenerics(declaredRaw, resolved);
+ }
+
+ /**
+ * Re-homes anonymous inner classes in a body lifted out of the {@code
beans} closure.
+ *
+ * <p>Groovy's {@code InnerClassVisitor} runs at SEMANTIC_ANALYSIS, before
this transform, and
+ * gives an anonymous class its enclosing instance from wherever it was
written: a class
+ * declared inside a closure gets {@code final Closure this$0} and a
constructor taking a
+ * {@code Closure}, where one declared in a method gets the declaring
class. Lifting the body
+ * into a method moves the code and not that decision, so the generated
call passes {@code this}
+ * - the configuration class - to a constructor still expecting the
closure. It compiles, and
+ * fails at runtime with a {@code GroovyCastException} naming neither the
bean nor the DSL.</p>
+ *
+ * <p>So the three places that decision landed are corrected here: the
{@code this$0} field, the
+ * synthetic constructor's first parameter, and the argument at the call
site.</p>
+ *
+ * <p>What this cannot correct is the anonymous class's <i>outer
class</i>, which
+ * {@code InnerClassNode} fixes at construction. On a plugin descriptor
the members move to a
+ * sibling while the anonymous class stays homed on the descriptor, so the
MOP dispatch methods
+ * Groovy generates for it read a {@code this$0} typed as the descriptor
while the field now
+ * holds the sibling. An anonymous class that touches only its own members
and what it inherits
+ * is fine; one that reaches a member declared in the block is rejected by
+ * {@link #rejectAnonymousClassReachingMovedMembers}, rather than left to
fail with
+ * {@code NoSuchFieldError} inside a running application.</p>
+ */
+ private boolean rehomeAnonymousInnerClasses(Statement body, ClassNode
host, boolean staticMethod,
+ String beanName, SourceUnit source) {
+ List<ConstructorCallExpression> anonymous = new ArrayList<>();
+ body.visit(new CodeVisitorSupport() {
+ // Deliberately not descending. The lift moves the closure's own
body into a method, so
+ // an anonymous class written directly in it loses the closure it
was homed against -
+ // that is what this repairs. One written inside a NESTED closure
does not: that closure
+ // survives the lift and is still its enclosing instance, so
re-homing it would rewrite a
+ // correct `this` into the configuration class and fail at runtime
with the very
+ // GroovyCastException this method exists to prevent - or, under
.staticMethod(), reject
+ // a body that has an enclosing instance and compiles perfectly
well.
+ @Override
+ public void visitClosureExpression(ClosureExpression expression) {
+ }
+
+ @Override
+ public void
visitConstructorCallExpression(ConstructorCallExpression call) {
+ if (call.isUsingAnonymousInnerClass()) {
+ anonymous.add(call);
+ }
+ super.visitConstructorCallExpression(call);
+ }
+ });
+ for (ConstructorCallExpression call : anonymous) {
+ ClassNode inner = call.getType();
Review Comment:
Fixed in 67d20dc. `inner.setEnclosingMethod(liftedMethod)` alongside the
`this$0` field, the constructor parameter and the call argument the lift
already corrects, with the bean/helper `MethodNode` threaded in from both call
sites.
Coverage added for a `@CompileStatic` plugin descriptor with an anonymous
class in a top-level bean body, and for a `group(...)` bean body constructing
one on both host kinds, dynamic and `@CompileStatic`. All the `@CompileStatic`
shapes fail with the `GroovyBugError` when the one line is backed out.
##########
grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java:
##########
@@ -875,9 +1208,423 @@ private void processBeanStatement(ClassNode classNode,
MethodCallExpression oute
}
}
+ if (!rejectNonStaticPostProcessor(beanMethod, beanType, baseCall,
source)) {
+ return;
+ }
+
+ if (!rehomeAnonymousInnerClasses(beanBody, classNode,
Modifier.isStatic(beanMethod.getModifiers()),
+ typeAndName.name, source)) {
+ return;
+ }
+
classNode.addMethod(beanMethod);
}
+ private static final String BEAN_FACTORY_POST_PROCESSOR =
"org.springframework.beans.factory.config.BeanFactoryPostProcessor";
+ private static final String BEAN_POST_PROCESSOR =
"org.springframework.beans.factory.config.BeanPostProcessor";
+
+ /**
+ * A {@code BeanFactoryPostProcessor}/{@code BeanPostProcessor} bean must
be creatable without
+ * instantiating its declaring class, because Spring has to obtain it
before the ordinary bean
+ * lifecycle it participates in has started. Declared as an instance
method it still "works",
+ * which is the problem: the configuration class is instantiated far too
early, taking every bean
+ * its methods depend on with it, out of order and past the
post-processors that would have
+ * configured them - a class of startup bug that shows up as an unrelated
bean being unconfigured
+ * rather than as anything pointing here.
+ *
+ * <p>{@code .staticMethod()} is the fix and is already in the DSL; this
only stops the mistake
+ * being silent. An instance-bound post-processor, if one is genuinely
wanted, is still writable
+ * as an ordinary {@code @Bean} method on the same class - the block does
not claim them.</p>
+ */
+ private boolean rejectNonStaticPostProcessor(MethodNode beanMethod,
ClassNode beanType,
+ ASTNode location, SourceUnit source) {
+ if (Modifier.isStatic(beanMethod.getModifiers())) {
+ return true;
+ }
+ String postProcessorType = null;
+ if (isSubtypeOf(beanType,
ClassHelper.make(BEAN_FACTORY_POST_PROCESSOR))) {
+ postProcessorType = "BeanFactoryPostProcessor";
+ }
+ else if (isSubtypeOf(beanType, ClassHelper.make(BEAN_POST_PROCESSOR)))
{
+ postProcessorType = "BeanPostProcessor";
+ }
+ if (postProcessorType == null) {
+ return true;
+ }
+ addError(location, source, "a " + postProcessorType + " bean must be
declared " +
+ ".staticMethod(), so Spring can obtain it without
instantiating this class - as an " +
+ "instance method it forces that instantiation before the beans
it post-processes " +
+ "are configured");
+ return false;
+ }
+
+ /**
+ * Writes the members this block generated to {@code
-Dgrails.beans.dsl.dumpdir=<dir>}, one file
+ * per host class.
+ *
+ * <p>Everything the DSL decides that the source does not say is a
declaration, not a body: the
+ * bean name Spring will resolve by, the annotations the qualifiers
became, the modifiers, the
+ * declared type and whether it ended up carrying type arguments, and the
parameter annotations
+ * that make a dependency optional or qualified. Bodies are excluded on
purpose - a bean body is
+ * the author's own closure body, lifted verbatim, so it is already
readable where they wrote it.
+ *
+ * <p>Without this, the only way to see any of it is {@code javap} on the
compiled class, which
+ * is a poor place to answer "did that qualifier attach anything" while
writing the block.
+ * Grails already takes this shape for its other compile-time generator,
where
+ * {@code grails.views.gsp.keepgenerateddir} keeps the Groovy a GSP
compiles to.</p>
+ */
+ private void dumpGeneratedMembers(ClassNode host, List<MethodNode>
methods, List<FieldNode> fields,
+ SourceUnit source) {
+ String dir = System.getProperty(DUMP_DIR_PROPERTY);
+ if (dir == null || dir.isBlank()) {
+ return;
+ }
+ StringBuilder text = new StringBuilder();
+ text.append("// Generated from the 'beans' DSL in
").append(host.getName()).append('\n');
+ text.append("// Bodies are omitted: each is the closure body from that
source, lifted verbatim.\n");
+ for (FieldNode field : fields) {
+ text.append('\n');
+ for (AnnotationNode annotation : field.getAnnotations()) {
+ text.append(annotationText(annotation)).append('\n');
+ }
+
text.append(AstToTextHelper.getModifiersText(field.getModifiers())).append(' ')
+ .append(typeText(field.getType())).append('
').append(field.getName()).append('\n');
+ }
+ for (MethodNode method : methods) {
+ text.append('\n');
+ for (AnnotationNode annotation : method.getAnnotations()) {
+ text.append(annotationText(annotation)).append('\n');
+ }
+
text.append(AstToTextHelper.getModifiersText(method.getModifiers())).append(' ')
+ .append(typeText(method.getReturnType())).append('
').append(method.getName())
+
.append('(').append(parametersText(method.getParameters())).append(")\n");
+ }
+ try {
+ Path target = Paths.get(dir);
+ Files.createDirectories(target);
+ Files.writeString(target.resolve(host.getName() + ".beans.txt"),
text.toString(),
+ StandardCharsets.UTF_8);
+ }
+ catch (IOException | RuntimeException e) {
+ // Opt-in by definition, so this can only fire for someone who
asked for the dump and
+ // would otherwise be left looking for a file that was never
written.
+ addError(host, source, "could not write the beans DSL dump for " +
host.getName() + " to \"" +
+ dir + "\" (" + DUMP_DIR_PROPERTY + "): " + e);
+ }
+ }
+
+ private String parametersText(Parameter[] parameters) {
+ StringBuilder text = new StringBuilder();
+ for (Parameter parameter : parameters) {
+ if (text.length() > 0) {
+ text.append(", ");
+ }
+ for (AnnotationNode annotation : parameter.getAnnotations()) {
+ text.append(annotationText(annotation)).append(' ');
+ }
+ text.append(typeText(parameter.getType())).append('
').append(parameter.getName());
+ }
+ return text.toString();
+ }
+
+ // Type arguments are printed only when every one of them is concrete. A
raw declared type
+ // resolved from a class still reports its own type PARAMETERS here, and
printing those would
+ // read as <String> when nothing of the sort was declared.
+ private String typeText(ClassNode type) {
+ GenericsType[] generics = type.getGenericsTypes();
+ if (generics == null || generics.length == 0) {
+ return type.getName();
+ }
+ StringBuilder text = new StringBuilder(type.getName());
+ for (GenericsType generic : generics) {
+ if (generic.isPlaceholder() || generic.isWildcard()) {
+ return type.getName();
+ }
+ }
+ text.append('<');
+ for (int i = 0; i < generics.length; i++) {
+ text.append(i == 0 ? "" : ",
").append(generics[i].getType().getName());
+ }
+ return text.append('>').toString();
+ }
+
+ private String annotationText(AnnotationNode annotation) {
+ StringBuilder text = new
StringBuilder("@").append(annotation.getClassNode().getNameWithoutPackage());
+ Map<String, Expression> members = annotation.getMembers();
+ if (members.isEmpty()) {
+ return text.toString();
+ }
+ text.append('(');
+ boolean first = true;
+ for (Map.Entry<String, Expression> member : members.entrySet()) {
+ text.append(first ? "" : ", ").append(member.getKey()).append(" =
")
+ .append(memberValueText(member.getValue()));
+ first = false;
+ }
+ return text.append(')').toString();
+ }
+
+ // Expression.getText() renders a String constant bare, so
@DependsOn("names") would print as
+ // value = names and read as an identifier. Quote them, and descend into a
list so an
+ // array-valued attribute reads the way it was written.
+ private String memberValueText(Expression value) {
+ if (value instanceof ConstantExpression && ((ConstantExpression)
value).getValue() instanceof String) {
+ return "\"" + ((ConstantExpression) value).getValue() + "\"";
+ }
+ if (value instanceof ListExpression) {
+ StringBuilder text = new StringBuilder("[");
+ List<Expression> entries = ((ListExpression)
value).getExpressions();
+ for (int i = 0; i < entries.size(); i++) {
+ text.append(i == 0 ? "" : ",
").append(memberValueText(entries.get(i)));
+ }
+ return text.append(']').toString();
+ }
+ return value.getText();
+ }
+
+ // 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>A full {@code @Configuration} class is not wholly exempt: the
interception is CGLIB
+ * subclassing, so it cannot override a {@code static} method, and Spring
documents that calls to
+ * a static {@code @Bean} method are never intercepted - not even there. A
{@code .staticMethod()}
+ * bean is therefore checked on every host, and is the only thing checked
on a proxied one.</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) {
+ boolean proxied = beanMethodsAreProxied(host);
+ Map<String, MethodNode> beanMethodsByName = new LinkedHashMap<>();
+ // Every @Bean method on the host, not only the ones this block
generated. A class that
+ // mixes hand-written @Bean methods with the DSL is what a migration
looks like midway
+ // through, and a call to one of those from a generated body misses
the singleton in exactly
+ // the same way - more easily, in fact, since it was correct in the
@Configuration class the
+ // beans are being moved out of. Only generated bodies are scanned:
what a hand-written
+ // method does is its author's business, not this transform's.
+ for (MethodNode method : host.getMethods()) {
+ if (method.getAnnotations(ClassHelper.make(Bean.class)).isEmpty())
{
+ continue;
+ }
+ // A proxied host still cannot intercept a .staticMethod() bean:
the interception is
+ // CGLIB subclassing, and a static method cannot be overridden. So
on a full
+ // @Configuration class those are the only sibling calls still
worth rejecting.
+ if (!proxied || method.isStatic()) {
+ beanMethodsByName.put(method.getName(), method);
+ }
+ }
+ if (beanMethodsByName.isEmpty()) {
+ 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, siblingCallCause(host,
call.getMethodAsString(), proxied) +
+ ", 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.");
+ }
+ });
+ }
+ }
+
+ // Why this particular call misses the singleton. On a proxied host the
map holds only static
+ // bean methods, so reaching here means the target is one.
+ private String siblingCallCause(ClassNode host, String name, boolean
proxied) {
+ if (proxied) {
+ return "\"" + name + "(...)\" 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 " +
host.getNameWithoutPackage() + " - because " +
+ "that interception is CGLIB subclassing, which cannot
override a static method";
+ }
+ return "\"" + name + "(...)\" is another bean declared in this block,
and " +
+ host.getNameWithoutPackage() + " is not a proxied
@Configuration class";
+ }
+
+ /**
+ * Rejects an unqualified reference from an anonymous inner class in a
bean body to a member that
+ * moved to the generated sibling.
+ *
+ * <p>Only on a plugin descriptor, and only because the anonymous class
cannot follow. Groovy
+ * fixes an inner class's outer class when it creates the node and offers
no way to move it, so
+ * the class stays homed on the descriptor while the members it wants are
on the sibling. Its MOP
+ * dispatch methods then read a {@code this$0} typed as the descriptor
where the field holds the
+ * sibling, and the reference fails with {@code NoSuchFieldError} inside a
running application -
+ * or, under {@code @CompileStatic}, as a "cannot find matching method"
naming a synthetic class
+ * nobody wrote.</p>
+ *
+ * <p>The test is the narrow one: a name that is <i>both</i> a member this
block generated and
+ * not resolvable on the anonymous class itself or anything it inherits. A
call to the anonymous
+ * class's own method, or to one from the interface it implements, is left
alone - it is only the
+ * names that actually moved that cannot be reached.</p>
+ */
+ private void rejectAnonymousClassReachingMovedMembers(ClassNode host,
ClassNode declaringClass,
+ List<MethodNode> generatedMethods, List<FieldNode>
generatedFields, SourceUnit source) {
+ if (host == declaringClass) {
Review Comment:
Fixed in 191830e. The check runs for each group as well as the sibling, and
the early return moved to the sibling call site.
A group passes no moved set: it is a static nested class with no enclosing
instance behind it, so anything the anonymous class cannot resolve on itself or
what it inherits is rejected. Tests for a group-level member and a host-level
member, on a plain host and on a descriptor, plus a self-contained control and
a captured-local control — the two ways the message names.
##########
grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java:
##########
@@ -875,9 +1208,423 @@ private void processBeanStatement(ClassNode classNode,
MethodCallExpression oute
}
}
+ if (!rejectNonStaticPostProcessor(beanMethod, beanType, baseCall,
source)) {
+ return;
+ }
+
+ if (!rehomeAnonymousInnerClasses(beanBody, classNode,
Modifier.isStatic(beanMethod.getModifiers()),
+ typeAndName.name, source)) {
+ return;
+ }
+
classNode.addMethod(beanMethod);
}
+ private static final String BEAN_FACTORY_POST_PROCESSOR =
"org.springframework.beans.factory.config.BeanFactoryPostProcessor";
+ private static final String BEAN_POST_PROCESSOR =
"org.springframework.beans.factory.config.BeanPostProcessor";
+
+ /**
+ * A {@code BeanFactoryPostProcessor}/{@code BeanPostProcessor} bean must
be creatable without
+ * instantiating its declaring class, because Spring has to obtain it
before the ordinary bean
+ * lifecycle it participates in has started. Declared as an instance
method it still "works",
+ * which is the problem: the configuration class is instantiated far too
early, taking every bean
+ * its methods depend on with it, out of order and past the
post-processors that would have
+ * configured them - a class of startup bug that shows up as an unrelated
bean being unconfigured
+ * rather than as anything pointing here.
+ *
+ * <p>{@code .staticMethod()} is the fix and is already in the DSL; this
only stops the mistake
+ * being silent. An instance-bound post-processor, if one is genuinely
wanted, is still writable
+ * as an ordinary {@code @Bean} method on the same class - the block does
not claim them.</p>
+ */
+ private boolean rejectNonStaticPostProcessor(MethodNode beanMethod,
ClassNode beanType,
+ ASTNode location, SourceUnit source) {
+ if (Modifier.isStatic(beanMethod.getModifiers())) {
+ return true;
+ }
+ String postProcessorType = null;
+ if (isSubtypeOf(beanType,
ClassHelper.make(BEAN_FACTORY_POST_PROCESSOR))) {
+ postProcessorType = "BeanFactoryPostProcessor";
+ }
+ else if (isSubtypeOf(beanType, ClassHelper.make(BEAN_POST_PROCESSOR)))
{
+ postProcessorType = "BeanPostProcessor";
+ }
+ if (postProcessorType == null) {
+ return true;
+ }
+ addError(location, source, "a " + postProcessorType + " bean must be
declared " +
+ ".staticMethod(), so Spring can obtain it without
instantiating this class - as an " +
+ "instance method it forces that instantiation before the beans
it post-processes " +
+ "are configured");
+ return false;
+ }
+
+ /**
+ * Writes the members this block generated to {@code
-Dgrails.beans.dsl.dumpdir=<dir>}, one file
+ * per host class.
+ *
+ * <p>Everything the DSL decides that the source does not say is a
declaration, not a body: the
+ * bean name Spring will resolve by, the annotations the qualifiers
became, the modifiers, the
+ * declared type and whether it ended up carrying type arguments, and the
parameter annotations
+ * that make a dependency optional or qualified. Bodies are excluded on
purpose - a bean body is
+ * the author's own closure body, lifted verbatim, so it is already
readable where they wrote it.
+ *
+ * <p>Without this, the only way to see any of it is {@code javap} on the
compiled class, which
+ * is a poor place to answer "did that qualifier attach anything" while
writing the block.
+ * Grails already takes this shape for its other compile-time generator,
where
+ * {@code grails.views.gsp.keepgenerateddir} keeps the Groovy a GSP
compiles to.</p>
+ */
+ private void dumpGeneratedMembers(ClassNode host, List<MethodNode>
methods, List<FieldNode> fields,
+ SourceUnit source) {
+ String dir = System.getProperty(DUMP_DIR_PROPERTY);
+ if (dir == null || dir.isBlank()) {
+ return;
+ }
+ StringBuilder text = new StringBuilder();
+ text.append("// Generated from the 'beans' DSL in
").append(host.getName()).append('\n');
+ text.append("// Bodies are omitted: each is the closure body from that
source, lifted verbatim.\n");
+ for (FieldNode field : fields) {
+ text.append('\n');
+ for (AnnotationNode annotation : field.getAnnotations()) {
+ text.append(annotationText(annotation)).append('\n');
+ }
+
text.append(AstToTextHelper.getModifiersText(field.getModifiers())).append(' ')
+ .append(typeText(field.getType())).append('
').append(field.getName()).append('\n');
+ }
+ for (MethodNode method : methods) {
+ text.append('\n');
+ for (AnnotationNode annotation : method.getAnnotations()) {
+ text.append(annotationText(annotation)).append('\n');
+ }
+
text.append(AstToTextHelper.getModifiersText(method.getModifiers())).append(' ')
+ .append(typeText(method.getReturnType())).append('
').append(method.getName())
+
.append('(').append(parametersText(method.getParameters())).append(")\n");
+ }
+ try {
+ Path target = Paths.get(dir);
+ Files.createDirectories(target);
+ Files.writeString(target.resolve(host.getName() + ".beans.txt"),
text.toString(),
+ StandardCharsets.UTF_8);
+ }
+ catch (IOException | RuntimeException e) {
+ // Opt-in by definition, so this can only fire for someone who
asked for the dump and
+ // would otherwise be left looking for a file that was never
written.
+ addError(host, source, "could not write the beans DSL dump for " +
host.getName() + " to \"" +
+ dir + "\" (" + DUMP_DIR_PROPERTY + "): " + e);
+ }
+ }
+
+ private String parametersText(Parameter[] parameters) {
+ StringBuilder text = new StringBuilder();
+ for (Parameter parameter : parameters) {
+ if (text.length() > 0) {
+ text.append(", ");
+ }
+ for (AnnotationNode annotation : parameter.getAnnotations()) {
+ text.append(annotationText(annotation)).append(' ');
+ }
+ text.append(typeText(parameter.getType())).append('
').append(parameter.getName());
+ }
+ return text.toString();
+ }
+
+ // Type arguments are printed only when every one of them is concrete. A
raw declared type
+ // resolved from a class still reports its own type PARAMETERS here, and
printing those would
+ // read as <String> when nothing of the sort was declared.
+ private String typeText(ClassNode type) {
+ GenericsType[] generics = type.getGenericsTypes();
+ if (generics == null || generics.length == 0) {
+ return type.getName();
+ }
+ StringBuilder text = new StringBuilder(type.getName());
+ for (GenericsType generic : generics) {
+ if (generic.isPlaceholder() || generic.isWildcard()) {
+ return type.getName();
+ }
+ }
+ text.append('<');
+ for (int i = 0; i < generics.length; i++) {
+ text.append(i == 0 ? "" : ",
").append(generics[i].getType().getName());
+ }
+ return text.append('>').toString();
+ }
+
+ private String annotationText(AnnotationNode annotation) {
+ StringBuilder text = new
StringBuilder("@").append(annotation.getClassNode().getNameWithoutPackage());
+ Map<String, Expression> members = annotation.getMembers();
+ if (members.isEmpty()) {
+ return text.toString();
+ }
+ text.append('(');
+ boolean first = true;
+ for (Map.Entry<String, Expression> member : members.entrySet()) {
+ text.append(first ? "" : ", ").append(member.getKey()).append(" =
")
+ .append(memberValueText(member.getValue()));
+ first = false;
+ }
+ return text.append(')').toString();
+ }
+
+ // Expression.getText() renders a String constant bare, so
@DependsOn("names") would print as
+ // value = names and read as an identifier. Quote them, and descend into a
list so an
+ // array-valued attribute reads the way it was written.
+ private String memberValueText(Expression value) {
+ if (value instanceof ConstantExpression && ((ConstantExpression)
value).getValue() instanceof String) {
+ return "\"" + ((ConstantExpression) value).getValue() + "\"";
+ }
+ if (value instanceof ListExpression) {
+ StringBuilder text = new StringBuilder("[");
+ List<Expression> entries = ((ListExpression)
value).getExpressions();
+ for (int i = 0; i < entries.size(); i++) {
+ text.append(i == 0 ? "" : ",
").append(memberValueText(entries.get(i)));
+ }
+ return text.append(']').toString();
+ }
+ return value.getText();
+ }
+
+ // 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>A full {@code @Configuration} class is not wholly exempt: the
interception is CGLIB
+ * subclassing, so it cannot override a {@code static} method, and Spring
documents that calls to
+ * a static {@code @Bean} method are never intercepted - not even there. A
{@code .staticMethod()}
+ * bean is therefore checked on every host, and is the only thing checked
on a proxied one.</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) {
+ boolean proxied = beanMethodsAreProxied(host);
+ Map<String, MethodNode> beanMethodsByName = new LinkedHashMap<>();
+ // Every @Bean method on the host, not only the ones this block
generated. A class that
+ // mixes hand-written @Bean methods with the DSL is what a migration
looks like midway
+ // through, and a call to one of those from a generated body misses
the singleton in exactly
+ // the same way - more easily, in fact, since it was correct in the
@Configuration class the
+ // beans are being moved out of. Only generated bodies are scanned:
what a hand-written
+ // method does is its author's business, not this transform's.
+ for (MethodNode method : host.getMethods()) {
+ if (method.getAnnotations(ClassHelper.make(Bean.class)).isEmpty())
{
+ continue;
+ }
+ // A proxied host still cannot intercept a .staticMethod() bean:
the interception is
+ // CGLIB subclassing, and a static method cannot be overridden. So
on a full
+ // @Configuration class those are the only sibling calls still
worth rejecting.
+ if (!proxied || method.isStatic()) {
+ beanMethodsByName.put(method.getName(), method);
+ }
+ }
+ if (beanMethodsByName.isEmpty()) {
+ 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, siblingCallCause(host,
call.getMethodAsString(), proxied) +
+ ", 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.");
+ }
+ });
+ }
+ }
+
+ // Why this particular call misses the singleton. On a proxied host the
map holds only static
+ // bean methods, so reaching here means the target is one.
+ private String siblingCallCause(ClassNode host, String name, boolean
proxied) {
+ if (proxied) {
+ return "\"" + name + "(...)\" 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 " +
host.getNameWithoutPackage() + " - because " +
+ "that interception is CGLIB subclassing, which cannot
override a static method";
+ }
+ return "\"" + name + "(...)\" is another bean declared in this block,
and " +
+ host.getNameWithoutPackage() + " is not a proxied
@Configuration class";
+ }
+
+ /**
+ * Rejects an unqualified reference from an anonymous inner class in a
bean body to a member that
+ * moved to the generated sibling.
+ *
+ * <p>Only on a plugin descriptor, and only because the anonymous class
cannot follow. Groovy
+ * fixes an inner class's outer class when it creates the node and offers
no way to move it, so
+ * the class stays homed on the descriptor while the members it wants are
on the sibling. Its MOP
+ * dispatch methods then read a {@code this$0} typed as the descriptor
where the field holds the
+ * sibling, and the reference fails with {@code NoSuchFieldError} inside a
running application -
+ * or, under {@code @CompileStatic}, as a "cannot find matching method"
naming a synthetic class
+ * nobody wrote.</p>
+ *
+ * <p>The test is the narrow one: a name that is <i>both</i> a member this
block generated and
+ * not resolvable on the anonymous class itself or anything it inherits. A
call to the anonymous
+ * class's own method, or to one from the interface it implements, is left
alone - it is only the
+ * names that actually moved that cannot be reached.</p>
+ */
+ private void rejectAnonymousClassReachingMovedMembers(ClassNode host,
ClassNode declaringClass,
+ List<MethodNode> generatedMethods, List<FieldNode>
generatedFields, SourceUnit source) {
+ if (host == declaringClass) {
+ // Not a plugin descriptor: the members and the anonymous class
share a home, so an
+ // unqualified reference resolves the way it reads.
+ return;
+ }
+ Set<String> moved = new HashSet<>();
+ for (MethodNode method : generatedMethods) {
+ moved.add(method.getName());
+ }
+ for (FieldNode field : generatedFields) {
+ moved.add(field.getName());
+ }
+ if (moved.isEmpty()) {
+ return;
+ }
+ for (MethodNode method : generatedMethods) {
+ if (method.getCode() == null) {
+ continue;
+ }
+ List<ConstructorCallExpression> anonymous = new ArrayList<>();
+ method.getCode().visit(new CodeVisitorSupport() {
+ @Override
+ public void
visitConstructorCallExpression(ConstructorCallExpression call) {
+ if (call.isUsingAnonymousInnerClass()) {
+ anonymous.add(call);
+ }
+ super.visitConstructorCallExpression(call);
+ }
+ });
+ for (ConstructorCallExpression call : anonymous) {
+ reportMovedMemberReferences(call, moved, host, source);
+ }
+ }
+ }
+
+ private void reportMovedMemberReferences(ConstructorCallExpression call,
Set<String> moved,
+ ClassNode host, SourceUnit source) {
+ ClassNode inner = call.getType();
+ Set<String> own = existingMemberNames(inner);
+ for (MethodNode method : inner.getMethods()) {
+ if (method.getCode() == null) {
+ continue;
+ }
+ method.getCode().visit(new CodeVisitorSupport() {
+ @Override
+ public void visitMethodCallExpression(MethodCallExpression
inner) {
+ super.visitMethodCallExpression(inner);
+ if (inner.isImplicitThis()) {
+ report(inner.getMethodAsString(), inner, "()");
+ }
+ }
+
+ @Override
+ public void visitVariableExpression(VariableExpression
expression) {
+ super.visitVariableExpression(expression);
+ if (expression.getAccessedVariable() instanceof
DynamicVariable) {
+ report(expression.getName(), expression, "");
+ }
+ }
+
+ private void report(String name, ASTNode at, String
callSuffix) {
+ if (name == null || own.contains(name) ||
!moved.contains(name)) {
Review Comment:
Fixed in 635f284. A `DynamicVariable` now matches on the `get`/`is`/`set`
spellings of its name as well as on the name itself, the same set
`collectMethodNames` reserves. Test: `method('getSuffix', String)` plus
`suffix` in the anonymous class body.
--
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]