This is an automated email from the ASF dual-hosted git repository. jamesfredley pushed a commit to branch feat/data-service-datasource-inheritance in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 19e84793677b55a82a4a1a553eda8f93c2734e69 Author: James Fredley <[email protected]> AuthorDate: Sat Feb 21 18:15:28 2026 -0500 feat: auto-inherit datasource from domain class in @Service data services @Service(DomainClass) now automatically inherits the datasource from the domain class's mapping block, so developers no longer need to specify @Transactional(connection = '...') when the domain already declares its datasource. Explicit @Transactional(connection) on the service still takes precedence. Two-layer implementation: - AST (ServiceTransformation): parses the domain's static mapping closure for datasource/connection calls and propagates to the generated service impl via @Transactional(connection) - Runtime (DatastoreServiceMethodInvokingFactoryBean): resolves the domain's datasource from the mapping context and sets the correct datastore on the service instance Assisted-by: Claude Code <[email protected]> --- .../DataServiceDatasourceInheritanceSpec.groovy | 215 ++++++++++++++++++++ .../transform/ServiceTransformation.groovy | 101 ++++++++++ .../ConnectionRoutingServiceTransformSpec.groovy | 216 +++++++++++++++++++++ ...atastoreServiceMethodInvokingFactoryBean.groovy | 47 ++++- .../guide/conf/dataSource/multipleDatasources.adoc | 44 +++++ .../example/InheritedProductService.groovy | 38 ++++ .../DataServiceDatasourceInheritanceSpec.groovy | 121 ++++++++++++ 7 files changed, 781 insertions(+), 1 deletion(-) diff --git a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/connections/DataServiceDatasourceInheritanceSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/connections/DataServiceDatasourceInheritanceSpec.groovy new file mode 100644 index 0000000000..6a9eb267dc --- /dev/null +++ b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/connections/DataServiceDatasourceInheritanceSpec.groovy @@ -0,0 +1,215 @@ +/* + * 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 org.grails.orm.hibernate.connections + +import org.hibernate.dialect.H2Dialect +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification + +import grails.gorm.annotation.Entity +import grails.gorm.services.Service +import grails.gorm.transactions.Transactional +import org.grails.datastore.gorm.GormEnhancer +import org.grails.datastore.mapping.core.DatastoreUtils +import org.grails.orm.hibernate.HibernateDatastore + +class DataServiceDatasourceInheritanceSpec extends Specification { + + @Shared Map config = [ + 'dataSource.url':"jdbc:h2:mem:grailsDB;LOCK_TIMEOUT=10000", + 'dataSource.dbCreate': 'create-drop', + 'dataSource.dialect': H2Dialect.name, + 'dataSource.formatSql': 'true', + 'hibernate.flush.mode': 'COMMIT', + 'hibernate.cache.queries': 'true', + 'hibernate.hbm2ddl.auto': 'create-drop', + 'dataSources.warehouse':[url:"jdbc:h2:mem:warehouseDB;LOCK_TIMEOUT=10000"], + ] + + @Shared @AutoCleanup HibernateDatastore datastore = new HibernateDatastore( + DatastoreUtils.createPropertyResolver(config), Inventory + ) + + @Shared InventoryService inventoryService + @Shared InventoryDataService inventoryDataService + @Shared ExplicitInventoryService explicitInventoryService + + void setupSpec() { + inventoryService = datastore + .getDatastoreForConnection('warehouse') + .getService(InventoryService) + inventoryDataService = datastore + .getDatastoreForConnection('warehouse') + .getService(InventoryDataService) + explicitInventoryService = datastore + .getDatastoreForConnection('warehouse') + .getService(ExplicitInventoryService) + } + + void setup() { + def api = GormEnhancer.findStaticApi(Inventory, 'warehouse') + api.withNewTransaction { + api.executeUpdate('delete from Inventory') + } + } + + void "abstract service without @Transactional(connection) inherits from domain"() { + when: "saving through a service that has no @Transactional(connection)" + def saved = inventoryService.save(new Inventory(sku: 'ABC-001', quantity: 50)) + + then: "the entity is persisted on the warehouse datasource" + saved != null + saved.id != null + saved.sku == 'ABC-001' + + and: "it exists on the warehouse datasource" + GormEnhancer.findStaticApi(Inventory, 'warehouse').withNewTransaction { + GormEnhancer.findStaticApi(Inventory, 'warehouse').count() + } == 1 + } + + void "get by ID routes to inherited datasource"() { + given: "an inventory item saved on warehouse" + def saved = inventoryService.save(new Inventory(sku: 'GET-001', quantity: 10)) + + when: "retrieving by ID" + def found = inventoryService.get(saved.id) + + then: "the correct entity is returned" + found != null + found.id == saved.id + found.sku == 'GET-001' + } + + void "delete routes to inherited datasource"() { + given: "an inventory item saved on warehouse" + def saved = inventoryService.save(new Inventory(sku: 'DEL-001', quantity: 5)) + + when: "deleting by ID" + def deleted = inventoryService.delete(saved.id) + + then: "the entity is deleted" + deleted != null + deleted.sku == 'DEL-001' + inventoryService.get(saved.id) == null + } + + void "count routes to inherited datasource"() { + given: "items saved on warehouse" + inventoryService.save(new Inventory(sku: 'CNT-001', quantity: 1)) + inventoryService.save(new Inventory(sku: 'CNT-002', quantity: 2)) + + expect: "count returns 2" + inventoryService.count() == 2 + } + + void "findBySku routes to inherited datasource"() { + given: "items saved on warehouse" + inventoryService.save(new Inventory(sku: 'FIND-001', quantity: 100)) + + when: "finding by sku" + def found = inventoryService.findBySku('FIND-001') + + then: "the correct entity is returned" + found != null + found.sku == 'FIND-001' + found.quantity == 100 + } + + void "interface service inherits datasource from domain"() { + when: "saving through an interface service with no @Transactional(connection)" + def saved = inventoryDataService.save(new Inventory(sku: 'IFACE-001', quantity: 25)) + + then: "the entity is persisted on warehouse" + saved != null + saved.id != null + + and: "retrievable through the same service" + inventoryDataService.get(saved.id) != null + } + + void "explicit @Transactional(connection) wins over domain datasource"() { + when: "saving through a service with explicit @Transactional(connection='warehouse')" + def saved = explicitInventoryService.save(new Inventory(sku: 'EXPL-001', quantity: 75)) + + then: "the entity is persisted correctly" + saved != null + saved.id != null + saved.sku == 'EXPL-001' + } + + void "abstract and interface services share the same inherited datasource"() { + given: "an item saved through the abstract service" + def saved = inventoryService.save(new Inventory(sku: 'CROSS-001', quantity: 42)) + + expect: "the interface service can find it" + inventoryDataService.findBySku('CROSS-001') != null + inventoryDataService.findBySku('CROSS-001').id == saved.id + } + +} + +@Entity +class Inventory { + Long id + Long version + String sku + Integer quantity + + static mapping = { + datasource 'warehouse' + } + static constraints = { + sku blank: false + } +} + +@Service(Inventory) +abstract class InventoryService { + + abstract Inventory get(Serializable id) + + abstract Inventory save(Inventory item) + + abstract Inventory delete(Serializable id) + + abstract Number count() + + abstract Inventory findBySku(String sku) +} + +@Service(Inventory) +interface InventoryDataService { + + Inventory get(Serializable id) + + Inventory save(Inventory item) + + Inventory delete(Serializable id) + + Inventory findBySku(String sku) +} + +@Service(Inventory) +@Transactional(connection = 'warehouse') +abstract class ExplicitInventoryService { + + abstract Inventory save(Inventory item) +} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy index 30811c5e30..d5d7c37812 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy @@ -31,12 +31,17 @@ import org.codehaus.groovy.ast.FieldNode import org.codehaus.groovy.ast.MethodNode import org.codehaus.groovy.ast.Parameter import org.codehaus.groovy.ast.PropertyNode +import org.codehaus.groovy.ast.expr.ArgumentListExpression import org.codehaus.groovy.ast.expr.ClassExpression +import org.codehaus.groovy.ast.expr.ClosureExpression import org.codehaus.groovy.ast.expr.ConstantExpression import org.codehaus.groovy.ast.expr.Expression import org.codehaus.groovy.ast.expr.ListExpression +import org.codehaus.groovy.ast.expr.MethodCallExpression import org.codehaus.groovy.ast.expr.VariableExpression import org.codehaus.groovy.ast.stmt.BlockStatement +import org.codehaus.groovy.ast.stmt.ExpressionStatement +import org.codehaus.groovy.ast.stmt.Statement import org.codehaus.groovy.ast.tools.GenericsUtils import org.codehaus.groovy.control.CompilePhase import org.codehaus.groovy.control.SourceUnit @@ -51,6 +56,7 @@ import groovyjarjarasm.asm.Opcodes import grails.gorm.services.Service import grails.gorm.transactions.NotTransactional +import grails.gorm.transactions.Transactional import org.apache.grails.common.compiler.GroovyTransformOrder import org.grails.datastore.gorm.services.Implemented import org.grails.datastore.gorm.services.ServiceEnhancer @@ -86,6 +92,7 @@ import org.grails.datastore.gorm.transactions.transform.TransactionalTransform import org.grails.datastore.gorm.transform.AbstractTraitApplyingGormASTTransformation import org.grails.datastore.gorm.validation.jakarta.services.implementers.MethodValidationImplementer import org.grails.datastore.mapping.core.Datastore +import org.grails.datastore.mapping.core.connections.ConnectionSource import org.grails.datastore.mapping.core.order.OrderedComparator import static org.apache.groovy.ast.tools.AnnotatedNodeUtils.markAsGenerated @@ -263,6 +270,19 @@ class ServiceTransformation extends AbstractTraitApplyingGormASTTransformation i // weave with generic argument weaveTraitWithGenerics(impl, getTraitClass(), targetDomainClass) + // Auto-inherit datasource from domain class's mapping if the service + // does not already have an explicit @Transactional(connection=...) + if (targetDomainClass != ClassHelper.OBJECT_TYPE) { + String domainConnection = resolveDomainDatasource(targetDomainClass) + if (domainConnection != null + && !ConnectionSource.DEFAULT.equals(domainConnection) + && !ConnectionSource.ALL.equals(domainConnection)) { + if (!hasExplicitConnectionAnnotation(classNode)) { + applyDomainConnectionToService(classNode, impl, domainConnection) + } + } + } + List<MethodNode> abstractMethods = findAllUnimplementedAbstractMethods(classNode) abstractMethods.sort(true) { it.name } // ensure a consistent order of processing methods @@ -452,6 +472,87 @@ class ServiceTransformation extends AbstractTraitApplyingGormASTTransformation i } } + private static String resolveDomainDatasource(ClassNode domainClass) { + FieldNode mappingField = domainClass.getDeclaredField('mapping') + if (mappingField == null) { + PropertyNode mappingProp = domainClass.getProperty('mapping') + if (mappingProp != null) { + mappingField = mappingProp.getField() + } + } + if (mappingField != null) { + return extractDatasourceFromExpression(mappingField.getInitialValueExpression()) + } + return null + } + + private static String extractDatasourceFromExpression(Expression expr) { + if (!(expr instanceof ClosureExpression)) { + return null + } + Statement code = ((ClosureExpression) expr).getCode() + if (!(code instanceof BlockStatement)) { + return null + } + for (Statement stmt : ((BlockStatement) code).getStatements()) { + if (!(stmt instanceof ExpressionStatement)) { + continue + } + Expression stmtExpr = ((ExpressionStatement) stmt).getExpression() + if (!(stmtExpr instanceof MethodCallExpression)) { + continue + } + MethodCallExpression call = (MethodCallExpression) stmtExpr + String methodName = call.getMethodAsString() + if ('datasource' == methodName || 'connection' == methodName || 'connections' == methodName) { + Expression args = call.getArguments() + if (args instanceof ArgumentListExpression) { + List<Expression> argExprs = ((ArgumentListExpression) args).getExpressions() + if (!argExprs.isEmpty() && argExprs[0] instanceof ConstantExpression) { + return ((ConstantExpression) argExprs[0]).getValue()?.toString() + } + } + } + } + return null + } + + private static boolean hasExplicitConnectionAnnotation(ClassNode classNode) { + AnnotationNode ann = findAnnotation(classNode, Transactional) + if (ann != null) { + Expression connection = ann.getMember('connection') + if (connection instanceof ConstantExpression) { + String value = ((ConstantExpression) connection).getValue()?.toString() + return value != null && !value.isEmpty() + } + } + return false + } + + private static void applyDomainConnectionToService(ClassNode classNode, ClassNode implClass, String connectionName) { + ConstantExpression connectionExpr = new ConstantExpression(connectionName) + + AnnotationNode classAnn = findAnnotation(classNode, Transactional) + if (classAnn != null) { + classAnn.setMember('connection', connectionExpr) + } + else { + AnnotationNode newAnn = new AnnotationNode(ClassHelper.make(Transactional)) + newAnn.setMember('connection', connectionExpr) + classNode.addAnnotation(newAnn) + } + + AnnotationNode implAnn = findAnnotation(implClass, Transactional) + if (implAnn != null) { + implAnn.setMember('connection', connectionExpr) + } + else { + AnnotationNode newImplAnn = new AnnotationNode(ClassHelper.make(Transactional)) + newImplAnn.setMember('connection', connectionExpr) + implClass.addAnnotation(newImplAnn) + } + } + @Override int priority() { GroovyTransformOrder.DATA_SERVICE_ORDER diff --git a/grails-datamapping-core/src/test/groovy/grails/gorm/services/ConnectionRoutingServiceTransformSpec.groovy b/grails-datamapping-core/src/test/groovy/grails/gorm/services/ConnectionRoutingServiceTransformSpec.groovy index ff223997e7..1c1aa441ac 100644 --- a/grails-datamapping-core/src/test/groovy/grails/gorm/services/ConnectionRoutingServiceTransformSpec.groovy +++ b/grails-datamapping-core/src/test/groovy/grails/gorm/services/ConnectionRoutingServiceTransformSpec.groovy @@ -377,4 +377,220 @@ class Gadget { thrown(IllegalStateException) } + void "test service auto-inherits datasource from domain mapping"() { + when: "a service targets a domain with datasource 'secondary' but has no @Transactional(connection)" + def service = new GroovyClassLoader().parseClass(''' +import grails.gorm.services.Service +import grails.gorm.annotation.Entity +import grails.gorm.transactions.Transactional + +@Service(AutoWidget) +abstract class AutoWidgetService { + + abstract AutoWidget save(AutoWidget widget) + + abstract AutoWidget find(Serializable id) + + abstract AutoWidget delete(Serializable id) +} + +@Entity +class AutoWidget { + String name + + static mapping = { + datasource 'secondary' + } +} +''') + + then: "the class compiles without errors" + !service.isInterface() + + when: "the implementation is loaded" + def impl = service.classLoader.loadClass('$AutoWidgetServiceImplementation') + + then: "the implementation inherits @Transactional(connection) from the domain datasource" + impl != null + impl.getAnnotation(Transactional) != null + impl.getAnnotation(Transactional).connection() == 'secondary' + } + + void "test interface service auto-inherits datasource from domain mapping"() { + when: "an interface service targets a domain with datasource 'secondary' but has no @Transactional(connection)" + def service = new GroovyClassLoader().parseClass(''' +import grails.gorm.services.Service +import grails.gorm.annotation.Entity +import grails.gorm.transactions.Transactional + +@Service(AutoGadget) +interface AutoGadgetService { + + AutoGadget save(AutoGadget gadget) + + AutoGadget find(Serializable id) + + AutoGadget delete(Serializable id) +} + +@Entity +class AutoGadget { + String label + + static mapping = { + datasource 'secondary' + } +} +''') + + then: "the interface compiles without errors" + service.isInterface() + + when: "the implementation is loaded" + def impl = service.classLoader.loadClass('$AutoGadgetServiceImplementation') + + then: "the implementation inherits @Transactional(connection) from the domain datasource" + impl != null + impl.getAnnotation(Transactional) != null + impl.getAnnotation(Transactional).connection() == 'secondary' + } + + void "test explicit @Transactional(connection) wins over domain datasource"() { + when: "a service has explicit @Transactional(connection='primary') but domain has datasource 'secondary'" + def service = new GroovyClassLoader().parseClass(''' +import grails.gorm.services.Service +import grails.gorm.annotation.Entity +import grails.gorm.transactions.Transactional + +@Service(ExplicitItem) +@Transactional(connection = 'primary') +abstract class ExplicitItemService { + + abstract ExplicitItem save(ExplicitItem item) +} + +@Entity +class ExplicitItem { + String name + + static mapping = { + datasource 'secondary' + } +} +''') + + then: "the class compiles without errors" + !service.isInterface() + + when: "the implementation is loaded" + def impl = service.classLoader.loadClass('$ExplicitItemServiceImplementation') + + then: "the explicit connection annotation wins" + impl != null + impl.getAnnotation(Transactional) != null + impl.getAnnotation(Transactional).connection() == 'primary' + } + + void "test service with domain on default datasource gets no connection annotation"() { + when: "a service targets a domain with no datasource mapping (default)" + def service = new GroovyClassLoader().parseClass(''' +import grails.gorm.services.Service +import grails.gorm.annotation.Entity +import grails.gorm.transactions.Transactional + +@Service(DefaultItem) +abstract class DefaultItemService { + + abstract DefaultItem save(DefaultItem item) +} + +@Entity +class DefaultItem { + String name +} +''') + + then: "the class compiles without errors" + !service.isInterface() + + when: "the implementation is loaded" + def impl = service.classLoader.loadClass('$DefaultItemServiceImplementation') + + then: "no @Transactional(connection) is added" + impl != null + def txAnn = impl.getAnnotation(Transactional) + txAnn == null || txAnn.connection() == '' + } + + void "test service auto-inherits connection from domain using connection() method"() { + when: "a domain uses connection() instead of datasource() in its mapping" + def service = new GroovyClassLoader().parseClass(''' +import grails.gorm.services.Service +import grails.gorm.annotation.Entity +import grails.gorm.transactions.Transactional + +@Service(ConnItem) +abstract class ConnItemService { + + abstract ConnItem save(ConnItem item) +} + +@Entity +class ConnItem { + String name + + static mapping = { + connection 'warehouse' + } +} +''') + + then: "the class compiles without errors" + !service.isInterface() + + when: "the implementation is loaded" + def impl = service.classLoader.loadClass('$ConnItemServiceImplementation') + + then: "the implementation inherits @Transactional(connection) from the domain's connection" + impl != null + impl.getAnnotation(Transactional) != null + impl.getAnnotation(Transactional).connection() == 'warehouse' + } + + void "test @Transactional without connection gets connection added from domain"() { + when: "a service has @Transactional (no connection) and domain has datasource 'secondary'" + def service = new GroovyClassLoader().parseClass(''' +import grails.gorm.services.Service +import grails.gorm.annotation.Entity +import grails.gorm.transactions.Transactional + +@Service(TxItem) +@Transactional +abstract class TxItemService { + + abstract TxItem save(TxItem item) +} + +@Entity +class TxItem { + String name + + static mapping = { + datasource 'secondary' + } +} +''') + + then: "the class compiles without errors" + !service.isInterface() + + when: "the implementation is loaded" + def impl = service.classLoader.loadClass('$TxItemServiceImplementation') + + then: "the existing @Transactional gets the connection member from the domain" + impl != null + impl.getAnnotation(Transactional) != null + impl.getAnnotation(Transactional).connection() == 'secondary' + } + } diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/DatastoreServiceMethodInvokingFactoryBean.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/DatastoreServiceMethodInvokingFactoryBean.groovy index e59393c918..6026ee4d5f 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/DatastoreServiceMethodInvokingFactoryBean.groovy +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/DatastoreServiceMethodInvokingFactoryBean.groovy @@ -19,6 +19,8 @@ package org.grails.datastore.mapping.config +import java.lang.annotation.Annotation + import groovy.transform.CompileStatic import groovy.transform.Internal @@ -29,6 +31,10 @@ import org.springframework.beans.factory.config.MethodInvokingFactoryBean import org.springframework.lang.Nullable import org.grails.datastore.mapping.core.Datastore +import org.grails.datastore.mapping.core.connections.ConnectionSource +import org.grails.datastore.mapping.core.connections.ConnectionSourcesSupport +import org.grails.datastore.mapping.core.connections.MultipleConnectionSourceCapableDatastore +import org.grails.datastore.mapping.model.PersistentEntity import org.grails.datastore.mapping.services.Service /** @@ -56,7 +62,8 @@ class DatastoreServiceMethodInvokingFactoryBean extends MethodInvokingFactoryBea protected Object invokeWithTargetException() throws Exception { Object object = super.invokeWithTargetException() if (object) { - ((Service) object).setDatastore((Datastore) targetObject) + Datastore effectiveDatastore = resolveEffectiveDatastore((Datastore) targetObject) + ((Service) object).setDatastore(effectiveDatastore) if (beanFactory instanceof AutowireCapableBeanFactory) { ((AutowireCapableBeanFactory) beanFactory).autowireBeanProperties(object, AutowireCapableBeanFactory.AUTOWIRE_BY_TYPE, false) } @@ -64,6 +71,44 @@ class DatastoreServiceMethodInvokingFactoryBean extends MethodInvokingFactoryBea object } + private Datastore resolveEffectiveDatastore(Datastore defaultDatastore) { + if (!(defaultDatastore instanceof MultipleConnectionSourceCapableDatastore)) { + return defaultDatastore + } + + Class<?> domainClass = getServiceDomainClass() + if (domainClass == null || domainClass == Object) { + return defaultDatastore + } + + PersistentEntity entity = defaultDatastore.getMappingContext()?.getPersistentEntity(domainClass.getName()) + if (entity == null) { + return defaultDatastore + } + + String domainConnection = ConnectionSourcesSupport.getDefaultConnectionSourceName(entity) + if (domainConnection != null + && !ConnectionSource.DEFAULT.equals(domainConnection) + && !ConnectionSource.ALL.equals(domainConnection)) { + return ((MultipleConnectionSourceCapableDatastore) defaultDatastore).getDatastoreForConnection(domainConnection) + } + + return defaultDatastore + } + + private Class<?> getServiceDomainClass() { + try { + for (Annotation ann : serviceClass.getAnnotations()) { + if ('grails.gorm.services.Service' == ann.annotationType().getName()) { + return (Class<?>) ann.annotationType().getMethod('value').invoke(ann) + } + } + } + catch (Exception ignored) { + } + return null + } + @Override void setBeanFactory(BeanFactory beanFactory) { super.setBeanFactory(beanFactory) diff --git a/grails-doc/src/en/guide/conf/dataSource/multipleDatasources.adoc b/grails-doc/src/en/guide/conf/dataSource/multipleDatasources.adoc index bd9e875a78..643e2b8ab5 100644 --- a/grails-doc/src/en/guide/conf/dataSource/multipleDatasources.adoc +++ b/grails-doc/src/en/guide/conf/dataSource/multipleDatasources.adoc @@ -260,6 +260,50 @@ Note that the datasource specified in a service has no bearing on which datasour If you have a `Foo` domain class in `dataSource1` and a `Bar` domain class in `dataSource2`, if `WahooService` uses `dataSource1`, a service method that saves a new `Foo` and a new `Bar` will only be transactional for `Foo` since they share the same datasource. The transaction won't affect the `Bar` instance. If you want both to be transactional you'd need to use two services and XA datasources for two-phase commit, e.g. with the Atomikos plugin. +===== GORM Data Service Datasource Inheritance + +When using GORM Data Services (the `@Service` annotation), the service automatically inherits the datasource from its domain class's `mapping` block. This means you do not need to explicitly specify `@Transactional(connection = '...')` if the domain class already declares its datasource. + +For example, given a domain class mapped to the `'lookup'` datasource: + +[source,groovy] +---- +class ZipCode { + + String code + + static mapping = { + datasource 'lookup' + } +} +---- + +A GORM Data Service targeting this domain class will automatically route all auto-implemented operations (save, get, delete, find, count) to the `'lookup'` datasource: + +[source,groovy] +---- +@Service(ZipCode) +abstract class ZipCodeService { + + abstract ZipCode get(Serializable id) + abstract ZipCode save(ZipCode zipCode) + abstract ZipCode delete(Serializable id) + abstract List<ZipCode> findByCode(String code) +} +---- + +If you need to override the inherited datasource, you can still use an explicit `@Transactional(connection = '...')` annotation on the service, which takes precedence: + +[source,groovy] +---- +@Service(ZipCode) +@Transactional(connection = 'auditing') +abstract class ZipCodeAuditService { + + abstract ZipCode save(ZipCode zipCode) +} +---- + ==== Transactions across multiple data sources Grails does not by default try to handle transactions that span multiple data sources. diff --git a/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/services/example/InheritedProductService.groovy b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/services/example/InheritedProductService.groovy new file mode 100644 index 0000000000..af5494f7ef --- /dev/null +++ b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/services/example/InheritedProductService.groovy @@ -0,0 +1,38 @@ +/* + * 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 example + +import grails.gorm.services.Service + +@Service(Product) +abstract class InheritedProductService { + + abstract Product get(Serializable id) + + abstract Product save(Product product) + + abstract Product delete(Serializable id) + + abstract Number count() + + abstract Product findByName(String name) + + abstract List<Product> findAllByName(String name) +} diff --git a/grails-test-examples/hibernate5/grails-data-service-multi-datasource/src/integration-test/groovy/functionaltests/DataServiceDatasourceInheritanceSpec.groovy b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/src/integration-test/groovy/functionaltests/DataServiceDatasourceInheritanceSpec.groovy new file mode 100644 index 0000000000..8432b789be --- /dev/null +++ b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/src/integration-test/groovy/functionaltests/DataServiceDatasourceInheritanceSpec.groovy @@ -0,0 +1,121 @@ +/* + * 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 functionaltests + +import example.Product +import example.InheritedProductService + +import org.springframework.beans.factory.annotation.Autowired + +import grails.testing.mixin.integration.Integration +import org.grails.orm.hibernate.HibernateDatastore +import spock.lang.Specification + +@Integration +class DataServiceDatasourceInheritanceSpec extends Specification { + + @Autowired + HibernateDatastore hibernateDatastore + + InheritedProductService inheritedProductService + + void setup() { + inheritedProductService = hibernateDatastore + .getDatastoreForConnection('secondary') + .getService(InheritedProductService) + } + + void cleanup() { + Product.secondary.withTransaction { + Product.secondary.executeUpdate('delete from Product') + } + } + + void "save routes to secondary datasource via inherited connection"() { + when: + def saved = inheritedProductService.save(new Product(name: 'InheritedWidget', amount: 42)) + + then: + saved != null + saved.id != null + saved.name == 'InheritedWidget' + saved.amount == 42 + } + + void "get by ID routes to secondary datasource via inherited connection"() { + given: + def saved = inheritedProductService.save(new Product(name: 'InheritedGadget', amount: 99)) + + when: + def found = inheritedProductService.get(saved.id) + + then: + found != null + found.id == saved.id + found.name == 'InheritedGadget' + found.amount == 99 + } + + void "count routes to secondary datasource via inherited connection"() { + given: + inheritedProductService.save(new Product(name: 'Alpha', amount: 10)) + inheritedProductService.save(new Product(name: 'Beta', amount: 20)) + + expect: + inheritedProductService.count() == 2 + } + + void "delete routes to secondary datasource via inherited connection"() { + given: + def saved = inheritedProductService.save(new Product(name: 'Ephemeral', amount: 1)) + + when: + inheritedProductService.delete(saved.id) + + then: + inheritedProductService.get(saved.id) == null + } + + void "findByName routes to secondary datasource via inherited connection"() { + given: + inheritedProductService.save(new Product(name: 'Unique', amount: 77)) + + when: + def found = inheritedProductService.findByName('Unique') + + then: + found != null + found.name == 'Unique' + found.amount == 77 + } + + void "findAllByName routes to secondary datasource via inherited connection"() { + given: + inheritedProductService.save(new Product(name: 'Duplicate', amount: 10)) + inheritedProductService.save(new Product(name: 'Duplicate', amount: 20)) + inheritedProductService.save(new Product(name: 'Other', amount: 30)) + + when: + def found = inheritedProductService.findAllByName('Duplicate') + + then: + found.size() == 2 + found.every { it.name == 'Duplicate' } + } +}
