Skip to content

Commit 18aae25

Browse files
committed
Register a generated auto-configuration where it is generated
The class a beans closure compiles to is created during compilation and is not a source file anyone can open, so listing it in AutoConfiguration.imports required knowing both that it exists and what the compiler decided to call it. Not knowing either produced a plugin whose beans were silently never registered, which is a poor thing to ask an author to notice. The name is settled in createAutoConfigurationSibling and nowhere else, so that is where the entry is now written - into the compilation's target directory, beside the grails-plugin.xml and grails.factories that are already generated there. A module that keeps the file by hand keeps it, and nothing is generated beside it: two copies of one resource cannot both go into the jar, and folding the hand authored entries into a copy under the build directory would lose them as soon as anyone deleted the file they were written in. Such a module is warned at compile time when the generated class is missing from its file, so the silent case is gone either way, and deleting the file is what opts in. Hand-authored entries have to stay possible: a class from another jar, one annotated with a composed annotation, or one carrying no annotation at all - the imports file being the registration and @autoConfiguration only supplying ordering. grails-databinding and the beans-dsl-plugin example no longer apply the autoconfiguration-imports convention plugin, which generated the same entries a second time. The beans-dsl example keeps it: what it registers is a standalone @GrailsBeans class, which is a source file its author can see and so is left to be registered by hand, as any other auto-configuration is.
1 parent 27b6097 commit 18aae25

7 files changed

