Compile scaffold pages by template and domain, and let the resolver look them up - #16398
jdaugherty merged 51 commits into
Conversation
…late The scaffolding resolver chooses the template a request uses exactly as before, then looks for the page compiled from that template and the domain class's model, at grails-scaffolded/<domain class>/<digest of both>.gsp under the views root, and expands the template only when there is none. A page is found only for the exact template and model it was expanded from, so it can never stand in for a different one, and a template the build did not see is expanded on its first use as it always was.
A compiled scaffold page is found by the template it was expanded from, so the resolver reads the template even when it does not expand it.
…ew directories generateScaffoldedViews expands every scaffolding template, namespace-specific ones included, for every scaffolded domain class, and writes each page where the runtime resolver looks for it: grails-scaffolded/<domain class>/<digest of template and model>.gsp. Nothing lands where a controller's views resolve from, so a generated page cannot shadow a view the application or a plugin declares, and controllers that are namespaced, or that share a name while scaffolding different domains, are precompiled too. The application-views input and the view-directory collision handling go with the directories. Template overrides are read as a tree, so an application's namespace-specific template is expanded as well.
…and Groovy generateScaffoldedViews no longer models, expands and names the pages itself with the build's Groovy 4 and a copy of the runtime's naming. It finds the scaffolded domain classes and chooses the templates, then runs ScaffoldedPagesGenerator from grails-scaffolding in a JVM on the application's runtime classpath, which expands and names each page with the runtime's own ModelBuilder, ScaffoldedPages and Groovy. The build and the resolver can no longer drift apart, and the copy in the Gradle plugin is gone. The generator's classpath is a tracked input, so the task stays cacheable. With a grails-scaffolding that predates the generator, nothing is compiled and the build warns. Templates are now read as UTF-8 wherever they are expanded, rather than in the JVM's default encoding, so the page the build compiles is the page the resolver would have expanded.
…e template mentions A page is now written as grails-scaffolded/<domain class>/<template path>-<digest>.gsp, so a page can be traced to its template, admin/show-3fa2...gsp for instance. The resolver passes the path of the template it chose, which tryGenerateScaffoldedView now resolves itself from the candidate paths a caller gives it; it is protected, so a resolver subclass can use it. The digest covers the template and only the model names the template mentions. A name the template never mentions cannot change what it expands to, and leaving it out keeps a page found when such a value differs between the machine that built the application and the one running it: packagePath follows the file separator, so an application built on Windows and run on Linux lost every page.
… used A miss used to be logged at debug, so a build and runtime that disagreed, or a template the build never saw, went unnoticed until a native image failed on it. Where compiled pages are in use, the resolver now warns once per template and domain class that it expanded the template. During development, where pages are expanded by design, it stays at debug.
A native image cannot expand a template, so a scaffolded view whose template has no page of its own failed there. That happens when the image's classpath puts a different copy of the template first than the build did, or with a plugin that overrides the templates. The resolver now tries every copy of the template on the classpath, in order, and serves the page compiled from the first copy that has one, which is the one the build chose, with a warning. On the JVM it still expands the template it resolved and never serves another copy's page. Checked against GraalVM 25: in a native image getResource returns the first copy in classpath order, as on the JVM, and getResources returns every copy.
…a compiled page The real resolver, reading the real templates, against the pages the example's own build compiled, for each view of a controller, a namespaced controller and a same-named controller scaffolding a different domain class, with no view expanded at runtime. It is where the build and the resolver are held together.
8.0.x now carries apache#16385, which kept the path-based approach and hardened it. This branch replaces that approach, so in the five files only apache#16385 touched - the generation task, the GSP plugin wiring, their specs and the scaffolding guide - this branch's version is kept, apart from GroovyPagePlugin.VIEWS_SERVER_PATH, which still names the views root the pages are compiled under.
The task read templates from the compile classpath, so a template from a dependency the application only has at runtime, a theme plugin declared runtimeOnly for instance, was never compiled: the JVM expanded it on first use and a native image could not. The templates are now read from the runtime classpath the generator already runs on, which becomes the task's single runtimeClasspath input.
…r is expected to choose When a template exists more than once - an application override and the stock template, or a plugin that overrides the templates - the task compiled only the copy it predicted the resolver would choose. That prediction was wrong for a template-override plugin, which the resolver consults before the classpath, and wherever a native image orders its classpath differently from the build. Every distinct copy is now expanded, identical ones once, so whichever copy the resolver chooses has its page; each is named for its own content, so they cannot be confused. Where a template exists once, as it usually does, nothing changes. The generator takes one templates directory per copy: ScaffoldedPagesGenerator <domain class list> <output directory> <templates directory>...
…d not choose A native image fell back to the page compiled from another copy of a template when the copy it resolved had none, because the build compiled only the copy it predicted. Now that every copy is compiled, the copy the resolver chooses has its own page wherever the build could see it, and the fallback only ever served a page from a template the application had not chosen. It is removed: the resolver serves the page compiled from the template it chose, or expands that template, in every environment.
…image does without a page
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16398 +/- ##
==================================================
+ Coverage 57.5998% 57.8154% +0.2156%
- Complexity 22697 22804 +107
==================================================
Files 2133 2135 +2
Lines 104226 104467 +241
Branches 18692 18750 +58
==================================================
+ Hits 60034 60398 +364
+ Misses 35906 35737 -169
- Partials 8286 8332 +46
🚀 New features to boost your workflow:
|
…es compiled pages The resolver judged whether compiled pages were in use from the environment, which said yes in an application's tests. Grails keeps compiled pages off the test runtime classpath, so there every scaffolded view was expanded and each warned that a native image could not do that. The resolver now asks the page locator, which knows whether it looks pages up among the compiled ones; DefaultGroovyPageLocator.isPrecompiledAvailable() is public for that.
…e classpath Every jar on the runtime classpath was opened twice, once for templates and once for the generator. One pass now does both. The generator is looked for in a jar, as grails-scaffolding ships it, as well as a directory, and a spec covers the jar.
…he build A scaffolded page is an optimisation, but one that did not compile failed compileGroovyPages and the build with it - the stock template for a domain class named Package, for instance, whose model directive declares a field called package. The page compiler now takes the directories of generated pages (grails.views.gsp.generatedDirectories, set by the build for grails-scaffolded) and leaves out such a page with a warning: without it the view is expanded when it is rendered, as it was before, and a page nobody renders costs nothing. A written page that does not compile still fails the build. A page's data files and registry entry are now written after its class, so one left out leaves nothing behind; the forked compiler prints what it left out, which is what the build shows of it.
…endered Expansion is the same code, template and model at build time and at runtime, so a template that cannot be expanded for a domain class in the build cannot be expanded when its view is asked for either. The message said it would be expanded then instead.
Every copy of every template was expanded for every scaffolded domain class. Two kinds of those pages can never be served: - The resolver looks beside the controller's class first, and an application's src/main/templates are packaged there, so for the application's controllers its own template is always the one chosen. The dependency copies it replaces were compiled anyway, adding pages to the artifact, and a warning for each that did not compile. - A namespace-specific template is only looked for on behalf of a controller with a namespace, yet admin/show.gsp was expanded for every domain class. The task now hands the generator a plan: each domain class with the copies its controllers can choose. A namespace is read from the class without loading it, including one inherited from a superclass or supplied by a trait's static accessor; a superclass that cannot be read counts as declaring none. Where the choice depends on what the build cannot see, a plugin overriding the templates or the runtime classpath order, every copy is still expanded.
A plugin's controller is an artefact of the application like its own, and the resolver renders a scaffolded one the same way, but only the application's classes were searched, so its pages were always expanded when rendered, which a native image cannot do. Controllers are now also read from each plugin on the runtime classpath, a jar or a directory carrying META-INF/grails-plugin.xml; a scaffolded class in any other library is not a controller and is left alone. The template beside a plugin's controller is its own, so that copy is the one expanded for it where there is one. A class that cannot be read is reported and skipped rather than failing the build. Whether the views are staged at all is still decided from the project's own controller sources, since a plugin's would have to be resolved while the build is configured; a project that scaffolds nothing itself has its plugins' pages expanded when rendered, as before.
An application can put its scaffolding templates under src/main/resources/META-INF/templates/scaffolding as well as in src/main/templates/scaffolding; both are packaged beside its controllers, where the resolver looks first. Only the second was read, so a template kept with the resources had no page, and the dependency copy it replaces had one that is never served.
The pages were always written as UTF-8, while compileGroovyPages reads sources in its compileOptions.encoding. An application compiling its views in another encoding had any character outside ASCII in a template or a domain class name read back as something else, so the compiled page differed from the one the resolver would have expanded. generateScaffoldedViews takes the encoding as an input, following compileGroovyPages', and hands it to the generator.
A template that mentions packagePath expands with the platform's file separator, and the resolver names the page it looks for from its own expansion. Every other input of generateScaffoldedViews is the same on Windows and elsewhere, so a page expanded on one could be restored from the build cache on the other and never be found there. The separator is now an input.
The staging decision looked for the text @scaffold in the controller sources, so a controller annotated @grails.plugin.scaffolding.annotation.Scaffold was missed and its pages were expanded when rendered, while an unrelated @ScaffoldingSomething counted. The annotation is now matched by name, imported or qualified.
…ve image A packaged application warned, once per page, about every scaffolded view it expanded instead of serving compiled. On the JVM that is what every such view did before any page was compiled, and still works, so a warning on each boot for, say, a controller using static scaffold was noise. It also said the build compiles a page for every template, which is no longer so. On the JVM this is now logged at INFO. In a native image, where the expansion that follows fails because a class cannot be defined at runtime, it stays a warning and says what to do. The tests capture the log to show each is emitted once and at which level.
The runtime reads a controller's namespace through its metaclass, which
also sees a constant on an interface the controller implements:
interface AdminArea { String namespace = 'admin' }
@scaffold(Book) class BookController implements AdminArea {}
is in the admin namespace, and its views are expanded from admin/
templates. The build only looked at the class and its superclasses, took
such a controller to have no namespace, and compiled none of the
namespace-specific pages it is served. Interfaces are now walked along
with superclasses.
…ss of mode During development the resolver reads a template from the project's src/main/templates, so an edit shows without a rebuild. The page locator uses the compiled pages in an ahead-of-time image even when it looks like development - an image started with -Dgrails.env=development from the directory it was built in - but the resolver still read the template from disk there. A template edited since the build, or the application's copy read on behalf of a plugin's controller, then named a page that was never compiled, and the view failed. The resolver now reads from the project only when it is not running from ahead-of-time artifacts, as the locator decides.
…ir encoding Two pieces of wiring had no test that would fail without them: the plugin passing compileGroovyPages' encoding to generateScaffoldedViews, and the compile task passing the generated directories to the forked compiler. Losing the second would turn every scaffolded page that does not compile back into a build failure; the functional test only printed the property it is passed from. The functional test now sets an encoding on compileGroovyPages and runs it, against a stand-in for the forked compiler that records what it is started with, so both reach the process that uses them.
…ot render The guide gave one remedy, use @scaffold or write a view, for three causes. A plugin's controller already uses @scaffold: its pages are compiled only when the application scaffolds a controller itself. A page the build left out needs its template fixed. And the warning it promised comes only where the application has compiled pages of its own.
Templates and plugin controllers were read only from entries ending in .jar, but the class loader reads any archive on the classpath, so a template in a .zip or a .JAR entry was one the resolver could choose and the build never expanded. Every file entry is now opened as an archive; one that is not an archive holds nothing to read and is passed over.
The application's own templates were read from src/main/templates and from META-INF/templates/scaffolding under each resource source directory. That missed templates the build packages by any other route - a processResources that copies some in, a resource directory another task generates - and, for a generated directory, lost the task that writes it, so the templates could be read before they existed. They are now read, through a new packagedTemplates input, from the output of processResources: what is packaged beside the application's controllers, however it got there, with the task that produces it. Not the whole source set output, which view compilers add directories to without naming themselves as their producers.
Every copy of a template is expanded, so one template path yields a page per copy, from the application, grails-scaffolding and any theme. When one of them could not be expanded, or its page did not compile, the build said only which template path and domain class, or which page, so there was no telling which copy to fix - and the guide told the reader to fix the template the build output names. The task now hands the generator where each copy came from - the application, a jar and its entry, or a classpath directory - named without this machine's paths, since the pages are cached. The generator names it when a template cannot be expanded, and ends every page with a comment naming it, which renders as nothing, so a page the compiler leaves out leads back to its template. The guide says so, and that a copy the application has replaced is compiled too.
…uild output The unit test showed an unreadable controller is left out while the others are expanded, and would have passed with the report back at INFO, which a build does not show - the defect the warning fixed. The functional test now puts a plugin with an unreadable controller on the runtime classpath and finds the warning in the build output, next to a note about a classpath entry that is not an archive, which it does not find there.
jdaugherty
left a comment
There was a problem hiding this comment.
I ran the scaffolding, GSP and Gradle plugin specs and the scaffolding example locally at 8c955be, and they all pass. I also ran the scaffold functional test under the configuration cache with problems set to fail, and it passed. A few comments inline.
A compiled page's class name carries the fully qualified domain class and the digest both, and with sixteen bytes of digest the example's reached 165 characters. With a deeper package and a longer entity name, under a typical Windows user directory, build/gsp-classes/main gets close to the 260-character MAX_PATH that archivers, antivirus and some IDE indexers still trip over. The domain class and the template path are in the name already, so the digest only has to tell apart the copies of one template and the models it is expanded with. Eight bytes do that with room to spare and make every name sixteen characters shorter.
The page compiler never removed anything from its target directory. A page removed or renamed since the last compilation left its class, inner classes and data files behind; the registry, written whole, no longer named them, so they were never served, but every artifact built from a workspace that had not been cleaned carried them. Scaffolded pages made that routine: a page is named for a digest of its template, so every edit to a template left the previous pages behind, one per domain class. After writing the registry, the compiler now removes what the previous registry named and the new one does not - the class, its inner classes and its data files. It removes nothing else, so a file it did not write is left alone, and a page whose name happens to extend a removed one is not taken for its inner class.
… expanded A template that could not be expanded for a domain class was left out with a line on stderr, whoever's it was. For a copy a dependency supplies that is right: it may be a stock template the application has replaced, which the resolver never chooses, and where it is chosen it is expanded when rendered, as it was before any page was compiled. But a template in src/main/templates/scaffolding, or one the application packages, is its own code as much as a view is: appending an invalid <%-- probe --%> to one left the build green and every view expanded from it failing when rendered, in a native image certainly. The generator now returns what it could not expand instead of printing it, and writes it to a report. The task, which knows whose each copy is, warns through Gradle for a dependency's and fails for the application's, naming each template and domain class.
A generated page that did not compile was left out, with a warning, anywhere under grails-scaffolded, so a page expanded from a template of the application's own that did not compile left the build green and the view failing when rendered - where a hand-written view that does not compile fails the build. The page compilation now takes the pages it may leave out rather than directories: compileGroovyPages has optionalPages, files listing them, passed to the forked compiler as grails.views.gsp.optionalPages in place of grails.views.gsp.generatedDirectories. The generator reports every page it writes, and generateScaffoldedViews lists as optional only those expanded from a template a dependency supplies. A page from the application's own template has to compile, as a view does.
…their exchange generateScaffoldedViews runs ScaffoldedPagesGenerator from the application's grails-scaffolding and talks to it through its command line and the plan, origins and report files, and the two ship apart: the generator in grails-scaffolding, the task in the Gradle plugin. A build pairing one version with another ran a generator that did not understand what it was handed, which stopped with a usage error and failed the build, saying nothing about why. The generator now declares the version of the exchange it speaks in a PROTOCOL constant. The task reads it from the class file the application's class loader finds first, without running it, and when it is not the version the task speaks - or there is none, from a generator older than the constant - warns and compiles no scaffolded page, as it already does when the library has no generator, so the views are expanded when rendered instead of the build failing.
A build that generates pages had to copy them into the views directory before compiling, because a second compilation writes a second gsp/views.properties and an archive keeps only the first. The page compiler now also takes directories of generated pages - on the forked compiler, as the grails.views.gsp.generatedViewDirectories system property - and compiles their pages in the same compilation, each named by its path under the directory holding it, as though it were among the views. A page of the views at the same path takes precedence. A compiler that does not know the property compiles no generated page, which is then produced when it is first rendered.
Whether a project generated scaffolded pages was decided while the build was configured, by looking for @scaffold in its own controller sources, because doing so meant copying grails-app/views and the pages into build/generated/views for compileGroovyPages - too much to put on every project. What it could not see got no pages: an application whose only scaffolded controllers come from its plugins, and, in a native image, every one of their views failed. compileGroovyPages now compiles grails-app/views where it is, with the generated pages as generatedViews in the same compilation, so nothing is copied and a project without scaffolding compiles exactly what it did. That lets generateScaffoldedViews be registered in every project: it finds the scaffolded controllers, the plugins' included, from their compiled classes, and in a project that scaffolds nothing it writes nothing. The generated pages are part of the compilation's source, so a project whose only pages are generated still compiles them. stageGroovyPages and GroovyPagePlugin.scaffoldsAnyController, both in 8.0.0-RC1, are gone; the upgrade guide says so.
jdaugherty
left a comment
There was a problem hiding this comment.
I re-ran the scaffolding, GSP core, grails-web-gsp and Gradle plugin tests, the scaffolding and scaffolding-fields examples, and codeStyle on the touched modules at 7ac17a4. All of them pass.
I also checked the three earlier points in the example app:
- Appending
<%-- probe --%>tosummary.gspnow failsgenerateScaffoldedViews, naming each domain class. A literal\${ 1 + }in the same template failscompileGroovyPages. - After editing
summary.gsp,bootJarandbuild/gsp-classes/mainhold only the classes for the new digest. - Page names carry 16 hex characters, and the longest class file in the example is now 149 characters.
The new wiring works under the configuration cache: a TestKit build with --configuration-cache-problems=fail reuses the entry and hands the plugin's page to the compiler as optional. In a project that scaffolds nothing, generateScaffoldedViews took 0.047s, so registering it everywhere costs very little.
One regression from the last commit and a doc nit, both inline.
| @PathSensitive(PathSensitivity.RELATIVE) | ||
| FileTree getSource() { | ||
| return super.getSource() | ||
| return super.getSource().plus(generatedViews.asFileTree) |
There was a problem hiding this comment.
The problem is the srcDir property above rather than this line: it is still @InputDirectory, so the task now fails validation when grails-app/views doesn't exist but there are generated pages to compile.
Before this commit, a scaffolding project compiled from the staged copy, which always existed. A project with no views had no source, so it was skipped as NO-SOURCE before validation ran. Now the generated pages make the source non-empty, and Gradle checks srcDir first. I reproduced it with a TestKit project based on gsp-compile-classpath: a @Scaffold(Integer) controller, a template in src/main/templates/scaffolding, and no grails-app/views. compileGroovyPages fails with:
Type 'org.grails.gradle.plugin.views.gsp.GroovyPageForkCompileTask' property 'srcDir' specifies directory '.../grails-app/views' which doesn't exist
The same project compiles at 8c955be. It also fails for the case this commit is for, an application whose only scaffolded controllers come from a plugin, when that application has no views directory. The likeliest way to hit it is a plugin project that scaffolds controllers but has no views of its own.
getSource() already fingerprints the pages, so srcDir could be @Internal, or @InputFiles, which accepts a missing directory. The forked compiler already skips a directory that doesn't exist. Could the new test "a plugin's scaffolded controllers have their pages generated in an application that scaffolds none of its own" also run compileGroovyPages with no grails-app/views? Right now it stops at generateScaffoldedViews, which is why it didn't catch this.
There was a problem hiding this comment.
Thanks, reproduced with the extended test and fixed in b5534e3. srcDir is @Internal now: its pages are fingerprinted through getSource(), and the forked compiler already skips a directory that doesn't exist.
The plugin-controller functional test now also runs compileGroovyPages in a project with no grails-app/views. The "scaffolds nothing" test now also checks that compileGroovyPages is still NO-SOURCE there.
| A CRUD interface will also be generated. To access this open `http://localhost:8080/book` in a browser. | ||
|
|
||
| The Gradle plugin expands scaffold templates into `build/generated/scaffolded-views` with `generateScaffoldedViews`. Then `stageGroovyPages` combines them with application views in `build/generated/views`, and `compileGroovyPages` compiles them so packaged applications and native images do not need to generate them on the first request. Running `compileGroovyPages` runs these prerequisite tasks automatically. Handwritten views in the application or supplied by a plugin still take precedence over generated scaffold views. | ||
| When the application is built, the Gradle plugin's `generateScaffoldedViews` task expands the scaffolding templates for every domain class a controller scaffolds with `@Scaffold`, the application's or a plugin's, and `compileGroovyPages` compiles the resulting pages in the same compilation as the application's views. The templates are expanded by the application's own scaffolding library and Groovy, in a JVM on its runtime classpath, exactly as they would be expanded when the view is requested. That library has to match the Grails Gradle plugin: with a `grails-scaffolding` of another version, the build warns and compiles no scaffolded page, and the views are expanded on their first use. A packaged application then renders a scaffolded view without generating it on the first request, and a native image, which cannot compile a page at runtime, can render it at all. |
There was a problem hiding this comment.
Nit: the task compares the version of the exchange the generator declares, not the library's version, so a grails-scaffolding from another release still has its pages compiled as long as that exchange hasn't changed. "Of another version" reads as though any mismatch, a patch release included, turns compilation off. Maybe: "with a grails-scaffolding the plugin cannot work with, the build warns and compiles no scaffolded page".
There was a problem hiding this comment.
Agreed, reworded in 212cdd9 to "With a grails-scaffolding the Grails Gradle plugin cannot work with, the build warns and compiles no scaffolded page".
compileGroovyPages declared its views directory as an input directory, and an input directory that does not exist fails validation. Before the pages were compiled beside the views, a project without views had no source and was skipped as NO-SOURCE before validation ran; now its generated pages are source, so a scaffolding project with no grails-app/views - most likely a plugin that scaffolds controllers and has no views of its own, or an application whose only scaffolded controllers come from one - failed with "property 'srcDir' specifies directory '.../grails-app/views' which doesn't exist". The directory is now internal: its pages are fingerprinted as part of the source already, and the forked compiler skips a directory that does not exist. The functional test of a plugin's scaffolded controllers now compiles its pages in a project with no views, and the one of a project that scaffolds nothing checks it is still skipped as NO-SOURCE.
The guide said a grails-scaffolding "of another version" leaves the scaffolded pages to runtime, which reads as though any mismatch, a patch release included, turned compilation off. What is compared is the version of the exchange between the task and the generator, which does not change with every release.
The command-line test of ScaffoldedPagesGenerator built the page paths it expected in the report by removing the output directory and a slash from each page file's path. On Windows that path uses backslashes, so nothing was removed and the test failed, though the generator reports a page's path as its URI does, with forward slashes, on every platform - which is what the page compiler matches optional pages against. The test now expects the path from the page's URI.
✅ All tests passed ✅🏷️ Commit: e61d5cb Learn more about TestLens at testlens.app/docs. |
matrei
left a comment
There was a problem hiding this comment.
Thanks, this is a much better shape than #16385. The build no longer has to predict the resolver's precedence decisions: it compiles every candidate, and the resolver keeps choosing exactly as before and only swaps the expansion for a lookup. I read through the runtime (ScaffoldingViewResolver, ScaffoldedPages, ScaffoldedPagesGenerator), the GSP compiler changes and the Gradle side, and checked a few places where the build and the runtime could disagree:
model(String)in the generator andmodel(Class)in the resolver both end up atnew ModelImpl(fullName)(the generator has nodefaultPackage), and ASM'sType.classNamematchesClass.name, nested classes included, so both sides compute the same digest.- The build reads
domainbeforevalue, the runtime reads onlydomain(), andScaffoldingControllerInjectornormalises intodomain, so they agree.@Scaffoldis not@Inherited, so reading only the controller's own class file matchesgetAnnotation(Scaffold). src/main/templatesis packaged asMETA-INF/templates(GrailsGradlePlugin), so the application copies the task plans are real runtime candidates, not just dev-mode ones.grails-plugin.xmlis written into the compilation's classes directory, so the directory-form plugin detection infindScaffoldedControllersworks for a sibling plugin project.- Pages compile into the default package, so the top-level
targetDir.listFiles()inremoveStalePagessees everything it registered, andcompileWebappGroovyPageshas its own destination, so the two registries don't clean up after each other.
Approving. None of the points below block the merge: 1 and 2 are about test coverage and can be done here or in a follow-up, and 3 is nits.
1. Nothing in CI renders a precompiled scaffolded page
PrecompiledScaffoldPagesSpec shows that the name the resolver computes matches a class in the compiled registry. But it stubs GroovyPagesTemplateEngine, so the compiled page is never loaded or rendered. The example's Geb tests do render the scaffolded views. However, in a Grails build the compiled pages are deliberately kept off the test class path (see the comment above compiledPages in GroovyPagePlugin), so those tests go through the runtime expansion, not the compiled pages.
Two things can go wrong unnoticed because the fallback hides them:
- a compiled scaffolded page that fails only when it renders (its data files, static compilation of the expanded page, and so on);
- the build and the resolver drifting apart on the page name.
I checked this by hand at e61d5cb. I built :grails-test-examples-scaffolding:bootJar and ran it with grails.env=test and DEBUG on org.grails.web.servlet.view. /book, /book/create, /book/show/1, /user, /user/create, /user/edit/1, /community/user and /community/user/create all return 200, each resolved from its compiled page. For example:
Resolved GSP view at URI [/grails-scaffolded/com.example.Book/index-d3113cfdae06fe73.gsp]
Resolved GSP view at URI [/grails-scaffolded/com.example.community.User/create-b6f0ef269f3e30f6.gsp]
Resolved GSP view at URI [/grails-scaffolded/com.example.User/edit-f032b9e247a0740c.gsp]
Nothing was expanded, and nothing was reported as not compiled. So it works today; I'd just like CI to keep it that way.
Could the spec use the real template engine and render one page per controller? Or, better, add a smoke test against the packaged application that also asserts the resolver fell back for nothing (reportedPages empty, or no "was not compiled by the build" log line)? Native images are the motivation here, so the packaged-JVM path should at least be covered by a test.
2. The spec goes through non-public API
PrecompiledScaffoldPagesSpec calls the newly protected tryGenerateScaffoldedView(viewName, controllerClass) and reads the protected reportedPages. Our guideline is to test through the API an application uses. You could bind a GrailsWebRequest with the controller class and call resolveViewName(...). That tests the same thing, and the new protected overload may then not be needed at all.
3. Nits
generateScaffoldedViewsrewrites every page each time it runs (outputDir.deleteDir()). The pages' mtimes are then always newer than their classes, soGroovyPageCompiler's up-to-date check recompiles every scaffolded page, even when only one template changed. This only happens when the task's inputs change, so it's harmless.- The page digest only covers the model names a template mentions (
text.contains(name)). A template that usespackagePaththerefore finds no compiled page when the app is built on Windows and run on Linux, or the other way round. ThefileSeparatortask input protects the build cache, not this runtime lookup. On the JVM that costs one expansion, so a sentence in the guide is enough. - The upgrade guide names
GroovyPagePlugin.scaffoldsAnyController, a protected method of the Gradle plugin. Saying that RC1'sstageGroovyPagestask is gone is enough for users, so I'd drop the method name. - The new paragraphs in
scaffolding.adocare dense (for example "A copy a dependency supplies is not, and because every copy is compiled, that includes a copy the application has replaced with its own."). A short list would be easier to scan: what gets compiled, what fails the build, what is still expanded at runtime, and what to do for a native image.
#16385 keeps precompiled scaffold pages in the controllers' view directories, so the build has to predict every precedence decision the runtime resolver makes: declared views, plugin views, namespaces, template overrides, controllers sharing a name. Each review round found another place that prediction drifted, and where it could not predict safely it gave up precompilation. Namespaced controllers, same-named controllers scaffolding different domains, and namespace-specific templates are all still expanded at runtime, which a native image cannot do.
This moves the decision back to the runtime and precompiles only the expensive part.
generateScaffoldedViewsreads the scaffolded controllers with ASM: the application's own, and those of every plugin on the runtime classpath (an archive or directory carryingMETA-INF/grails-plugin.xml). For each, it plans every copy of every template: the application's own, fromsrc/main/templates/scaffoldingand from whatprocessResourcespackages beside its controllers (however the build puts it there), and every copy on the runtime classpath. A namespace-specific template is planned only for a controller with a namespace. The namespace can be declared on the class, supplied by a trait's static accessor, inherited from a superclass, or taken from an interface constant. The task then runsorg.apache.grails.scaffolding.ScaffoldedPagesGeneratorin a JVM on the application's runtime classpath. It expands each planned template with the runtime's ownModelBuilder,ScaffoldedPagesand Groovy, and writes it, incompileGroovyPages' encoding, tograils-scaffolded/<domain class>/<template path>-<digest>.gsp, ending with a GSP comment that names the copy it came from (by jar name, not by path, so the page stays cacheable).compileGroovyPagescompilesgrails-app/viewswhere it is, with the generated pages in the same compilation (oneviews.properties), so nothing is copied. The task is registered in every project: which controllers are scaffolded, a plugin's among them, is found from compiled classes rather than guessed while the build is configured, and a project that scaffolds nothing generates nothing. The digest covers the template and the values of the model names the template mentions.ScaffoldingViewResolverchooses the template exactly as before. Where it used to expand and compile it, it computes the same name and serves the compiled page when there is one. Otherwise it expands the template as before and logs it once: at INFO on the JVM, where that still works, and as a WARN in a native image, where the expansion that follows fails.Why every copy rather than the one the resolver is expected to choose: on the JVM the resolver looks beside the controller's class first, but a native image keeps no class files and takes the first copy on the classpath it was built from. The build cannot know which copy that is, so it doesn't try to.
Consequences:
grails-scaffoldedcannot be a controller's view directory (it has a hyphen), and a page is used only for the exact template and model it was expanded from. The plugin view-index scanning and view-directory collision handling from Preserve view precedence when precompiling scaffolds #16385 are no longer needed and are removed.src/main/templates/scaffoldingor packaged underMETA-INF/templates/scaffolding) is its code, as a view is. If it cannot be expanded for a domain class,generateScaffoldedViewsfails, naming each template and domain class. If a page expanded from it does not compile,compileGroovyPagesfails, as it does for a hand-written view.compileGroovyPagestakesoptionalPages, files listing them, passed to the forked compiler asgrails.views.gsp.optionalPages.GroovyPageCompilerleaves out one of those that does not compile, and the build output names it; the page's closing comment says which copy it came from. It is then expanded when rendered, as before.GroovyPageCompilerremoves what an earlier run compiled for a page the new registry no longer names: its class, inner classes and data files, and nothing it did not register. Digest-named pages otherwise left a dead class behind on every template edit, packaged untilclean; renamed hand-written views did too. The digest in a page's name is 8 bytes (16 hex characters), keeping class names clear of Windows path limits.static scaffoldproperty, and pages the build left out. The scaffolding guide lists both and what to do about each.stageGroovyPagestask andGroovyPagePlugin.scaffoldsAnyControllerare removed; the upgrade guide says so.PROTOCOLconstant, which the task reads from the class file before running it. On a mismatch, or with a generator that declares none, the task warns and compiles no scaffolded page, as when the library has no generator at all, so the views are expanded when rendered instead of the build failing.getResourcereturns the first copy of a template in classpath order.GroovyPageViewResolver.createGroovyPageViewis now protected.ScaffoldingViewResolver.tryGenerateScaffoldedView(viewName, controllerClass)is the resolver's own template lookup for a controller; an overload that takes the candidate paths is also protected.DefaultGroovyPageLocator.isPrecompiledAvailable()is public.GenerateScaffoldedViewsTaskno longer has RC1'stemplateClasspathandapplicationViewsproperties; the upgrade guide says so.Cost: once a view is resolved, a request costs one more view-cache key computation and map lookup than with #16385 (the parent resolver caches the null result for a scaffolded view). The first request for each view reads and hashes its template. The build forks one JVM when scaffolding inputs change; the task is cacheable, with the runtime classpath as an input. Pages are compiled per copy of a template, so a template with more than one copy (an application override of a stock template, say) costs one page per copy per domain class.
Validation:
All pass (71, 267, 22, 15 and 304 tests). Coverage added:
grails-test-examples-scaffolding:testgainsPrecompiledScaffoldPagesSpec. It runs the real resolver, through its own template lookup, against the real templates and the pages the example's own build compiled. It checks that every view of a controller, a namespaced controller, and a same-named controller scaffolding a different domain is served from a compiled page. That includes an application template and a namespace-specific application template.compileGroovyPagesagainst a stand-in compiler, to show that the generated directories and the encoding reach the forked process. It also covers templates packaged by aprocessResourcescustomization and by a task-generated resource directory, a dependency copy competing with the application's, and the warning for an unreadable plugin controller.Not run, and not claimed to pass: the example's Geb integration tests, the repository-wide
aggregateViolationscheck, and a native build of a full application. Resource resolution in a native image was checked only with the standalone probe.Known and not addressed here:
normalization { runtimeClasspath { metaInf { ignoreCompletely() } } }hides template edits inside dependency jars from the task's cache key.