diff --git a/grails-doc/src/en/guide/scaffolding.adoc b/grails-doc/src/en/guide/scaffolding.adoc index a418f95c2e0..924ad758df1 100644 --- a/grails-doc/src/en/guide/scaffolding.adoc +++ b/grails-doc/src/en/guide/scaffolding.adoc @@ -36,13 +36,14 @@ dependencies { ==== Dynamic Scaffolding - -The simplest way to get started with scaffolding is to enable it by setting the `scaffold` property in the controller to a specific domain class: +The simplest way to get started with scaffolding is to use the `@Scaffold` annotation on a controller: [source,groovy] ---- +import grails.plugin.scaffolding.annotation.Scaffold + +@Scaffold(Book) class BookController { - static scaffold = Book // Or any other domain class such as "Author", "Publisher" } ---- @@ -58,26 +59,95 @@ 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. -Note: The old alternative of defining `scaffold` property: +If you prefer to keep your domain model in Java and https://hibernate.org/[mapped with Hibernate] you can still use scaffolding, simply import the domain class and use it in the `@Scaffold` annotation. + +===== Legacy Static Scaffold Property + +The older `static scaffold = Book` syntax is still supported but the `@Scaffold` annotation is preferred: + +[source,groovy] +---- +class BookController { + static scaffold = Book // Legacy syntax - @Scaffold annotation is preferred +} +---- + +NOTE: The `static scaffold = true` form is not supported in Grails 3.0 and above. + +===== Scaffolded Services + +In addition to controllers, you can also scaffold services using the `@Scaffold` annotation: + +[source,groovy] +---- +import grails.plugin.scaffolding.annotation.Scaffold + +@Scaffold(Book) +class BookService { +} +---- + +This generates a service that extends `grails.plugin.scaffolding.GormService` and provides standard CRUD operations: + +* `get(id)` - Retrieve by ID +* `list(Map args)` - List with pagination/sorting +* `count(Map args)` - Count entities +* `save(instance)` - Create or update +* `delete(id)` - Delete by ID + +===== Service-Backed Controllers + +For better separation of concerns, you can create controllers that delegate to a scaffolded service using `RestfulServiceController`: [source,groovy] ---- +import grails.plugin.scaffolding.annotation.Scaffold +import grails.plugin.scaffolding.RestfulServiceController + +@Scaffold(RestfulServiceController) class BookController { - static scaffold = true } ---- -is no longer supported above Grails 3.0. +This controller will automatically locate and use the corresponding `BookService` for all data operations. -If you prefer to keep your domain model in Java and https://hibernate.org/[mapped with Hibernate] you can still use scaffolding, simply import the domain class and set its name as the `scaffold` argument. +===== Extending Custom Classes -You can add new actions to a scaffolded controller, for example: +You can specify a custom class to extend for your scaffolded controllers or services: [source,groovy] ---- +import grails.plugin.scaffolding.annotation.Scaffold +import com.example.MyCustomService + +@Scaffold(MyCustomService) +class BookService { +} +---- + +===== Read-Only Mode + +You can create read-only scaffolded controllers or services: + +[source,groovy] +---- +import grails.plugin.scaffolding.annotation.Scaffold + +@Scaffold(domain = Book, readOnly = true) class BookController { +} +---- + +===== Adding Custom Actions - static scaffold = Book +You can add new actions to a scaffolded controller: + +[source,groovy] +---- +import grails.plugin.scaffolding.annotation.Scaffold + +@Scaffold(Book) +class BookController { def changeAuthor() { def b = Book.get(params.id) @@ -85,8 +155,8 @@ class BookController { b.save() // redirect to a scaffolded action - redirect(action:show) - } + redirect(action: 'show') + } } ---- @@ -94,9 +164,10 @@ You can also override the scaffolded actions: [source,groovy] ---- -class BookController { +import grails.plugin.scaffolding.annotation.Scaffold - static scaffold = Book +@Scaffold(Book) +class BookController { // overrides scaffolded action to return both authors and books def index() { @@ -108,7 +179,7 @@ class BookController { def show() { def book = Book.get(params.id) log.error("{}", book) - [bookInstance : book] + [bookInstance: book] } } ---- @@ -122,35 +193,96 @@ Also, the standard scaffold views expect model variables of the form `) +class BookController { +} +---- + +**Custom class to extend:** + +[source,groovy] +---- +import grails.plugin.scaffolding.annotation.Scaffold +import com.example.MyCustomService + +@Scaffold(MyCustomService) +class BookService { +} +---- + +**Read-only scaffolding:** + +[source,groovy] +---- +import grails.plugin.scaffolding.annotation.Scaffold + +@Scaffold(domain = Book, readOnly = true) +class BookController { +} +---- + +====== CLI Commands + +New CLI commands are available to generate scaffolded controllers and services: + +[source,bash] +---- +# Generate a scaffolded controller +grails create-scaffold-controller Book + +# Generate a scaffolded service +grails create-scaffold-service Book + +# Generate both service and controller +grails generate-scaffold-all Book +---- + +These commands support options like `--extends` to specify a custom class to extend, `--namespace` for controller namespacing, and `--service` to use `RestfulServiceController`. + +For full details, see the link:{guidePath}scaffolding.html[Scaffolding] documentation. diff --git a/grails-doc/src/en/ref/Plug-ins/scaffolding.adoc b/grails-doc/src/en/ref/Plug-ins/scaffolding.adoc index 5a752fed0e8..3729c692e41 100644 --- a/grails-doc/src/en/ref/Plug-ins/scaffolding.adoc +++ b/grails-doc/src/en/ref/Plug-ins/scaffolding.adoc @@ -31,18 +31,50 @@ The `scaffolding` plugin configures Grails' support for CRUD via link:{guidePath === Examples -An example of enabling "dynamic" scaffolding: +An example of enabling "dynamic" scaffolding using the `@Scaffold` annotation: +[source,groovy] +---- +import grails.plugin.scaffolding.annotation.Scaffold + +@Scaffold(Book) +class BookController { +} +---- + +You can also scaffold services: + +[source,groovy] +---- +import grails.plugin.scaffolding.annotation.Scaffold + +@Scaffold(Book) +class BookService { +} +---- + +For controllers that delegate to a service layer: [source,groovy] ---- +import grails.plugin.scaffolding.annotation.Scaffold +import grails.plugin.scaffolding.RestfulServiceController + +@Scaffold(RestfulServiceController) class BookController { - static scaffold = Book // static scaffold = true form is not supported in grails 3.0 and above } ---- +The legacy `static scaffold` syntax is still supported but the `@Scaffold` annotation is preferred: +[source,groovy] +---- +class BookController { + static scaffold = Book // Legacy syntax - @Scaffold annotation is preferred +} +---- +NOTE: The `static scaffold = true` form is not supported in Grails 3.0 and above. === Description diff --git a/grails-scaffolding/grails-app/commands/scaffolding/CreateScaffoldControllerCommand.groovy b/grails-scaffolding/grails-app/commands/scaffolding/CreateScaffoldControllerCommand.groovy index 16fe143ecfd..6679d5c5882 100644 --- a/grails-scaffolding/grails-app/commands/scaffolding/CreateScaffoldControllerCommand.groovy +++ b/grails-scaffolding/grails-app/commands/scaffolding/CreateScaffoldControllerCommand.groovy @@ -32,7 +32,6 @@ import org.grails.io.support.Resource * Creates a scaffolded controller. * Usage: ./gradlew runCommand "-Pargs=create-scaffold-controller [DOMAIN_CLASS_NAME]" * - * @author Puneet Behl * @since 5.0.0 */ @CompileStatic @@ -56,11 +55,28 @@ class CreateScaffoldControllerCommand implements GrailsApplicationCommand, Comma } boolean overwrite = isFlagPresent('force') final Model model = model(sourceClass) + + String namespace = flag('namespace') + boolean useService = isFlagPresent('service') + String extendsClass = flag('extends') + + Map templateModel = model.asMap() + templateModel.put('useService', useService) + templateModel.put('namespace', namespace ?: '') + templateModel.put('extendsClass', extendsClass ?: '') + templateModel.put('extendsClassName', extendsClass ? extendsClass.substring(extendsClass.lastIndexOf('.') + 1) : '') + + String destinationPath = "grails-app/controllers/${model.packagePath}" + + if (namespace) { + destinationPath = "${destinationPath}/${namespace}" + } + render(template: template('scaffolding/ScaffoldedController.groovy'), - destination: file("grails-app/controllers/${model.packagePath}/${model.convention('Controller')}.groovy"), - model: model, + destination: file("${destinationPath}/${model.convention('Controller')}.groovy"), + model: templateModel, overwrite: overwrite) - verbose('Scaffold controller created for class domain-class') + verbose('Scaffold controller created for domain class') return SUCCESS } diff --git a/grails-scaffolding/grails-app/commands/scaffolding/CreateScaffoldServiceCommand.groovy b/grails-scaffolding/grails-app/commands/scaffolding/CreateScaffoldServiceCommand.groovy new file mode 100644 index 00000000000..62ba9f82264 --- /dev/null +++ b/grails-scaffolding/grails-app/commands/scaffolding/CreateScaffoldServiceCommand.groovy @@ -0,0 +1,74 @@ +/* + * 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 scaffolding + +import groovy.transform.CompileStatic + +import grails.build.logging.ConsoleLogger +import grails.build.logging.GrailsConsole +import grails.codegen.model.Model +import grails.dev.commands.GrailsApplicationCommand +import grails.plugin.scaffolding.CommandLineHelper +import grails.plugin.scaffolding.SkipBootstrap +import org.grails.io.support.Resource + +/** + * Creates a scaffolded service. + * Usage: ./gradlew runCommand "-Pargs=create-scaffold-service [DOMAIN_CLASS_NAME]" + * + * @author Scott Murphy Heiberg + * @since 7.1.0 + */ +@CompileStatic +class CreateScaffoldServiceCommand implements GrailsApplicationCommand, CommandLineHelper, SkipBootstrap { + + String description = 'Creates a scaffolded service' + + @Delegate + ConsoleLogger consoleLogger = GrailsConsole.getInstance() + + boolean handle() { + final String domainClassName = args[0] + if (!domainClassName) { + error('No domain-class specified') + return FAILURE + } + final Resource sourceClass = source(domainClassName) + if (!sourceClass) { + error("No domain-class found for name: ${domainClassName}") + return FAILURE + } + boolean overwrite = isFlagPresent('force') + final Model model = model(sourceClass) + + String extendsClass = flag('extends') + + Map templateModel = model.asMap() + templateModel.put('extendsClass', extendsClass ?: '') + templateModel.put('extendsClassName', extendsClass ? extendsClass.substring(extendsClass.lastIndexOf('.') + 1) : '') + + render(template: template('scaffolding/ScaffoldedService.groovy'), + destination: file("grails-app/services/${model.packagePath}/${model.convention('Service')}.groovy"), + model: templateModel, + overwrite: overwrite) + verbose('Scaffold service created for domain class') + + return SUCCESS + } +} diff --git a/grails-scaffolding/grails-app/commands/scaffolding/GenerateScaffoldAllCommand.groovy b/grails-scaffolding/grails-app/commands/scaffolding/GenerateScaffoldAllCommand.groovy new file mode 100644 index 00000000000..53f2a6f5047 --- /dev/null +++ b/grails-scaffolding/grails-app/commands/scaffolding/GenerateScaffoldAllCommand.groovy @@ -0,0 +1,96 @@ +/* + * 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 scaffolding + +import groovy.transform.CompileStatic + +import grails.build.logging.ConsoleLogger +import grails.build.logging.GrailsConsole +import grails.codegen.model.Model +import grails.dev.commands.GrailsApplicationCommand +import grails.plugin.scaffolding.CommandLineHelper +import grails.plugin.scaffolding.SkipBootstrap +import org.grails.io.support.Resource + +/** + * Generates a scaffolded service and controller. + * Usage: ./gradlew runCommand "-Pargs=generate-scaffold-all [DOMAIN_CLASS_NAME]" + * + * @author Scott Murphy Heiberg + * @since 7.1.0 + */ +@CompileStatic +class GenerateScaffoldAllCommand implements GrailsApplicationCommand, CommandLineHelper, SkipBootstrap { + + String description = 'Generates a scaffolded service and controller' + + @Delegate + ConsoleLogger consoleLogger = GrailsConsole.getInstance() + + boolean handle() { + final String domainClassName = args[0] + if (!domainClassName) { + error('No domain-class specified') + return FAILURE + } + final Resource sourceClass = source(domainClassName) + if (!sourceClass) { + error("No domain-class found for name: ${domainClassName}") + return FAILURE + } + boolean overwrite = isFlagPresent('force') + final Model model = model(sourceClass) + + String namespace = flag('namespace') + String serviceExtends = flag('serviceExtends') + String controllerExtends = flag('controllerExtends') + + // Generate scaffolded service + Map serviceTemplateModel = model.asMap() + serviceTemplateModel.put('extendsClass', serviceExtends ?: '') + serviceTemplateModel.put('extendsClassName', serviceExtends ? serviceExtends.substring(serviceExtends.lastIndexOf('.') + 1) : '') + + render(template: template('scaffolding/ScaffoldedService.groovy'), + destination: file("grails-app/services/${model.packagePath}/${model.convention('Service')}.groovy"), + model: serviceTemplateModel, + overwrite: overwrite) + verbose('Scaffold service created for domain class') + + // Generate scaffolded controller with service reference + Map templateModel = model.asMap() + templateModel.put('useService', true) + templateModel.put('namespace', namespace ?: '') + templateModel.put('extendsClass', controllerExtends ?: '') + templateModel.put('extendsClassName', controllerExtends ? controllerExtends.substring(controllerExtends.lastIndexOf('.') + 1) : '') + + String controllerDestinationPath = "grails-app/controllers/${model.packagePath}" + + if (namespace) { + controllerDestinationPath = "${controllerDestinationPath}/${namespace}" + } + + render(template: template('scaffolding/ScaffoldedController.groovy'), + destination: file("${controllerDestinationPath}/${model.convention('Controller')}.groovy"), + model: templateModel, + overwrite: overwrite) + verbose('Scaffold controller created for domain class with service reference') + + return SUCCESS + } +} diff --git a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/CommandLineHelper.groovy b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/CommandLineHelper.groovy index 4280a31f8a0..9ace02ac0d0 100644 --- a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/CommandLineHelper.groovy +++ b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/CommandLineHelper.groovy @@ -39,4 +39,13 @@ trait CommandLineHelper { } } + String flag(String name) { + final CommandLine commandLine = executionContext.commandLine + if (commandLine.hasOption(name)) { + return commandLine.optionValue(name)?.toString() + } else { + return commandLine?.undeclaredOptions?.get(name)?.toString() + } + } + } diff --git a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/GormService.groovy b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/GormService.groovy index 7ed8dbec29a..d4f34f68fb4 100644 --- a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/GormService.groovy +++ b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/GormService.groovy @@ -33,7 +33,7 @@ import org.grails.datastore.gorm.GormEntityApi @Artefact('Service') @ReadOnly @CompileStatic -class GormService> { +class GormService> implements ScaffoldService { @Lazy GormAllOperations gormStaticApi = GormEnhancer.findStaticApi(resource) as GormAllOperations @@ -49,18 +49,22 @@ class GormService> { resourceName = GrailsNameUtils.getPropertyName(resource) } + @Override T get(Serializable id) { gormStaticApi.get(id) } + @Override List list(Map args) { gormStaticApi.list(args) } + @Override Long count(Map args) { gormStaticApi.count() } + @Override @Transactional void delete(Serializable id) { if (readOnly) { @@ -69,6 +73,7 @@ class GormService> { ((GormEntityApi) get(id)).delete(flush: true) } + @Override @Transactional T save(T instance) { if (readOnly) { diff --git a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/RestfulServiceController.groovy b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/RestfulServiceController.groovy index da626085074..d33978447fa 100644 --- a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/RestfulServiceController.groovy +++ b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/RestfulServiceController.groovy @@ -25,6 +25,33 @@ import grails.gorm.transactions.ReadOnly import grails.rest.RestfulController import org.grails.datastore.gorm.GormEntity +/** + * Restful controller that delegates all operations to a scaffold service. + * + *

This controller is datastore-agnostic and works with any {@link ScaffoldService} + * implementation (GORM, JPA, JDBC, REST, custom, etc.). It uses the service interface + * rather than concrete implementations, allowing different backends to be swapped.

+ * + *

Read-only protection is handled by the service layer - services with {@code readOnly=true} + * will silently ignore mutation operations (no-op behavior).

+ * + *

Example Usage

+ *
{@code
+ * @Scaffold(RestfulServiceController)
+ * class CarController {}
+ * }
+ * + *

The controller will automatically locate and inject the corresponding service + * (e.g., {@code CarService}) using {@link DomainServiceLocator}.

+ * + * @param The domain/entity type + * + * @author Scott Murphy Heiberg + * @since 7.1.0 + * + * @see ScaffoldService + * @see DomainServiceLocator + */ @Artefact('Controller') @ReadOnly @CompileStatic @@ -34,7 +61,12 @@ class RestfulServiceController> extends RestfulControlle super(resource, readOnly) } - protected GormService getService() { + /** + * Get the scaffold service for this controller. + * + * @return The scaffold service (resolved via {@link DomainServiceLocator}) + */ + protected ScaffoldService getService() { DomainServiceLocator.resolve(resource) } diff --git a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/ScaffoldService.groovy b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/ScaffoldService.groovy new file mode 100644 index 00000000000..1cbaf3c8fa8 --- /dev/null +++ b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/ScaffoldService.groovy @@ -0,0 +1,114 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed 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 + +/** + * Scaffold service interface providing CRUD operations for domain entities. + * + *

This interface defines the complete contract for scaffold services. + * It is completely datastore-agnostic and can be implemented for any persistence backend: + * GORM (Hibernate, MongoDB, Neo4j), JPA, JDBC, REST clients, or custom implementations.

+ * + *

Use Cases

+ *
    + *
  • Standard CRUD services with full create, read, update, delete capabilities
  • + *
  • Domain services that need both query and mutation operations
  • + *
  • Services wrapping any data access technology
  • + *
  • Read-only services using the {@code readOnly} flag to throw exceptions on mutations
  • + *
+ * + *

Usage with @Scaffold Annotation

+ *
{@code
+ * // Default GORM implementation
+ * @Scaffold(Car)
+ * class CarService {}
+ * // AST transformation generates implementation automatically
+ *
+ * // Read-only service (save/delete are no-ops)
+ * @Scaffold(domain = Report, readOnly = true)
+ * class ReportService {}
+ * }
+ * + * @param The entity/domain type + * @param The identifier type (Long, String, UUID, composite keys, etc.) + * + * @author Scott Murphy Heiberg + * @since 7.1.0 + */ +interface ScaffoldService { + + /** + * Retrieve a single entity by its identifier. + * + * @param id The entity identifier + * @return The entity instance, or null if not found + */ + T get(ID id) + + /** + * List entities with optional pagination, sorting, and filtering. + * + *

The args map typically contains:

+ *
    + *
  • max - Maximum number of results to return
  • + *
  • offset - Starting offset for pagination
  • + *
  • sort - Field name to sort by
  • + *
  • order - Sort order ('asc' or 'desc')
  • + *
  • Additional domain-specific filter parameters
  • + *
+ * + * @param args Map containing query parameters for pagination, sorting, and filtering + * @return List of entities matching the criteria + */ + List list(Map args) + + /** + * Count the total number of entities matching the given criteria. + * + * @param args Map containing filtering criteria + * @return Total count of matching entities + */ + Long count(Map args) + + /** + * Save (create or update) an entity. + * + *

Implementations should:

+ *
    + *
  • Create a new entity if it doesn't have an ID
  • + *
  • Update an existing entity if it has an ID
  • + *
  • Perform validation if applicable
  • + *
  • Flush changes to the datastore
  • + *
+ * + * @param instance The entity instance to save + * @return The saved entity (with generated ID if new), or the original instance if readOnly mode + * @throws ValidationException if validation fails + */ + T save(T instance) + + /** + * Delete an entity by its identifier. + * + *

Implementations should handle the case where the entity doesn't exist gracefully + * (either silently succeed or throw a specific exception).

+ * + *

In readOnly mode, this operation is silently ignored (no-op).

+ * + * @param id The identifier of the entity to delete + */ + void delete(ID id) +} diff --git a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/annotation/Scaffold.java b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/annotation/Scaffold.java index 1779cb14101..2c2cdd75246 100644 --- a/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/annotation/Scaffold.java +++ b/grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/annotation/Scaffold.java @@ -24,10 +24,103 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Scaffolding annotation for Controllers and Services. + * + *

At compile-time, this annotation: + *

    + *
  • Detects whether applied to a Service or Controller
  • + *
  • Sets appropriate parent class (with sensible defaults)
  • + *
  • Sets the domain/entity class
  • + *
  • Generates required constructor
  • + *
+ * + *

Usage Examples

+ * + *

Services:

+ *
{@code
+ * // Simple: domain class only (uses default GormService)
+ * @Scaffold(Car.class)
+ * class CarService {}  // → extends GormService
+ *
+ * // Explicit: specify both class to extend and domain
+ * @Scaffold(value = GormService.class, domain = Car.class)
+ * class CarService {}  // → extends GormService
+ *
+ * // Alternative: domain parameter
+ * @Scaffold(domain = Car.class)
+ * class CarService {}  // → extends GormService
+ * }
+ * + *

Controllers:

+ *
{@code
+ * // Simple: domain class only (uses default RestfulController)
+ * @Scaffold(Car.class)
+ * class CarController {}  // → extends RestfulController
+ *
+ * // Explicit: specify both class to extend and domain
+ * @Scaffold(value = RestfulController.class, domain = Car.class)
+ * class CarController {}  // → extends RestfulController
+ * }
+ * + * @author Scott Murphy Heiberg + * @since 5.1.0 + */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface Scaffold { + + /** + * Class to extend OR domain class (context-dependent). + * + *

Interpretation:

+ *
    + *
  • If value has generics (e.g., {@code GormService}): + *
      + *
    • Extends {@code GormService}
    • + *
    • Domain class = {@code Car} (extracted from generic)
    • + *
    + *
  • + *
  • If value is a scaffold class (e.g., {@code GormService}): + *
      + *
    • Extends value
    • + *
    • Domain class = from {@link #domain()} parameter
    • + *
    + *
  • + *
  • Otherwise (e.g., {@code Car}): + *
      + *
    • Domain class = value
    • + *
    • Extends default for artefact type
    • + *
    + *
  • + *
+ * + * @return the class to extend or domain class + */ Class value() default Void.class; + + /** + * Domain/entity class (alternative to value). + * More explicit when also specifying the class to extend. + * + *

Examples:

+ *
{@code
+     * @Scaffold(domain = Car.class)  // Uses default
+     * @Scaffold(value = JpaScaffoldService.class, domain = Car.class)
+     * }
+ * + * @return the domain class + */ Class domain() default Void.class; + + /** + * Whether this service/controller is read-only. + * Passed to constructor of the extended class. + * + *

For services: mutations throw {@code ReadOnlyServiceException} + *

For controllers: mutation endpoints may return 405 Method Not Allowed + * + * @return true if read-only, false otherwise + */ boolean readOnly() default false; } diff --git a/grails-scaffolding/src/main/groovy/org/grails/compiler/scaffolding/ScaffoldingControllerInjector.groovy b/grails-scaffolding/src/main/groovy/org/grails/compiler/scaffolding/ScaffoldingControllerInjector.groovy index 0d7c0fbe217..898941c00f5 100644 --- a/grails-scaffolding/src/main/groovy/org/grails/compiler/scaffolding/ScaffoldingControllerInjector.groovy +++ b/grails-scaffolding/src/main/groovy/org/grails/compiler/scaffolding/ScaffoldingControllerInjector.groovy @@ -37,10 +37,13 @@ import org.grails.core.artefact.ControllerArtefactHandler import org.grails.plugins.web.rest.transform.ResourceTransform /** - * Transformation that turns a controller into a scaffolding controller at compile time if 'static scaffold = Foo' - * is specified + * Transformation that turns a controller into a scaffolding controller at compile time if '@Scaffold' is specified. + * + *

The legacy 'static scaffold = Foo' syntax is deprecated and will be removed in a future version. + * Use the {@code @Scaffold} annotation instead.

* * @author Graeme Rocher + * @author Scott Murphy Heiberg * @since 3.1 */ @AstTransformer @@ -68,18 +71,47 @@ class ScaffoldingControllerInjector implements GrailsArtefactClassInjector { def expression = propertyNode?.getInitialExpression() if (expression instanceof ClassExpression || annotationNode) { - ClassNode controllerClassNode = annotationNode?.getMember('value')?.type - ClassNode superClassNode = ClassHelper.make(controllerClassNode?.getTypeClass() ?: RestfulController).getPlainNodeReference() + if (!annotationNode) { + ClassNode domainClassNode = ((ClassExpression) expression).getType() + String domainClassName = domainClassNode.getNameWithoutPackage() + String controllerClassName = classNode.getNameWithoutPackage() + GrailsASTUtils.warning(source, propertyNode, """ + The 'static scaffold = ${domainClassName}' syntax is deprecated and will be removed in a future version of Grails. + Please use the @Scaffold annotation instead: + + import grails.plugin.scaffolding.annotation.Scaffold + + @Scaffold(${domainClassName}) + class ${controllerClassName} { + } + """.stripIndent()) + } + ClassNode valueClassNode = annotationNode?.getMember('value')?.type + ClassNode superClassNode = ClassHelper.make(RestfulController).getPlainNodeReference() ClassNode currentSuperClass = classNode.getSuperClass() if (currentSuperClass.equals(GrailsASTUtils.OBJECT_CLASS_NODE)) { def domainClass = expression ? ((ClassExpression) expression).getType() : null if (!domainClass) { domainClass = annotationNode.getMember('domain')?.type if (!domainClass) { - domainClass = extractGenericDomainClass(controllerClassNode) - if (domainClass) { - // set the domain value on the annotation so that ScaffoldingViewResolver can identify the domain object. + def genericsTypes = valueClassNode?.genericsTypes + boolean hasGenerics = genericsTypes != null && genericsTypes.length > 0 + + if (hasGenerics) { + // CASE 1: @Scaffold(RestfulController) + domainClass = extractGenericDomainClass(valueClassNode) + if (domainClass) { + // set the domain value on the annotation so that ScaffoldingViewResolver can identify the domain object. + annotationNode.addMember('domain', new ClassExpression(domainClass)) + } + superClassNode = valueClassNode.getPlainNodeReference() + } else if (valueClassNode) { + // CASE 2: @Scaffold(Car) + domainClass = valueClassNode + // Set domain on annotation for view resolution annotationNode.addMember('domain', new ClassExpression(domainClass)) + // Set value to RestfulController so it's available at runtime + annotationNode.setMember('value', new ClassExpression(superClassNode)) } } if (!domainClass) { diff --git a/grails-scaffolding/src/main/groovy/org/grails/compiler/scaffolding/ScaffoldingServiceInjector.groovy b/grails-scaffolding/src/main/groovy/org/grails/compiler/scaffolding/ScaffoldingServiceInjector.groovy index e346de1d086..5238469dab1 100644 --- a/grails-scaffolding/src/main/groovy/org/grails/compiler/scaffolding/ScaffoldingServiceInjector.groovy +++ b/grails-scaffolding/src/main/groovy/org/grails/compiler/scaffolding/ScaffoldingServiceInjector.groovy @@ -24,6 +24,7 @@ import java.util.regex.Pattern import groovy.transform.CompileStatic import org.codehaus.groovy.ast.ClassHelper import org.codehaus.groovy.ast.ClassNode +import org.codehaus.groovy.ast.expr.ClassExpression import org.codehaus.groovy.ast.expr.ConstantExpression import org.codehaus.groovy.classgen.GeneratorContext import org.codehaus.groovy.control.SourceUnit @@ -38,7 +39,7 @@ import org.grails.io.support.GrailsResourceUtils import org.grails.plugins.web.rest.transform.ResourceTransform /** - * Transformation that turns a service into a scaffolding service at compile time if '@ScaffoldService' + * Transformation that turns a service into a scaffolding service at compile time if '@Scaffold' * is specified * * @author Scott Murphy Heiberg @@ -66,13 +67,26 @@ class ScaffoldingServiceInjector implements GrailsArtefactClassInjector { void performInjectionOnAnnotatedClass(SourceUnit source, ClassNode classNode) { def annotationNode = classNode.getAnnotations(ClassHelper.make(Scaffold)).find() if (annotationNode) { - ClassNode serviceClassNode = annotationNode?.getMember('value')?.type - ClassNode superClassNode = ClassHelper.make(serviceClassNode?.getTypeClass() ?: GormService).getPlainNodeReference() + ClassNode valueClassNode = annotationNode?.getMember('value')?.type + ClassNode superClassNode = ClassHelper.make(GormService).getPlainNodeReference() ClassNode currentSuperClass = classNode.getSuperClass() if (currentSuperClass.equals(GrailsASTUtils.OBJECT_CLASS_NODE)) { def domainClass = annotationNode.getMember('domain')?.type if (!domainClass) { - domainClass = ScaffoldingControllerInjector.extractGenericDomainClass(serviceClassNode) + def genericsTypes = valueClassNode?.genericsTypes + boolean hasGenerics = genericsTypes != null && genericsTypes.length > 0 + + if (hasGenerics) { + domainClass = ScaffoldingControllerInjector.extractGenericDomainClass(valueClassNode) + if (domainClass) { + annotationNode.addMember('domain', new ClassExpression(domainClass)) + } + superClassNode = valueClassNode.getPlainNodeReference() + } else if (valueClassNode) { + domainClass = valueClassNode + annotationNode.addMember('domain', new ClassExpression(domainClass)) + annotationNode.setMember('value', new ClassExpression(superClassNode)) + } } if (!domainClass) { GrailsASTUtils.error(source, classNode, "Scaffolded service (${classNode.name}) with @Scaffold does not have domain class set.", true) diff --git a/grails-scaffolding/src/main/scripts/CreateScaffoldController.groovy b/grails-scaffolding/src/main/scripts/CreateScaffoldController.groovy index b87674dfd72..68ee26962b3 100644 --- a/grails-scaffolding/src/main/scripts/CreateScaffoldController.groovy +++ b/grails-scaffolding/src/main/scripts/CreateScaffoldController.groovy @@ -18,18 +18,36 @@ */ description("Creates a scaffolded controller") { - usage 'create-controller [controller name]' + usage 'create-scaffold-controller [controller name]' completer org.grails.cli.interactive.completers.DomainClassCompleter argument name:'Controller Name', description:"The name of controller", required:true flag name:'force', description:"Whether to overwrite existing files" + flag name:'namespace', description:"The namespace for the controller" + flag name:'service', description:"Use grails.plugin.scaffolding.RestfulServiceController instead of grails.rest.RestfulController" + flag name:'extends', description:"The class to extend (default: grails.rest.RestfulController)" } -def model = model(args[0]) +def modelInstance = model(args[0]) def overwrite = flag('force') ? true : false +def namespace = flag('namespace') +def useService = flag('service') ? true : false +def extendsClass = flag('extends') + +def templateModel = modelInstance.asMap() +templateModel.put('useService', useService) +templateModel.put('namespace', namespace ?: '') +templateModel.put('extendsClass', extendsClass ?: '') +templateModel.put('extendsClassName', extendsClass ? extendsClass.substring(extendsClass.lastIndexOf('.') + 1) : '') + +def destinationPath = "grails-app/controllers/${modelInstance.packagePath}" + +if (namespace) { + destinationPath = "${destinationPath}/${namespace}" +} render template: template('scaffolding/ScaffoldedController.groovy'), - destination: file("grails-app/controllers/${model.packagePath}/${model.convention("Controller")}.groovy"), - model: model, + destination: file("${destinationPath}/${modelInstance.convention("Controller")}.groovy"), + model: templateModel, overwrite: overwrite return true diff --git a/grails-scaffolding/src/main/scripts/CreateScaffoldService.groovy b/grails-scaffolding/src/main/scripts/CreateScaffoldService.groovy new file mode 100644 index 00000000000..8501206c662 --- /dev/null +++ b/grails-scaffolding/src/main/scripts/CreateScaffoldService.groovy @@ -0,0 +1,41 @@ +/* + * 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. + */ + +description("Creates a scaffolded service") { + usage 'create-scaffold-service [service name]' + completer org.grails.cli.interactive.completers.DomainClassCompleter + argument name:'Service Name', description:"The name of service", required:true + flag name:'force', description:"Whether to overwrite existing files" + flag name:'extends', description:"The class to extend (default: grails.plugin.scaffolding.GormService)" + } + +def modelInstance = model(args[0]) +def overwrite = flag('force') ? true : false +def extendsClass = flag('extends') + +def templateModel = modelInstance.asMap() +templateModel.put('extendsClass', extendsClass ?: '') +templateModel.put('extendsClassName', extendsClass ? extendsClass.substring(extendsClass.lastIndexOf('.') + 1) : '') + +render template: template('scaffolding/ScaffoldedService.groovy'), + destination: file("grails-app/services/${modelInstance.packagePath}/${modelInstance.convention("Service")}.groovy"), + model: templateModel, + overwrite: overwrite + +return true diff --git a/grails-scaffolding/src/main/scripts/GenerateScaffoldAll.groovy b/grails-scaffolding/src/main/scripts/GenerateScaffoldAll.groovy new file mode 100644 index 00000000000..93dba3eaa68 --- /dev/null +++ b/grails-scaffolding/src/main/scripts/GenerateScaffoldAll.groovy @@ -0,0 +1,64 @@ +/* + * 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. + */ + +description("Generates a scaffolded service and controller") { + usage 'generate-scaffold-all [domain class name]' + completer org.grails.cli.interactive.completers.DomainClassCompleter + argument name:'Domain Class Name', description:"The name of domain class", required:true + flag name:'force', description:"Whether to overwrite existing files" + flag name:'namespace', description:"The namespace for the controller" + flag name:'serviceExtends', description:"The class to extend for the service (default: grails.plugin.scaffolding.GormService)" + flag name:'controllerExtends', description:"The class to extend for the controller (default: grails.plugin.scaffolding.RestfulServiceController)" + } + +def modelInstance = model(args[0]) +def overwrite = flag('force') ? true : false +def namespace = flag('namespace') +def serviceExtends = flag('serviceExtends') +def controllerExtends = flag('controllerExtends') + +// Generate scaffolded service +def serviceTemplateModel = modelInstance.asMap() +serviceTemplateModel.put('extendsClass', serviceExtends ?: '') +serviceTemplateModel.put('extendsClassName', serviceExtends ? serviceExtends.substring(serviceExtends.lastIndexOf('.') + 1) : '') + +render template: template('scaffolding/ScaffoldedService.groovy'), + destination: file("grails-app/services/${modelInstance.packagePath}/${modelInstance.convention("Service")}.groovy"), + model: serviceTemplateModel, + overwrite: overwrite + +// Generate scaffolded controller with service reference +def controllerTemplateModel = modelInstance.asMap() +controllerTemplateModel.put('useService', true) +controllerTemplateModel.put('namespace', namespace ?: '') +controllerTemplateModel.put('extendsClass', controllerExtends ?: '') +controllerTemplateModel.put('extendsClassName', controllerExtends ? controllerExtends.substring(controllerExtends.lastIndexOf('.') + 1) : '') + +def controllerDestinationPath = "grails-app/controllers/${modelInstance.packagePath}" + +if (namespace) { + controllerDestinationPath = "${controllerDestinationPath}/${namespace}" +} + +render template: template('scaffolding/ScaffoldedController.groovy'), + destination: file("${controllerDestinationPath}/${modelInstance.convention("Controller")}.groovy"), + model: controllerTemplateModel, + overwrite: overwrite + +return true diff --git a/grails-scaffolding/src/main/templates/scaffolding/ScaffoldedController.groovy b/grails-scaffolding/src/main/templates/scaffolding/ScaffoldedController.groovy index 99eb28bb236..008d4fd3c61 100644 --- a/grails-scaffolding/src/main/templates/scaffolding/ScaffoldedController.groovy +++ b/grails-scaffolding/src/main/templates/scaffolding/ScaffoldedController.groovy @@ -1,7 +1,12 @@ -<%=packageName ? "package ${packageName}" : ''%> +<% if (namespace) { %><%=packageName ? "package ${packageName}.${namespace}" : "package ${namespace}"%> -class ${className}Controller { +import ${packageName}.${className}<% } else { %><%=packageName ? "package ${packageName}" : ''%><% } %> - static scaffold = ${className} +import grails.plugin.scaffolding.annotation.Scaffold<% if (extendsClass) { %> +import ${extendsClass}<% } else if (useService) { %> +import grails.plugin.scaffolding.RestfulServiceController<% } %> -} +<% if (extendsClass) { %>@Scaffold(${extendsClassName}<${className}>)<% } else if (useService) { %>@Scaffold(RestfulServiceController<${className}>)<% } else { %>@Scaffold(${className})<% } %> +class ${className}Controller {<% if (namespace) { %> + static namespace = '${namespace}' +<% } %>} diff --git a/grails-scaffolding/src/main/templates/scaffolding/ScaffoldedService.groovy b/grails-scaffolding/src/main/templates/scaffolding/ScaffoldedService.groovy new file mode 100644 index 00000000000..c60d728a1fc --- /dev/null +++ b/grails-scaffolding/src/main/templates/scaffolding/ScaffoldedService.groovy @@ -0,0 +1,8 @@ +<%=packageName ? "package ${packageName}" : ''%> + +import grails.plugin.scaffolding.annotation.Scaffold<% if (extendsClass) { %> +import ${extendsClass}<% } %> + +<% if (extendsClass) { %>@Scaffold(${extendsClassName}<${className}>)<% } else { %>@Scaffold(${className})<% } %> +class ${className}Service { +}