Lines changed: 344 additions & 4 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.grails.compiler.beans;
20+
21+
import java.io.File;
22+
import java.io.IOException;
23+
import java.nio.charset.StandardCharsets;
24+
import java.nio.file.Files;
25+
import java.util.Set;
26+
import java.util.TreeSet;
27+
28+
import org.codehaus.groovy.control.SourceUnit;
29+
import org.codehaus.groovy.control.messages.WarningMessage;
30+
31+
/**
32+
* Registers a generated auto-configuration in
33+
* {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}.
34+
*
35+
* <p>The class a {@code beans} closure compiles to is created during compilation and is not a source
36+
* file anyone can open. Leaving its registration to be written by hand made a plugin whose beans are
37+
* silently never registered the ordinary consequence of not knowing the class exists - and the name
38+
* to write is one only the compiler knows, since it follows from the descriptor's name and package.
39+
* Writing it where the class is created is the only point at which that name is known for certain.
40+
*
41+
* <p>A module that keeps the file by hand keeps it: generating a second copy would put the same
42+
* resource at the same path twice, and folding its entries into a copy under the build directory
43+
* would lose them the moment anyone deleted the file that was, until then, where they were written
44+
* down. Such a module is warned when the generated class is missing from it and is otherwise left
45+
* alone, so nothing that builds today builds differently - deleting the hand-authored file is what
46+
* opts in, and is safe once it holds nothing but what is generated.
47+
*
48+
* <p>Hand-authored entries have to remain possible: a module may register a class from another jar,
49+
* one annotated with a composed annotation, or one carrying no annotation at all, the imports file
50+
* being the registration and {@code @AutoConfiguration} only supplying ordering.
51+
*
52+
* @since 8.0
53+
*/
54+
final class AutoConfigurationImportsWriter {
55+
56+
static final String IMPORTS_LOCATION =
57+
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports";
58+
59+
static final String SOURCE_IMPORTS_LOCATION = "src/main/resources/" + IMPORTS_LOCATION;
60+
61+
/** Set by the Grails Gradle plugin on the compiler's fork options; see GrailsAppBaseDirProvider. */
62+
private static final String BASE_DIR_PROPERTY = "base.dir";
63+
64+
private static final String COMMENT_START = "#";
65+
66+
private AutoConfigurationImportsWriter() {
67+
}
68+
69+
/**
70+
* Adds {@code className} to the generated imports file under {@code targetDirectory}, together
71+
* with anything an earlier source unit of the same compilation registered there. A module that
72+
* keeps the file by hand is warned instead, and its file is left as the only one.
73+
*
74+
* @param className the generated auto-configuration's binary name
75+
* @param targetDirectory the compilation output directory, or {@code null} when the compiler did
76+
* not supply one - in which case there is nowhere to write and the class
77+
* stays registerable by hand
78+
* @return {@code true} when the file was written
79+
*/
80+
static boolean register(String className, File targetDirectory, SourceUnit source) {
81+
if (className == null || className.isEmpty() || targetDirectory == null) {
82+
return false;
83+
}
84+
85+
File sourceDirectory = findSourceDirectory(targetDirectory);
86+
File handAuthored = sourceDirectory == null ? null : new File(sourceDirectory, SOURCE_IMPORTS_LOCATION);
87+
if (handAuthored != null && handAuthored.isFile()) {
88+
Set<String> handAuthoredEntries = new TreeSet<>();
89+
readEntries(handAuthored, handAuthoredEntries);
90+
if (!handAuthoredEntries.contains(className)) {
91+
warn(source, className + " is generated from a beans closure but is not listed in " +
92+
SOURCE_IMPORTS_LOCATION + ", so Spring Boot will not read it. Add it there, or delete " +
93+
"that file once it holds nothing that is not generated and it will be written for you.");
94+
}
95+
return false;
96+
}
97+
98+
File importsFile = new File(targetDirectory, IMPORTS_LOCATION);
99+
Set<String> entries = new TreeSet<>();
100+
// Entries an earlier source unit of the same compilation already registered
101+
readEntries(importsFile, entries);
102+
if (!entries.add(className) && importsFile.isFile()) {
103+
return false;
104+
}
105+
106+
try {
107+
Files.createDirectories(importsFile.toPath().getParent());
108+
// Sorted and newline-terminated, so recompiling the same sources rewrites the same bytes.
109+
Files.write(importsFile.toPath(), (String.join("\n", entries) + "\n")
110+
.getBytes(StandardCharsets.UTF_8));
111+
return true;
112+
}
113+
catch (IOException notWritable) {
114+
// The class is still generated and still registerable by hand, so failing compilation
115+
// over the convenience of not having to would be the worse trade.
116+
return false;
117+
}
118+
}
119+
120+
private static void warn(SourceUnit source, String message) {
121+
if (source != null) {
122+
source.getErrorCollector().addWarning(WarningMessage.LIKELY_ERRORS, message, null, source);
123+
}
124+
}
125+
126+
/**
127+
* The module's base directory, so a hand-authored imports file can be found. Mirrors
128+
* {@code FactoriesFileWriter.findSourceDirectory}: the build tool's own answer if it supplied
129+
* one, otherwise the directory above the output root.
130+
*/
131+
private static File findSourceDirectory(File targetDirectory) {
132+
String baseDir = System.getProperty(BASE_DIR_PROPERTY);
133+
if (baseDir != null && !baseDir.isEmpty()) {
134+
File candidate = new File(baseDir);
135+
if (candidate.isDirectory()) {
136+
return candidate;
137+
}
138+
}
139+
File directory = targetDirectory;
140+
while (directory != null && !("build".equals(directory.getName()) || "target".equals(directory.getName()))) {
141+
directory = directory.getParentFile();
142+
}
143+
return directory == null ? null : directory.getParentFile();
144+
}
145+
146+
/** Adds the names in {@code file}, skipping blanks and the {@code #} comments Spring Boot skips. */
147+
private static void readEntries(File file, Set<String> entries) {
148+
if (file == null || !file.isFile()) {
149+
return;
150+
}
151+
try {
152+
for (String line : Files.readAllLines(file.toPath(), StandardCharsets.UTF_8)) {
153+
String entry = line.trim();
154+
if (!entry.isEmpty() && !entry.startsWith(COMMENT_START)) {
155+
entries.add(entry);
156+
}
157+
}
158+
}
159+
catch (IOException unreadable) {
160+
// Nothing to merge that can be read; the generated entry is still written below.
161+
}
162+
}
163+
164+
}

grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
package org.grails.compiler.beans;
2020

2121
import java.beans.Introspector;
22+
import java.io.File;
2223
import java.lang.reflect.Modifier;
2324
import java.util.ArrayList;
2425
import java.util.HashSet;
@@ -63,6 +64,7 @@
6364
import org.codehaus.groovy.ast.stmt.Statement;
6465
import org.codehaus.groovy.control.CompilationUnit;
6566
import org.codehaus.groovy.control.CompilePhase;
67+
import org.codehaus.groovy.control.CompilerConfiguration;
6668
import org.codehaus.groovy.control.SourceUnit;
6769
import org.codehaus.groovy.syntax.SyntaxException;
6870
import org.codehaus.groovy.syntax.Types;
@@ -324,9 +326,18 @@ private ClassNode createAutoConfigurationSibling(ClassNode pluginClass, Annotati
324326
sibling.addAnnotations(siblingAnnotations);
325327
pluginClass.getAnnotations().removeAll(siblingAnnotations);
326328

329+
// The name is settled here and nowhere else, so this is where it can be registered.
330+
AutoConfigurationImportsWriter.register(siblingName, targetDirectory(source), source);
331+
327332
return sibling;
328333
}
329334

