codeconsole commented on code in PR #16385:
URL: https://github.com/apache/grails-core/pull/16385#discussion_r4089255687
##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTask.groovy:
##########
@@ -189,54 +217,163 @@ abstract class GenerateScaffoldedViewsTask extends
DefaultTask {
templates
}
+ /**
+ * Compiled plugin pages win over runtime scaffolding, so they must also
win at build time. Each
+ * artifact contributes the first index it carries, as the runtime reads
only one per plugin.
+ */
+ private Set<String> findPluginViews() {
+ Set<String> views = []
+ for (File entry : viewClasspath.files) {
+ Properties index = new Properties()
+ if (entry.isDirectory()) {
+ File resource = VIEW_INDEXES.collect { new File(entry, it)
}.find { it.isFile() }
+ resource?.withInputStream { InputStream input ->
index.load(input) }
+ }
+ else if (entry.name.endsWith('.jar') && entry.isFile()) {
+ new JarFile(entry).withCloseable { JarFile jar ->
+ JarEntry resource = VIEW_INDEXES.collect {
jar.getJarEntry(it) }.find { it != null }
+ if (resource != null) {
+ jar.getInputStream(resource).withCloseable {
InputStream input -> index.load(input) }
+ }
+ }
+ }
+ views.addAll(index.stringPropertyNames())
+ }
+ views
+ }
+
/**
* Maps view directory name to the fully qualified domain class, for every
{@code @Scaffold}
* controller. Qualified rather than simple because a view declaring the
type of its model has to
* name a type that resolves.
*
- * <p>A view directory is named for the controller alone - {@code
getDeployedViewURI} builds
- * {@code /WEB-INF/grails-app/views/<controller>/<view>.gsp} and never
consults the namespace -
- * so two controllers of the same simple name in different packages share
one directory whatever
- * their namespaces are. Where they scaffold different domains, no single
page can serve both:
- * whichever was written would declare one domain as its model and be
rendered by the controller
- * of the other. Both are left out rather than one of them guessed at, and
the resolver goes on
- * expanding a template per request for them, which is what it did before
any of this and is the
- * one thing that gets each controller its own domain. Everything else in
the project is still
- * precompiled.</p>
+ * <p>Namespaced controllers are left to the runtime resolver, which can
evaluate the namespace
+ * and select namespace-specific templates. Emitting their pages into a
shared, unqualified
+ * directory would make them visible to unrelated controllers. The entire
shared directory is
+ * left out, including when an unqualified controller also claims it.</p>
+ *
+ * <p>This is deliberately broader than it needs to be for a namespaced
controller that has no
+ * namespace-specific template, whose page would come out identical to the
plain one. Narrowing
+ * it needs to know whether {@code <namespace>/<view>.gsp} exists, and
neither half is available
+ * here: the namespace value is assigned in {@code <clinit>} for the usual
Groovy declarations,
+ * so the bytecode carries no constant for it, and the runtime also finds
namespace templates in
+ * places this task does not read - the application's own resources beside
the controller class,
+ * {@code src/main/templates/scaffolding} in development, and a
template-override plugin.
+ * Guessing wrong would precompile a plain page over a namespace-specific
one, silently.</p>
+ *
+ * <p>Likewise, controllers sharing a name but scaffolding different
domains cannot share a
+ * precompiled page. The runtime resolver expands a template for the
appropriate domain.</p>
*/
private Map<String, String> findScaffoldedControllers() {
Map<String, String> found = [:]
Map<String, List<String>> claimants = [:]
- for (File dir : classesDirs.files) {
- if (!dir.isDirectory()) {
- continue
- }
- dir.eachFileRecurse { File f ->
- if (!f.name.endsWith('Controller.class')) {
- return
+ Set<String> namespaced = []
+ Map<String, Boolean> ancestors = [:]
+ URL[] classpath = (classesDirs.files +
controllerClasspath.files).collect { it.toURI().toURL() } as URL[]
+ new URLClassLoader(classpath, (ClassLoader) null).withCloseable {
URLClassLoader resources ->
+ for (File dir : classesDirs.files) {
+ if (!dir.isDirectory()) {
+ continue
}
- String domain = readScaffoldDomain(f)
- if (domain == null) {
- return
+ dir.eachFileRecurse { File f ->
+ if (!f.name.endsWith('Controller.class')) {
+ return
+ }
+ String controllerName = viewDirectory(f.name - '.class')
+ ClassReader reader = new ClassReader(f.bytes)
+ if (hasNamespace(reader, resources, ancestors)) {
+ namespaced.add(controllerName)
+ }
+ String domain = readScaffoldDomain(reader)
+ if (domain == null) {
+ return
+ }
+ claimants.computeIfAbsent(controllerName) { []
}.add(domain)
+ found.put(controllerName, domain)
}
- String controllerName = decapitalize(f.name -
'Controller.class')
- claimants.computeIfAbsent(controllerName) { [] }.add(domain)
- found.put(controllerName, domain)
+ }
+ }
+ namespaced.each { String controllerName ->
+ if (found.remove(controllerName) != null) {
+ logger.warn('Not precompiling the views of {}: a controller
with this name declares or inherits a namespace. ' +
+ 'These scaffold views are expanded at runtime; native
images require concrete GSP views.', controllerName)
}
}
claimants.each { String controllerName, List<String> domains ->
List<String> distinct = domains.unique(false)
if (distinct.size() > 1) {
found.remove(controllerName)
logger.warn("Not precompiling the views of ${controllerName}:
" +
- "${distinct.size()} controllers named
${capitalize(controllerName)}Controller " +
+ "${distinct.size()} controllers with the view
directory ${controllerName} " +
"scaffold different domains (${distinct.join(', ')})
and share the one view " +
'directory. They are expanded per request instead, as
they were before.')
}
}
found
}
+ /**
+ * Read declarations, including inherited ones, without evaluating
application code.
+ *
+ * <p>A declaration is all this can see, not its value, so {@code static
namespace = null}
+ * still counts even though the runtime, which tests the value, gives that
controller no
+ * namespace. The value lives in {@code <clinit>} for the usual Groovy
forms and code is not
+ * read here, so the difference cannot be recovered; the controller is
only expanded at runtime
+ * rather than precompiled.</p>
+ */
+ private boolean hasNamespace(ClassReader reader, ClassLoader resources,
Map<String, Boolean> ancestors) {
+ boolean declared = false
+ reader.accept(new ClassVisitor(Opcodes.ASM9) {
+ @Override
+ FieldVisitor visitField(int access, String name, String
descriptor, String signature, Object value) {
+ if (name == 'namespace' && (access & Opcodes.ACC_STATIC) != 0)
{
+ declared = true
+ }
+ null
+ }
+
+ @Override
+ MethodVisitor visitMethod(int access, String name, String
descriptor, String signature, String[] exceptions) {
+ if (name == 'getNamespace' && descriptor.startsWith('()') &&
(access & Opcodes.ACC_STATIC) != 0) {
+ declared = true
+ }
+ null
+ }
+ }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG |
ClassReader.SKIP_FRAMES)
+ if (declared || reader.superName == null || reader.superName ==
'java/lang/Object') {
+ return declared
+ }
+ String superName = reader.superName
+ Boolean known = ancestors.get(superName)
+ if (known != null) {
+ return known
+ }
+ boolean inherited = ancestorHasNamespace(superName, resources,
ancestors)
+ ancestors.put(superName, inherited)
+ inherited
+ }
+
+ /**
+ * Superclasses can come from dependencies, whose class files may be newer
than the bundled ASM
+ * reads. One that cannot be read is taken to declare no namespace rather
than failing the build.
+ */
+ private boolean ancestorHasNamespace(String internalName, ClassLoader
resources, Map<String, Boolean> ancestors) {
+ InputStream parent =
resources.getResourceAsStream("${internalName}.class")
+ if (parent == null) {
+ return false
+ }
+ ClassReader reader
+ try {
+ reader = parent.withCloseable { InputStream input -> new
ClassReader(input) }
+ }
+ catch (IllegalArgumentException e) {
Review Comment:
Fixed in 94fc557516. The ancestor-read path now catches
IllegalArgumentException, IOException, and IndexOutOfBoundsException. The try
block covers both constructing ClassReader and visiting the ancestor, so
malformed bytecode encountered during either step follows the documented
fallback. Regression tests cover an unsupported version, truncated and empty
class files, and a stream that throws IOException.
##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTask.groovy:
##########
@@ -189,54 +217,163 @@ abstract class GenerateScaffoldedViewsTask extends
DefaultTask {
templates
}
+ /**
+ * Compiled plugin pages win over runtime scaffolding, so they must also
win at build time. Each
+ * artifact contributes the first index it carries, as the runtime reads
only one per plugin.
+ */
+ private Set<String> findPluginViews() {
Review Comment:
Fixed in 94fc557516. JAR indexes now contribute views only when the JAR also
contains META-INF/grails-plugin.xml. Directory outputs remain ungated because a
project dependency can put its descriptor and compiled index in separate output
directories; the method documentation explains this distinction. Tests cover
descriptorless JARs and directories for both index locations, and the existing
plugin-JAR fixtures now include descriptors.
##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTask.groovy:
##########
@@ -189,54 +217,163 @@ abstract class GenerateScaffoldedViewsTask extends
DefaultTask {
templates
}
+ /**
+ * Compiled plugin pages win over runtime scaffolding, so they must also
win at build time. Each
+ * artifact contributes the first index it carries, as the runtime reads
only one per plugin.
+ */
+ private Set<String> findPluginViews() {
+ Set<String> views = []
+ for (File entry : viewClasspath.files) {
+ Properties index = new Properties()
+ if (entry.isDirectory()) {
+ File resource = VIEW_INDEXES.collect { new File(entry, it)
}.find { it.isFile() }
+ resource?.withInputStream { InputStream input ->
index.load(input) }
+ }
+ else if (entry.name.endsWith('.jar') && entry.isFile()) {
+ new JarFile(entry).withCloseable { JarFile jar ->
+ JarEntry resource = VIEW_INDEXES.collect {
jar.getJarEntry(it) }.find { it != null }
+ if (resource != null) {
+ jar.getInputStream(resource).withCloseable {
InputStream input -> index.load(input) }
+ }
+ }
+ }
+ views.addAll(index.stringPropertyNames())
+ }
+ views
+ }
+
/**
* Maps view directory name to the fully qualified domain class, for every
{@code @Scaffold}
* controller. Qualified rather than simple because a view declaring the
type of its model has to
* name a type that resolves.
*
- * <p>A view directory is named for the controller alone - {@code
getDeployedViewURI} builds
- * {@code /WEB-INF/grails-app/views/<controller>/<view>.gsp} and never
consults the namespace -
- * so two controllers of the same simple name in different packages share
one directory whatever
- * their namespaces are. Where they scaffold different domains, no single
page can serve both:
- * whichever was written would declare one domain as its model and be
rendered by the controller
- * of the other. Both are left out rather than one of them guessed at, and
the resolver goes on
- * expanding a template per request for them, which is what it did before
any of this and is the
- * one thing that gets each controller its own domain. Everything else in
the project is still
- * precompiled.</p>
+ * <p>Namespaced controllers are left to the runtime resolver, which can
evaluate the namespace
+ * and select namespace-specific templates. Emitting their pages into a
shared, unqualified
+ * directory would make them visible to unrelated controllers. The entire
shared directory is
+ * left out, including when an unqualified controller also claims it.</p>
+ *
+ * <p>This is deliberately broader than it needs to be for a namespaced
controller that has no
+ * namespace-specific template, whose page would come out identical to the
plain one. Narrowing
+ * it needs to know whether {@code <namespace>/<view>.gsp} exists, and
neither half is available
+ * here: the namespace value is assigned in {@code <clinit>} for the usual
Groovy declarations,
+ * so the bytecode carries no constant for it, and the runtime also finds
namespace templates in
+ * places this task does not read - the application's own resources beside
the controller class,
+ * {@code src/main/templates/scaffolding} in development, and a
template-override plugin.
+ * Guessing wrong would precompile a plain page over a namespace-specific
one, silently.</p>
+ *
+ * <p>Likewise, controllers sharing a name but scaffolding different
domains cannot share a
+ * precompiled page. The runtime resolver expands a template for the
appropriate domain.</p>
*/
private Map<String, String> findScaffoldedControllers() {
Map<String, String> found = [:]
Map<String, List<String>> claimants = [:]
- for (File dir : classesDirs.files) {
- if (!dir.isDirectory()) {
- continue
- }
- dir.eachFileRecurse { File f ->
- if (!f.name.endsWith('Controller.class')) {
- return
+ Set<String> namespaced = []
+ Map<String, Boolean> ancestors = [:]
+ URL[] classpath = (classesDirs.files +
controllerClasspath.files).collect { it.toURI().toURL() } as URL[]
+ new URLClassLoader(classpath, (ClassLoader) null).withCloseable {
URLClassLoader resources ->
+ for (File dir : classesDirs.files) {
+ if (!dir.isDirectory()) {
+ continue
}
- String domain = readScaffoldDomain(f)
- if (domain == null) {
- return
+ dir.eachFileRecurse { File f ->
+ if (!f.name.endsWith('Controller.class')) {
+ return
+ }
+ String controllerName = viewDirectory(f.name - '.class')
+ ClassReader reader = new ClassReader(f.bytes)
+ if (hasNamespace(reader, resources, ancestors)) {
+ namespaced.add(controllerName)
+ }
+ String domain = readScaffoldDomain(reader)
+ if (domain == null) {
+ return
+ }
+ claimants.computeIfAbsent(controllerName) { []
}.add(domain)
+ found.put(controllerName, domain)
}
- String controllerName = decapitalize(f.name -
'Controller.class')
- claimants.computeIfAbsent(controllerName) { [] }.add(domain)
- found.put(controllerName, domain)
+ }
+ }
+ namespaced.each { String controllerName ->
+ if (found.remove(controllerName) != null) {
+ logger.warn('Not precompiling the views of {}: a controller
with this name declares or inherits a namespace. ' +
+ 'These scaffold views are expanded at runtime; native
images require concrete GSP views.', controllerName)
}
}
claimants.each { String controllerName, List<String> domains ->
List<String> distinct = domains.unique(false)
if (distinct.size() > 1) {
found.remove(controllerName)
logger.warn("Not precompiling the views of ${controllerName}:
" +
- "${distinct.size()} controllers named
${capitalize(controllerName)}Controller " +
+ "${distinct.size()} controllers with the view
directory ${controllerName} " +
"scaffold different domains (${distinct.join(', ')})
and share the one view " +
'directory. They are expanded per request instead, as
they were before.')
}
}
found
}
+ /**
+ * Read declarations, including inherited ones, without evaluating
application code.
+ *
+ * <p>A declaration is all this can see, not its value, so {@code static
namespace = null}
+ * still counts even though the runtime, which tests the value, gives that
controller no
+ * namespace. The value lives in {@code <clinit>} for the usual Groovy
forms and code is not
+ * read here, so the difference cannot be recovered; the controller is
only expanded at runtime
+ * rather than precompiled.</p>
+ */
+ private boolean hasNamespace(ClassReader reader, ClassLoader resources,
Map<String, Boolean> ancestors) {
+ boolean declared = false
+ reader.accept(new ClassVisitor(Opcodes.ASM9) {
+ @Override
+ FieldVisitor visitField(int access, String name, String
descriptor, String signature, Object value) {
+ if (name == 'namespace' && (access & Opcodes.ACC_STATIC) != 0)
{
+ declared = true
+ }
+ null
+ }
+
+ @Override
+ MethodVisitor visitMethod(int access, String name, String
descriptor, String signature, String[] exceptions) {
+ if (name == 'getNamespace' && descriptor.startsWith('()') &&
(access & Opcodes.ACC_STATIC) != 0) {
Review Comment:
Addressed in 94fc557516. The hasNamespace javadoc now explains the renamed
trait field, the static getNamespace() accessor generated on the implementing
class, and why no interface walk is needed. The functional spec also compiles a
real Groovy trait with a static namespace and verifies that its scaffolded
implementing controller is excluded from shared view generation and produces
the native-image warning.
##########
grails-doc/src/en/guide/scaffolding.adoc:
##########
@@ -59,6 +59,10 @@ With this configured, when you start your application the
actions and views will
A CRUD interface will also be generated. To access this open
`http://localhost:8080/book` in a browser.
+During `compileGroovyPages`, the Gradle plugin expands scaffold templates and
compiles the resulting GSP views so packaged applications and native images do
not need to generate them on the first request. Handwritten views in the
application or supplied by a plugin still take precedence over generated
scaffold views.
Review Comment:
Addressed both points in 94fc557516. The guide now names
generateScaffoldedViews, stageGroovyPages, and compileGroovyPages separately,
gives the generated and staged output directories, and explains that
compilation runs its prerequisite tasks automatically. It also documents
controllers whose view directories collide while scaffolding different domains
and their need for concrete views in native images. The collision warning now
mentions that native-image requirement too. The two affected specs passed all
46 tests; module codeStyle and validateDependencyVersions passed as well.
--
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]