Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion grails-doc/src/en/guide/upgrading/upgrading60x.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1162,4 +1162,79 @@ grails {
}
----

This allows you to use classes from these packages without explicit imports throughout your Groovy code. The `starImports` configuration works independently and will be combined with any imports from `importGrailsCommonAnnotations` or `importJavaTime` flags if those are also enabled.
This allows you to use classes from these packages without explicit imports throughout your Groovy code. The `starImports` configuration works independently and will be combined with any imports from `importGrailsCommonAnnotations` or `importJavaTime` flags if those are also enabled.

===== 12.30 Scaffolding Namespace View Defaults

Grails 7.1 introduces an opt-in feature for scaffolding that allows namespace-specific scaffolded templates to take priority over non-namespaced view fallbacks.

====== Background

Previously, when a namespace controller requested a view, the scaffolding plugin would only generate a scaffolded view if no view existed at all. This meant that if you had:

* A namespace controller (e.g., `namespace = 'admin'`)
* A non-namespaced view in `grails-app/views/event/index.gsp`
* A namespace-specific scaffolded template in `src/main/templates/scaffolding/admin/index.gsp`

The non-namespaced view would always be used, and the namespace-specific scaffolded template would be ignored.

====== New Behavior

With the new `enableNamespaceViewDefaults` configuration, namespace-specific scaffolded templates can now override non-namespaced view fallbacks. This provides better support for namespace-specific customization of scaffolded views.

====== Configuration

To enable this feature, add the following to your `application.yml`:

[source,yml]
.application.yml
----
grails:
scaffolding:
enableNamespaceViewDefaults: true
----

====== View Resolution Priority

When `enableNamespaceViewDefaults` is enabled, the view resolution priority for namespace controllers is:

1. **Namespace-specific view** (e.g., `grails-app/views/admin/event/index.gsp`)
- If exists → used (highest priority)

2. **Namespace-specific scaffolded template** (e.g., `src/main/templates/scaffolding/admin/index.gsp`)
- If exists and no namespace view → used (overrides fallback)

3. **Non-namespaced view fallback** (e.g., `grails-app/views/event/index.gsp`)
- Used if no namespace view or scaffolded template exists

4. **Non-namespaced scaffolded template** (e.g., `src/main/templates/scaffolding/index.gsp`)
- Used if no views exist at all

====== Example Use Case

This feature is useful when you want different scaffolded views for different namespaces:

[source,groovy]
----
// Regular event controller
@Scaffold(RestfulServiceController<Event>)
class EventController {
}

// Admin event controller with namespace
@Scaffold(RestfulServiceController<Event>)
class EventController {
static namespace = 'admin'
}
----

With `enableNamespaceViewDefaults: true`, you can provide:

* `src/main/templates/scaffolding/index.gsp` - Default scaffolded template
* `src/main/templates/scaffolding/admin/index.gsp` - Admin-specific scaffolded template

The admin controller will use the admin-specific template even if a non-namespaced view exists.

====== Backward Compatibility

This feature is **disabled by default** (`false`), ensuring complete backward compatibility. Existing applications will continue to work without any changes. Enable the feature only when you need namespace-specific scaffolded template support.
7 changes: 7 additions & 0 deletions grails-scaffolding/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ dependencies {
api project(':grails-rest-transforms')

compileOnly 'jline:jline'

testImplementation 'org.spockframework:spock-core'
testImplementation project(':grails-web-gsp')
testImplementation project(':grails-core')
testImplementation 'jakarta.servlet:jakarta.servlet-api'
testImplementation 'org.springframework:spring-web'
testImplementation 'net.bytebuddy:byte-buddy'
}

apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Plugin that generates scaffolded controllers and views for a Grails application.
bean.lazyInit = true
bean.parent = 'abstractViewResolver'
enableReload = reloadEnabled
enableNamespaceViewDefaults = config.getProperty('grails.scaffolding.enableNamespaceViewDefaults', Boolean, false)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import org.springframework.core.io.UrlResource
import org.springframework.web.servlet.View