335+
/** The compiler's output directory, which is where generated metadata belongs. */
336+
private static File targetDirectory(SourceUnit source) {
337+
CompilerConfiguration configuration = source == null ? null : source.getConfiguration();
338+
return configuration == null ? null : configuration.getTargetDirectory();
339+
}
340+
330341
private Set<String> parseMoveAnnotations(AnnotationNode grailsBeansAnnotation, SourceUnit source) {
331342
Expression member = grailsBeansAnnotation.getMember(MOVE_ANNOTATIONS_MEMBER);
332343
if (member == null) {
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.grails.compiler.beans
20+
21+
import org.codehaus.groovy.control.CompilationUnit
22+
import org.codehaus.groovy.control.CompilerConfiguration
23+
import org.codehaus.groovy.control.Phases
24+
import spock.lang.Specification
25+
import spock.lang.TempDir
26+
27+
/**
28+
* The class a {@code beans} closure compiles to is generated rather than written, so nobody can
29+
* list it in {@code AutoConfiguration.imports} without first knowing it exists. These drive a real
30+
* compilation with a target directory, which is the only thing that makes the file observable.
31+
*/
32+
class AutoConfigurationImportsWriterSpec extends Specification {
33+
34+
private static final String IMPORTS =
35+
'META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports'
36+
37+
@TempDir
38+
File projectDir
39+
40+
private File targetDir
41+
private String previousBaseDir
42+
43+
void setup() {
44+
targetDir = new File(projectDir, 'build/classes/groovy/main')
45+
targetDir.mkdirs()
46+
previousBaseDir = System.setProperty('base.dir', projectDir.absolutePath)
47+
}
48+
49+
void cleanup() {
50+
if (previousBaseDir == null) {
51+
System.clearProperty('base.dir')
52+
}
53+
else {
54+
System.setProperty('base.dir', previousBaseDir)
55+
}
56+
}
57+
58+
void 'the generated sibling registers itself'() {
59+
when:
60+
compile(plugin('Greeting'))
61+
62+
then: 'the name only the compiler knows is written where Spring Boot reads it'
63+
importsEntries() == ['com.example.GreetingAutoConfiguration']
64+
}
65+
66+
void 'autoConfigurationName registers under the name it asks for'() {
67+
when:
68+
compile(plugin('Renamed', "autoConfigurationName = 'LegacyAutoConfiguration'"))
69+
70+
then: 'what is registered is the name the class is actually generated under'
71+
importsEntries() == ['com.example.LegacyAutoConfiguration']
72+
}
73+
74+
void 'siblings from separate source units accumulate rather than replacing one another'() {
75+
when: 'two descriptors compile separately, as they do in a real build'
76+
compile(plugin('First'))
77+
compile(plugin('Second'))
78+
79+
then:
80+
importsEntries() == ['com.example.FirstAutoConfiguration', 'com.example.SecondAutoConfiguration']
81+
}
82+
83+
void 'a module that keeps the file by hand keeps it'() {
84+
given: 'a hand-authored file, which may hold entries no compilation can discover'
85+
File handAuthored = new File(projectDir, "src/main/resources/${IMPORTS}")
86+
handAuthored.parentFile.mkdirs()
87+
handAuthored.text = 'com.example.GreetingAutoConfiguration\ncom.elsewhere.FromAnotherJar\n'
88+
89+
when:
90+
compile(plugin('Greeting'))
91+
92+
then: 'nothing is generated beside it, which would put the same resource at the same path twice'
93+
!new File(targetDir, IMPORTS).exists()
94+
95+
and: 'and the entries it alone knows about are untouched'
96+
handAuthored.readLines().contains('com.elsewhere.FromAnotherJar')
97+
}
98+
99+
void 'a hand-authored file missing the generated class is warned about'() {
100+
given:
101+
File handAuthored = new File(projectDir, "src/main/resources/${IMPORTS}")
102+
handAuthored.parentFile.mkdirs()
103+
handAuthored.text = 'com.elsewhere.FromAnotherJar\n'
104+
105+
when:
106+
compile(plugin('Greeting'))
107+
108+
then: 'silently registering nothing is the failure this exists to prevent'
109+
warnings().any {
110+
it.contains('com.example.GreetingAutoConfiguration') && it.contains(IMPORTS)
111+
}
112+
}
113+
114+
private List<String> collectedWarnings = []
115+
116+
private List<String> warnings() {
117+
collectedWarnings
118+
}
119+
120+
private List<String> importsEntries() {
121+
File file = new File(targetDir, IMPORTS)
122+
file.exists() ? file.readLines().findAll { it.trim() && !it.startsWith('#') } : []
123+
}
124+
125+
private static String plugin(String name, String grailsBeansMembers = '') {
126+
"""
127+
package com.example
128+
129+
import grails.compiler.beans.GrailsBeans
130+
import grails.plugins.Plugin
131+
import org.springframework.boot.autoconfigure.AutoConfiguration
132+
133+
@GrailsBeans(${grailsBeansMembers})
134+
@AutoConfiguration
135+
class ${name}GrailsPlugin extends Plugin {
136+
def beans = {
137+
bean('${name.uncapitalize()}Greeting', String) { 'hello' }
138+
}
139+
}
140+
"""
141+
}
142+
143+
/** A real compilation, since only a target directory makes the generated file observable. */
144+
private CompilerConfiguration compile(String source) {
145+
CompilerConfiguration configuration = new CompilerConfiguration()
146+
configuration.targetDirectory = targetDir
147+
CompilationUnit unit = new CompilationUnit(configuration, null,
148+
new GroovyClassLoader(getClass().classLoader, configuration))
149+
unit.addSource("Source${System.identityHashCode(source)}.groovy", source)
150+
try {
151+
unit.compile(Phases.CLASS_GENERATION)
152+
}
153+
catch (Exception ignored) {
154+
// the generated file is what is under test, not the class
155+
}
156+
List warningMessages = unit.errorCollector.warnings
157+
if (warningMessages) {
158+
collectedWarnings.addAll(warningMessages.collect { it.message?.toString() ?: it.toString() })
159+
}
160+
configuration
161+
}
162+
163+
}

grails-databinding/build.gradle

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ plugins {
2323
id 'project-report'
2424
id 'org.apache.grails.buildsrc.properties'
2525
id 'org.apache.grails.buildsrc.dependency-validator'
26-
id 'org.apache.grails.buildsrc.autoconfiguration-imports'
2726
id 'org.apache.grails.buildsrc.compile'
2827
id 'org.apache.grails.buildsrc.publish'
2928
id 'org.apache.grails.buildsrc.sbom'

grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,11 @@ bean('greeting', Greeting) {
193193
}
194194
----
195195

196-
Because the result is an ordinary `AutoConfiguration` class, it must be registered the same way any Spring Boot auto-configuration is: listed in `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`. Modules built as part of this project can apply the `org.apache.grails.buildsrc.autoconfiguration-imports` Gradle convention plugin to generate that file automatically, by scanning the module's own compiled classes for `@AutoConfiguration` at build time. Projects outside this build register the class the same way any hand-written `AutoConfiguration` is registered — by listing it in that file directly.
196+
Because the result is an ordinary `AutoConfiguration` class, it must be registered the same way any Spring Boot auto-configuration is: listed in `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`. **The compiler writes that entry for you.** The class is generated rather than written, so its name is one only the compiler knows — leaving the registration to be typed by hand made a plugin whose beans are silently never registered the ordinary consequence of not knowing the class exists.
197+
198+
A module that keeps the imports file by hand keeps it, and nothing is generated beside it: two copies of the same resource cannot both go into the jar, and folding the hand-authored entries into a copy under the build directory would lose them as soon as anyone deleted the file they were written in. Such a module is warned at compile time when the generated class is missing from its file. Deleting that file is what opts in, and is safe once it lists nothing that is not generated — a module may still need it for entries no compilation can discover, such as a class from another jar, one annotated with a composed annotation, or one carrying no annotation at all, the imports file being the registration and `@AutoConfiguration` only supplying ordering.
199+
200+
A standalone `@GrailsBeans` class — one that is neither a plugin descriptor nor an `Application` — is a source file you can see, so it is registered by hand like any other auto-configuration.
197201

198202
NOTE: `@GrailsBeans` does not require extending `Plugin` at all — it produces a plain Spring Boot `AutoConfiguration`, so it can be used by any Spring Boot module, not just a Grails plugin descriptor. Applications can use it too, including directly on the `Application` class — where no imports-file registration is needed, since Spring Boot processes the application class itself — see link:spring.html#springdslAdditional[Configuring Additional Beans]. Within a plugin, reach for it specifically when a bean's registration needs to be ordered against a particular other auto-configuration; otherwise `beanRegistrar()` remains the recommended way to register beans from a `Plugin`.
199203

0 commit comments

Comments
 (0)