diff --git a/grails-doc/src/en/guide/upgrading/upgrading60x.adoc b/grails-doc/src/en/guide/upgrading/upgrading60x.adoc index d9bb57b550e..245502dcb55 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading60x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading60x.adoc @@ -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. \ No newline at end of file +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) +class EventController { +} + +// Admin event controller with namespace +@Scaffold(RestfulServiceController) +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. \ No newline at end of file diff --git a/grails-scaffolding/build.gradle b/grails-scaffolding/build.gradle index 8fbabf34ec3..46546203908 100644 --- a/grails-scaffolding/build.gradle +++ b/grails-scaffolding/build.gradle @@ -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 { diff --git a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/ScaffoldingGrailsPlugin.groovy b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/ScaffoldingGrailsPlugin.groovy index 117cb9fe38b..4e523222ad2 100644 --- a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/ScaffoldingGrailsPlugin.groovy +++ b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/ScaffoldingGrailsPlugin.groovy @@ -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) } } } diff --git a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/ScaffoldingViewResolver.groovy b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/ScaffoldingViewResolver.groovy index 5327f32af69..241d4ce09d6 100644 --- a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/ScaffoldingViewResolver.groovy +++ b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/ScaffoldingViewResolver.groovy @@ -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 @@ -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 generatedViewCache = new ConcurrentHashMap<>() + protected Map 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('/')) @@ -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 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), 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). + 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() + } } diff --git a/grails-scaffolding/src/test/groovy/grails/plugin/scaffolding/ScaffoldingViewResolverSpec.groovy b/grails-scaffolding/src/test/groovy/grails/plugin/scaffolding/ScaffoldingViewResolverSpec.groovy new file mode 100644 index 00000000000..c597fd163eb --- /dev/null +++ b/grails-scaffolding/src/test/groovy/grails/plugin/scaffolding/ScaffoldingViewResolverSpec.groovy @@ -0,0 +1,273 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.plugin.scaffolding + +import grails.core.GrailsControllerClass +import grails.plugin.scaffolding.annotation.Scaffold +import org.grails.gsp.GroovyPagesTemplateEngine +import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.grails.web.servlet.view.GroovyPageView +import org.springframework.core.io.Resource +import org.springframework.web.context.request.RequestContextHolder +import spock.lang.Specification + +class ScaffoldingViewResolverSpec extends Specification { + + static final String TEST_NAMESPACE = "admin" + static final String TEST_VIEW_NAME = "/event/index" + + ScaffoldingViewResolver resolver + GrailsConventionGroovyPageLocator mockPageLocator + GroovyPagesTemplateEngine mockTemplateEngine + GrailsWebRequest mockWebRequest + GrailsControllerClass mockControllerClass + + def setup() { + resolver = new ScaffoldingViewResolver() + mockPageLocator = Mock(GrailsConventionGroovyPageLocator) + mockTemplateEngine = Mock(GroovyPagesTemplateEngine) + mockControllerClass = Mock(GrailsControllerClass) + + // Create GrailsWebRequest with required constructor args + def mockHttpRequest = Mock(jakarta.servlet.http.HttpServletRequest) + def mockHttpResponse = Mock(jakarta.servlet.http.HttpServletResponse) + def mockServletContext = Mock(jakarta.servlet.ServletContext) + mockWebRequest = Stub(GrailsWebRequest, constructorArgs: [mockHttpRequest, mockHttpResponse, mockServletContext]) + + resolver.groovyPageLocator = mockPageLocator + resolver.templateEngine = mockTemplateEngine + + // Set up thread local + RequestContextHolder.setRequestAttributes(mockWebRequest) + } + + def cleanup() { + RequestContextHolder.resetRequestAttributes() + resolver.clearCache() + } + + // Helper methods + void setupScaffoldController(Class controllerClazz, Class scaffoldDomain = null) { + mockControllerClass.clazz >> controllerClazz + mockControllerClass.getPropertyValue('scaffold') >> scaffoldDomain + mockWebRequest.controllerClass >> mockControllerClass + } + + void setupNamespaceController(String namespace = TEST_NAMESPACE) { + mockControllerClass.namespace >> namespace + } + + GroovyPageView mockViewWithUrl(String url) { + def view = Mock(GroovyPageView) + view.url >> url + return view + } + + void "test enableNamespaceViewDefaults defaults to false"() { + expect: + !resolver.enableNamespaceViewDefaults + } + + void "test scaffold value cache stores null values for non-scaffold controllers"() { + given: + setupScaffoldController(String) + + when: + def result = resolver.getScaffoldValue(mockControllerClass) + + then: + result == null + resolver.scaffoldValueCache.containsKey(String) + resolver.scaffoldValueCache.get(String) == ScaffoldingViewResolver.NULL_SCAFFOLD_VALUE + } + + void "test scaffold value cache returns cached value"() { + given: + setupScaffoldController(String) + def cachedValue = String + resolver.scaffoldValueCache.put(String, cachedValue) + + when: + def result = resolver.getScaffoldValue(mockControllerClass) + + then: + result == cachedValue + 0 * mockControllerClass.getPropertyValue(_) // Uses cache + } + + void "test scaffold value cache handles annotation"() { + given: + setupScaffoldController(TestScaffoldController) + + when: + def result = resolver.getScaffoldValue(mockControllerClass) + + then: + result == TestDomain + resolver.scaffoldValueCache.containsKey(TestScaffoldController) + } + + void "test clearCache clears both view and scaffold caches"() { + given: + resolver.generatedViewCache.put("test", Mock(GroovyPageView)) + resolver.scaffoldValueCache.put(String, String) + + when: + resolver.clearCache() + + then: + resolver.generatedViewCache.isEmpty() + resolver.scaffoldValueCache.isEmpty() + } + + void "test buildCacheKey includes view name"() { + given: + mockPageLocator.resolveViewFormat(TEST_VIEW_NAME) >> TEST_VIEW_NAME + + when: + def cacheKey = resolver.buildCacheKey(TEST_VIEW_NAME) + + then: + cacheKey != null + cacheKey.contains(TEST_VIEW_NAME) + } + + void "test namespace controller without scaffold annotation returns null scaffold value"() { + given: + resolver.enableNamespaceViewDefaults = true + setupScaffoldController(String) + setupNamespaceController() + + expect: + resolver.getScaffoldValue(mockControllerClass) == null + } + + void "test tryGenerateScaffoldedView returns null for non-scaffold controller"() { + given: + setupScaffoldController(String) + + when: + def result = resolver.tryGenerateScaffoldedView(TEST_VIEW_NAME, mockControllerClass) { shortViewName -> + Mock(Resource) + } + + then: + result == null + } + + void "test tryGenerateScaffoldedView uses generated view cache"() { + given: + def cacheKey = "test-cache-key" + def cachedView = Mock(GroovyPageView) + resolver.generatedViewCache.put(cacheKey, cachedView) + + expect: + resolver.generatedViewCache.get(cacheKey) == cachedView + } + + void "test tryGenerateScaffoldedView returns null when resource does not exist"() { + given: + setupScaffoldController(TestScaffoldController, TestDomain) + mockPageLocator.resolveViewFormat(TEST_VIEW_NAME) >> TEST_VIEW_NAME + + when: + def result = resolver.tryGenerateScaffoldedView(TEST_VIEW_NAME, mockControllerClass) { shortViewName -> + def resource = Mock(Resource) + resource.exists() >> false + return resource + } + + then: + result == null + } + + void "test RestfulServiceController annotation without AST transformation returns null"() { + given: + setupScaffoldController(TestRestfulServiceScaffoldController) + + when: + def result = resolver.getScaffoldValue(mockControllerClass) + + then: + // This test validates RAW annotation behavior (pre-AST transformation). + // In real applications, ScaffoldingControllerInjector AST transformation extracts + // the generic type from RestfulServiceController and sets domain() + // at compile time, so @Scaffold(RestfulServiceController) DOES work. + // See grails-test-examples/scaffolding for working integration tests. + result == null + resolver.scaffoldValueCache.containsKey(TestRestfulServiceScaffoldController) + } + + void "test Scaffold annotation with domain attribute works correctly"() { + given: + setupScaffoldController(TestScaffoldController, TestDomain) + + when: + def result = resolver.getScaffoldValue(mockControllerClass) + + then: + // This tests @Scaffold(domain = TestDomain) AND validates the post-AST behavior + // of @Scaffold(RestfulServiceController) since AST transformation + // sets domain = TestDomain at compile time for both patterns + result == TestDomain + resolver.scaffoldValueCache.containsKey(TestScaffoldController) + } + + void "test namespace view URL detection identifies namespace-specific views"() { + given: + def namespaceView = mockViewWithUrl("/grails-app/views/${TEST_NAMESPACE}/event/index.gsp") + def nonNamespaceView = mockViewWithUrl("/grails-app/views/event/index.gsp") + setupNamespaceController() + + expect: + // Namespace view should contain namespace in URL + namespaceView.url.contains("/${TEST_NAMESPACE}/") + // Non-namespace view should not + !nonNamespaceView.url.contains("/${TEST_NAMESPACE}/") + } + + void "test cache prevents repeated reflection for non-scaffold controllers"() { + given: + setupScaffoldController(String) // Non-scaffold controller + + when: "First call performs reflection" + def result1 = resolver.getScaffoldValue(mockControllerClass) + + then: + result1 == null + resolver.scaffoldValueCache.get(String) == ScaffoldingViewResolver.NULL_SCAFFOLD_VALUE + + when: "Second call uses cache" + def result2 = resolver.getScaffoldValue(mockControllerClass) + + then: + result2 == null + 0 * mockControllerClass.getPropertyValue(_) // No reflection on second call + } + + // Test domain class for annotation testing + static class TestDomain {} + + @Scaffold(domain = TestDomain) + static class TestScaffoldController {} + + @Scaffold(RestfulServiceController) + static class TestRestfulServiceScaffoldController {} +}