import grails.codegen.model.ModelBuilder
import grails.core.GrailsControllerClass
import grails.io.IOUtils
import grails.plugin.scaffolding.annotation.Scaffold
import grails.util.BuildSettings
Expand Down Expand Up @@ -84,13 +85,22 @@ class ScaffoldingViewResolver extends GroovyPageViewResolver implements Resource
this.templateOverridePluginDescriptor = templateOverridePluginDescriptor
}

private static final Object NULL_SCAFFOLD_VALUE = new Object()

ResourceLoader resourceLoader
protected Map<String, View> generatedViewCache = new ConcurrentHashMap<>()
protected Map<Class, Object> scaffoldValueCache = new ConcurrentHashMap<>()
protected boolean enableReload = false
protected boolean enableNamespaceViewDefaults = false

void setEnableReload(boolean enableReload) {
this.enableReload = enableReload
}

void setEnableNamespaceViewDefaults(boolean enableNamespaceViewDefaults) {
this.enableNamespaceViewDefaults = enableNamespaceViewDefaults
}

protected String buildCacheKey(String viewName) {
String viewCacheKey = groovyPageLocator.resolveViewFormat(viewName)
String currentControllerKeyPrefix = resolveCurrentControllerKeyPrefixes(viewName.startsWith('/'))
Expand Down Expand Up @@ -128,53 +138,126 @@ class ScaffoldingViewResolver extends GroovyPageViewResolver implements Resource
@Override
protected View loadView(String viewName, Locale locale) throws Exception {
def view = super.loadView(viewName, locale)
if (view == null) {
String cacheKey = buildCacheKey(viewName)
view = enableReload ? null : generatedViewCache.get(cacheKey)
if (view != null) {

if (view != null) {
if (!enableNamespaceViewDefaults) {
return view
} else {
def webR = GrailsWebRequest.lookup()
def controllerClass = webR.controllerClass

def scaffoldValue = controllerClass?.getPropertyValue('scaffold')
if (!scaffoldValue) {
Scaffold scaffoldAnnotation = controllerClass?.clazz?.getAnnotation(Scaffold)
scaffoldValue = scaffoldAnnotation?.domain()
if (scaffoldValue == Void) {
scaffoldValue = null
}
}

def controllerClass = GrailsWebRequest.lookup()?.controllerClass
if (controllerClass?.namespace) {
// Check if the view found is already a namespace-specific view
def isNamespaceSpecificView = view instanceof GroovyPageView &&
view.url?.contains("/${controllerClass.namespace}/")

if (!isNamespaceSpecificView) {
// View is a fallback (non-namespaced), check for namespace-specific scaffolded template
return tryGenerateScaffoldedView(viewName, controllerClass) { String shortViewName ->
// Only check namespace-specific template
resolveResource(controllerClass.clazz, "${controllerClass.namespace}/${shortViewName}")
} ?: view
}
}
return view
}

def controllerClass = GrailsWebRequest.lookup()?.controllerClass

return tryGenerateScaffoldedView(viewName, controllerClass) { String shortViewName ->
Resource res = controllerClass?.namespace ? resolveResource(controllerClass.clazz, "${controllerClass.namespace}/${shortViewName}") : null
if (!res?.exists()) {
res = resolveResource(controllerClass.clazz, shortViewName)
}
return res
}
}

/**
* Attempts to generate a scaffolded view for the given controller
* @param viewName The view name
* @param controllerClass The controller class
* @param resourceResolver Closure that resolves the scaffold template resource given a short view name
* @return The generated scaffolded view, or null if not applicable
*/
private View tryGenerateScaffoldedView(String viewName, GrailsControllerClass controllerClass, Closure<Resource> resourceResolver) {
def scaffoldValue = getScaffoldValue(controllerClass)
if (!(scaffoldValue instanceof Class)) {
return null
}

String cacheKey = buildCacheKey(viewName)

if (scaffoldValue instanceof Class) {
def shortViewName = viewName.substring(viewName.lastIndexOf('/') + 1)
Resource res = controllerClass.namespace ? resolveResource(controllerClass.clazz, "${controllerClass.namespace}/${shortViewName}") : null
if (!res?.exists()) {
res = resolveResource(controllerClass.clazz, shortViewName)
}
if (res.exists()) {
def model = model((Class) scaffoldValue)
def viewGenerator = new GStringTemplateEngine()
Template t = viewGenerator.createTemplate(res.URL)

def contents = new FastStringWriter()
t.make(model.asMap()).writeTo(contents)

def template = templateEngine.createTemplate(new ByteArrayResource(contents.toString().getBytes(templateEngine.gspEncoding), "view:$cacheKey"), !enableReload)
view = new GroovyPageView()
view.setServletContext(getServletContext())
view.setTemplate(template)
view.setApplicationContext(getApplicationContext())
view.setTemplateEngine(templateEngine)
view.afterPropertiesSet()
generatedViewCache.put(cacheKey, view)
return view
} else {
return view
}
// Check cache first
def cachedScaffoldedView = enableReload ? null : generatedViewCache.get(cacheKey)
if (cachedScaffoldedView != null) {
return cachedScaffoldedView
}

def shortViewName = viewName.substring(viewName.lastIndexOf('/') + 1)
Resource scaffoldResource = resourceResolver.call(shortViewName)

if (scaffoldResource?.exists()) {
return generateScaffoldedView(scaffoldValue, scaffoldResource, cacheKey)
}

return null
}

private Object getScaffoldValue(GrailsControllerClass controllerClass) {
if (!controllerClass) {
return null
}

// Cache the scaffold value to avoid repeated reflection
Class controllerClazz = controllerClass.clazz
if (scaffoldValueCache.containsKey(controllerClazz)) {
Object cached = scaffoldValueCache.get(controllerClazz)
return cached == NULL_SCAFFOLD_VALUE ? null : cached
}

def scaffoldValue = controllerClass.getPropertyValue('scaffold')
if (!scaffoldValue) {
Scaffold scaffoldAnnotation = controllerClazz?.getAnnotation(Scaffold)
if (scaffoldAnnotation) {
// Check domain() attribute for view scaffolding - domain class is required for model generation.
// Note: For @Scaffold(RestfulServiceController<T>), the AST transformation
// (ScaffoldingControllerInjector) extracts T and sets it as domain() at compile time,
// so this works for both @Scaffold(domain = User) and @Scaffold(RestfulServiceController<User>).
scaffoldValue = scaffoldAnnotation.domain()
if (scaffoldValue == Void) {
scaffoldValue = null
}
}
}

// Cache the result (even if null, to avoid repeated lookups)
scaffoldValueCache.put(controllerClazz, scaffoldValue == null ? NULL_SCAFFOLD_VALUE : scaffoldValue)
return scaffoldValue
}

private View generateScaffoldedView(Class scaffoldValue, Resource res, String cacheKey) {
def model = model((Class) scaffoldValue)
def viewGenerator = new GStringTemplateEngine()
Template t = viewGenerator.createTemplate(res.URL)

def contents = new FastStringWriter()
t.make(model.asMap()).writeTo(contents)

def template = templateEngine.createTemplate(new ByteArrayResource(contents.toString().getBytes(templateEngine.gspEncoding), "view:$cacheKey"), !enableReload)
def view = new GroovyPageView()
view.setServletContext(getServletContext())
view.setTemplate(template)
view.setApplicationContext(getApplicationContext())
view.setTemplateEngine(templateEngine)
view.afterPropertiesSet()
generatedViewCache.put(cacheKey, view)
return view
}

@Override
void clearCache() {
super.clearCache()
generatedViewCache.clear()
scaffoldValueCache.clear()
}
}
Loading
Loading