diff --git a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/connections/DataServiceMultiDataSourceSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/connections/DataServiceMultiDataSourceSpec.groovy new file mode 100644 index 00000000000..b7177c772b3 --- /dev/null +++ b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/connections/DataServiceMultiDataSourceSpec.groovy @@ -0,0 +1,365 @@ +/* + * 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 + +/** + * Integration tests for GORM Data Service auto-implemented CRUD methods + * routing to a non-default datasource via @Transactional(connection). + * + * The Product domain is mapped exclusively to the 'books' datasource. + * Without the connection-routing fix, auto-implemented save/get/delete + * would attempt to use the default datasource (where no Product table + * exists), causing failures. + * + * Tests both patterns: + * - Abstract class implementing interface (ProductService) + * - Interface-only with @Transactional(connection) (ProductDataService) + * + * @see org.grails.datastore.gorm.services.implementers.SaveImplementer + * @see org.grails.datastore.gorm.services.implementers.DeleteImplementer + * @see org.grails.datastore.gorm.services.implementers.FindAndDeleteImplementer + * @see org.grails.datastore.gorm.services.implementers.AbstractDetachedCriteriaServiceImplementor + */ +class DataServiceMultiDataSourceSpec 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.books':[url:"jdbc:h2:mem:booksDB;LOCK_TIMEOUT=10000"], + ] + + @Shared @AutoCleanup HibernateDatastore datastore = new HibernateDatastore( + DatastoreUtils.createPropertyResolver(config), Product + ) + + @Shared ProductService productService + @Shared ProductDataService productDataService + + void setupSpec() { + productService = datastore + .getDatastoreForConnection('books') + .getService(ProductService) + productDataService = datastore + .getDatastoreForConnection('books') + .getService(ProductDataService) + } + + void setup() { + def api = GormEnhancer.findStaticApi(Product, 'books') + api.withNewTransaction { + api.executeUpdate('delete from Product') + } + } + + void "schema is created on the books datasource"() { + when: 'we query the books datasource for the product table' + def api = GormEnhancer.findStaticApi(Product, 'books') + def result = api.withNewTransaction { + api.executeQuery('SELECT 1 FROM Product p WHERE 1=0') + } + + then: 'no exception - table exists on books' + noExceptionThrown() + result != null + } + + void "save routes to books datasource"() { + when: 'a product is saved through the Data Service' + def saved = productService.save(new Product(name: 'Widget', amount: 42)) + + then: 'it is persisted with an ID' + saved != null + saved.id != null + saved.name == 'Widget' + saved.amount == 42 + + and: 'it exists on the books datasource' + GormEnhancer.findStaticApi(Product, 'books').withNewTransaction { + GormEnhancer.findStaticApi(Product, 'books').count() + } == 1 + } + + void "get by ID routes to books datasource"() { + given: 'a product saved on books' + def saved = productService.save(new Product(name: 'Gadget', amount: 99)) + + when: 'we retrieve it by ID' + def found = productService.get(saved.id) + + then: 'the correct entity is returned' + found != null + found.id == saved.id + found.name == 'Gadget' + found.amount == 99 + } + + void "count routes to books datasource"() { + given: 'two products saved on books' + productService.save(new Product(name: 'Alpha', amount: 10)) + productService.save(new Product(name: 'Beta', amount: 20)) + + expect: 'count returns 2' + productService.count() == 2 + } + + void "delete by ID routes to books datasource - FindAndDeleteImplementer"() { + given: 'a product saved on books' + def saved = productService.save(new Product(name: 'Ephemeral', amount: 1)) + + when: 'we delete it using delete(id) which returns the domain object' + def deleted = productService.delete(saved.id) + + then: 'the deleted entity is returned and no longer exists' + deleted != null + deleted.name == 'Ephemeral' + productService.get(saved.id) == null + productService.count() == 0 + } + + void "delete by ID routes to books datasource - DeleteImplementer"() { + given: 'a product saved on books' + def saved = productService.save(new Product(name: 'AlsoEphemeral', amount: 2)) + + when: 'we delete it using void deleteProduct(id)' + productService.deleteProduct(saved.id) + + then: 'it no longer exists' + productService.get(saved.id) == null + productService.count() == 0 + } + + void "findByName routes to books datasource"() { + given: "products saved on books" + productService.save(new Product(name: 'Unique', amount: 77)) + productService.save(new Product(name: 'Other', amount: 88)) + + when: "we find by name" + def found = productService.findByName('Unique') + + then: "the correct entity is returned" + found != null + found.name == 'Unique' + found.amount == 77 + } + + void "findAllByName routes to books datasource"() { + given: 'products with duplicate names on books' + productService.save(new Product(name: 'Duplicate', amount: 10)) + productService.save(new Product(name: 'Duplicate', amount: 20)) + productService.save(new Product(name: 'Singleton', amount: 30)) + + when: 'we find all by name' + def found = productService.findAllByName('Duplicate') + + then: 'both matching entities are returned' + found.size() == 2 + found.every { it.name == 'Duplicate' } + } + + void "GormEnhancer escape-hatch HQL works on books datasource"() { + given: 'products saved on books' + productService.save(new Product(name: 'Foo', amount: 100)) + productService.save(new Product(name: 'Bar', amount: 200)) + + when: 'we run aggregate HQL through GormEnhancer' + def api = GormEnhancer.findStaticApi(Product, 'books') + def result = api.withNewTransaction { + api.executeQuery('SELECT SUM(p.amount) FROM Product p') + } + + then: 'the aggregation reflects books data' + result[0] == 300 + } + + void "save, get, and find round-trip through Data Service"() { + when: 'a product is saved, retrieved by ID, and found by name' + def saved = productService.save(new Product(name: 'RoundTrip', amount: 33)) + def byId = productService.get(saved.id) + def byName = productService.findByName('RoundTrip') + + then: 'all three references point to the same entity' + saved.id == byId.id + saved.id == byName.id + byId.name == 'RoundTrip' + byName.amount == 33 + } + + void "save with constructor-style arguments routes to books datasource"() { + when: 'a product is saved using property arguments' + def saved = productService.saveProduct('Constructed', 55) + + then: 'it is persisted on books' + saved != null + saved.id != null + saved.name == 'Constructed' + saved.amount == 55 + + and: 'retrievable' + productService.get(saved.id) != null + } + + // ---- Interface-pattern Data Service tests ---- + + void "interface service: save routes to books datasource"() { + when: 'a product is saved through the interface Data Service' + def saved = productDataService.save(new Product(name: 'InterfaceWidget', amount: 42)) + + then: 'it is persisted with an ID' + saved != null + saved.id != null + saved.name == 'InterfaceWidget' + saved.amount == 42 + + and: 'it exists on the books datasource' + GormEnhancer.findStaticApi(Product, 'books').withNewTransaction { + GormEnhancer.findStaticApi(Product, 'books').count() + } == 1 + } + + void "interface service: get by ID routes to books datasource"() { + given: 'a product saved on books via abstract service' + def saved = productService.save(new Product(name: 'InterfaceGet', amount: 99)) + + when: 'we retrieve it through the interface Data Service' + def found = productDataService.get(saved.id) + + then: 'the correct entity is returned' + found != null + found.id == saved.id + found.name == 'InterfaceGet' + } + + void "interface service: delete routes to books datasource"() { + given: 'a product saved on books' + def saved = productService.save(new Product(name: 'InterfaceDelete', amount: 1)) + + when: 'we delete through the interface Data Service (FindAndDeleteImplementer)' + def deleted = productDataService.delete(saved.id) + + then: 'the entity is deleted' + deleted != null + deleted.name == 'InterfaceDelete' + productDataService.get(saved.id) == null + } + + void "interface service: void delete routes to books datasource"() { + given: 'a product saved on books' + def saved = productService.save(new Product(name: 'InterfaceVoidDel', amount: 2)) + + when: 'we delete through the interface Data Service (DeleteImplementer)' + productDataService.deleteProduct(saved.id) + + then: 'the entity is deleted' + productDataService.get(saved.id) == null + } + + void "interface and abstract services share the same datasource"() { + given: 'a product saved through the abstract service' + def saved = productService.save(new Product(name: 'CrossService', amount: 77)) + + expect: 'the interface service can find it and vice versa' + productDataService.findByName('CrossService') != null + productDataService.findByName('CrossService').id == saved.id + + and: 'counts match across both service patterns' + productService.count() == productDataService.count() + } + +} + +@Entity +class Product { + Long id + Long version + String name + Integer amount + + static mapping = { + datasource 'books' + } + static constraints = { + name blank: false + } +} + +@Service(Product) +@Transactional(connection = 'books') +abstract class ProductService { + + abstract Product get(Serializable id) + + abstract Product save(Product product) + + abstract Product delete(Serializable id) + + abstract void deleteProduct(Serializable id) + + abstract Number count() + + abstract Product findByName(String name) + + abstract List findAllByName(String name) + + /** + * Constructor-style save - GORM creates the entity from parameters. + * Tests that SaveImplementer routes multi-arg saves through connection-aware API. + */ + abstract Product saveProduct(String name, Integer amount) +} + +/** + * Interface-only Data Service pattern. + * Verifies that connection routing works identically whether the service + * is declared as an interface or an abstract class. + */ +@Service(Product) +@Transactional(connection = 'books') +interface ProductDataService { + + Product get(Serializable id) + + Product save(Product product) + + Product delete(Serializable id) + + void deleteProduct(Serializable id) + + Number count() + + Product findByName(String name) + + List findAllByName(String name) +} diff --git a/grails-datamapping-core-test/src/test/groovy/grails/gorm/tests/MultipleDataSourceSpec.groovy b/grails-datamapping-core-test/src/test/groovy/grails/gorm/tests/MultipleDataSourceSpec.groovy index d1a3046861d..8531c271cae 100644 --- a/grails-datamapping-core-test/src/test/groovy/grails/gorm/tests/MultipleDataSourceSpec.groovy +++ b/grails-datamapping-core-test/src/test/groovy/grails/gorm/tests/MultipleDataSourceSpec.groovy @@ -18,56 +18,55 @@ */ package grails.gorm.tests +import spock.lang.AutoCleanup +import spock.lang.Specification + import grails.gorm.DetachedCriteria import grails.gorm.annotation.Entity import grails.gorm.services.Service import grails.gorm.transactions.Transactional import org.grails.datastore.mapping.core.connections.ConnectionSource import org.grails.datastore.mapping.simple.SimpleMapDatastore -import spock.lang.AutoCleanup -import spock.lang.Shared -import spock.lang.Specification -/** - * Created by graemerocher on 10/01/2017. - */ class MultipleDataSourceSpec extends Specification { - @AutoCleanup SimpleMapDatastore datastore = new SimpleMapDatastore([ConnectionSource.DEFAULT, "one"], Player) + @AutoCleanup + SimpleMapDatastore datastore = new SimpleMapDatastore( + [ConnectionSource.DEFAULT, 'one'], + Player + ) - void "test multiple datasource support with in-memory GORM"() { + void 'test multiple datasource support with in-memory GORM'() { given: - new Player(name: "Giggs").save(flush:true) - new Player(name: "Keane").save(flush:true) - PlayerService service = new PlayerService() - IPlayerService dataService = datastore.getService(IPlayerService) - dataService.savePlayer("Neville") + new Player(name: 'Giggs').save(flush: true) + new Player(name: 'Keane').save(flush: true) + def service = new PlayerService() + def dataService = datastore.getService(IPlayerService) + dataService.savePlayer('Neville') + expect: Player.count() == 2 new DetachedCriteria<>(Player).count() == 2 - new DetachedCriteria<>(Player).withConnection("one").count() == 1 + new DetachedCriteria<>(Player).withConnection('one').count() == 1 Player.one.count() == 1 service.countPlayers() == 2 service.countPlayersOne() == 1 dataService.countPlayers() == 1 } - void "test delete on data service"() { - + void 'test delete on data service'() { given: - PlayerService service = new PlayerService() - IPlayerService dataService = datastore.getService(IPlayerService) - + def dataService = datastore.getService(IPlayerService) when: - dataService.savePlayer("Neville") + dataService.savePlayer('Neville') then: Player.count() == 0 dataService.countPlayers() == 1 when: - dataService.deletePlayer("Neville") + dataService.deletePlayer('Neville') then: dataService.countPlayers() == 0 @@ -76,26 +75,40 @@ class MultipleDataSourceSpec extends Specification { @Entity class Player { + String name static mapping = { - datasources ConnectionSource.DEFAULT, "one" + datasources(ConnectionSource.DEFAULT, 'one') } } @Transactional class PlayerService { - @Transactional("one") + @Transactional('one') Number countPlayersOne() { // check the right datastore transaction is being used - assert transactionStatus.transaction.sessionHolder.sessions.first().datastore.backingMap[Player.name].size() == 1 + assert transactionStatus + .transaction + .sessionHolder + .sessions + .first() + .datastore + .backingMap[Player.name] + .size() == 1 Player.one.count() } - Number countPlayers() { - assert !transactionStatus.transaction.sessionHolder.sessions.first().datastore.backingMap[Player.name].isEmpty() + assert !transactionStatus + .transaction + .sessionHolder + .sessions + .first() + .datastore + .backingMap[Player.name] + .isEmpty() Player.count() } } @@ -105,8 +118,6 @@ class PlayerService { interface IPlayerService { Number countPlayers() - Player savePlayer(String name) - void deletePlayer(String name) } \ No newline at end of file diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/AbstractDetachedCriteriaServiceImplementor.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/AbstractDetachedCriteriaServiceImplementor.groovy index 845aa25fe67..312b60454a7 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/AbstractDetachedCriteriaServiceImplementor.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/AbstractDetachedCriteriaServiceImplementor.groovy @@ -64,8 +64,15 @@ abstract class AbstractDetachedCriteriaServiceImplementor extends AbstractReadOp int parameterCount = parameters.length AnnotationNode joinAnnotation = AstUtils.findAnnotation(abstractMethodNode, Join) if (lookupById() && joinAnnotation == null && parameterCount == 1 && parameters[0].name == GormProperties.IDENTITY) { - // optimize query by id - Expression byId = callX(classX(domainClassNode), 'get', varX(parameters[0])) + // optimize query by id — route through static API when connection is specified + Expression connectionId = findConnectionId(abstractMethodNode) + Expression byId + if (connectionId != null) { + byId = callX(buildStaticApiLookup(domainClassNode, connectionId), 'get', varX(parameters[0])) + } + else { + byId = callX(classX(domainClassNode), 'get', varX(parameters[0])) + } implementById(domainClassNode, abstractMethodNode, newMethodNode, targetClassNode, body, byId) } else { @@ -75,7 +82,7 @@ abstract class AbstractDetachedCriteriaServiceImplementor extends AbstractReadOp body.addStatement( declS(queryVar, ctorX(getDetachedCriteriaType(domainClassNode), args(classX(domainClassNode.plainNodeReference)))) ) - Expression connectionId = findConnectionId(newMethodNode) + Expression connectionId = findConnectionId(abstractMethodNode) if (connectionId != null) { body.addStatement( diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/AbstractSaveImplementer.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/AbstractSaveImplementer.groovy index 73b8f21c626..2ba6864766b 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/AbstractSaveImplementer.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/AbstractSaveImplementer.groovy @@ -51,7 +51,7 @@ import static org.grails.datastore.gorm.transform.AstMethodDispatchUtils.namedAr @CompileStatic abstract class AbstractSaveImplementer extends AbstractWriteOperationImplementer { - protected Statement bindParametersAndSave(ClassNode domainClassNode, MethodNode abstractMethodNode, Parameter[] parameters, BlockStatement body, VariableExpression entityVar) { + protected Statement bindParametersAndSave(ClassNode domainClassNode, MethodNode abstractMethodNode, MethodNode newMethodNode, Parameter[] parameters, BlockStatement body, VariableExpression entityVar) { Expression argsExpression = null for (Parameter parameter in parameters) { @@ -64,8 +64,8 @@ abstract class AbstractSaveImplementer extends AbstractWriteOperationImplementer argsExpression = varX(parameter) } else { AstUtils.error( - abstractMethodNode.declaringClass.module.context, - abstractMethodNode, + newMethodNode.declaringClass.module.context, + newMethodNode, "Cannot implement method for argument [${parameterName}]. No property exists on domain class [$domainClassNode.name]" ) } diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/DeleteImplementer.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/DeleteImplementer.groovy index e470e9a31f5..701a6858b0b 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/DeleteImplementer.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/DeleteImplementer.groovy @@ -33,6 +33,7 @@ import org.codehaus.groovy.ast.stmt.Statement import org.grails.datastore.gorm.transactions.transform.TransactionalTransform import org.grails.datastore.mapping.reflect.AstUtils +import static org.codehaus.groovy.ast.tools.GeneralUtils.args import static org.codehaus.groovy.ast.tools.GeneralUtils.block import static org.codehaus.groovy.ast.tools.GeneralUtils.callX import static org.codehaus.groovy.ast.tools.GeneralUtils.constX @@ -80,7 +81,15 @@ class DeleteImplementer extends AbstractDetachedCriteriaServiceImplementor imple void implementById(ClassNode domainClassNode, MethodNode abstractMethodNode, MethodNode newMethodNode, ClassNode targetClassNode, BlockStatement body, Expression byIdLookup) { boolean isVoidReturnType = ClassHelper.VOID_TYPE.equals(newMethodNode.returnType) VariableExpression obj = varX('$obj') - Statement deleteStatement = stmt(callX(obj, 'delete')) + Expression connectionId = findConnectionId(abstractMethodNode) + Statement deleteStatement + if (connectionId != null) { + // Route delete through the instance API for the specified connection + deleteStatement = stmt(callX(buildInstanceApiLookup(domainClassNode, connectionId), 'delete', args(obj))) + } + else { + deleteStatement = stmt(callX(obj, 'delete')) + } if (!isVoidReturnType) { deleteStatement = block( deleteStatement, diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/FindAndDeleteImplementer.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/FindAndDeleteImplementer.groovy index 6c66421b6b7..59331576dea 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/FindAndDeleteImplementer.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/FindAndDeleteImplementer.groovy @@ -23,6 +23,7 @@ import groovy.transform.CompileStatic import org.codehaus.groovy.ast.AnnotationNode import org.codehaus.groovy.ast.ClassNode import org.codehaus.groovy.ast.MethodNode +import org.codehaus.groovy.ast.expr.ArgumentListExpression import org.codehaus.groovy.ast.expr.Expression import org.codehaus.groovy.ast.expr.MethodCallExpression import org.codehaus.groovy.ast.expr.VariableExpression @@ -35,6 +36,8 @@ import org.grails.datastore.mapping.reflect.AstUtils import static org.codehaus.groovy.ast.tools.GeneralUtils.block import static org.codehaus.groovy.ast.tools.GeneralUtils.callX import static org.codehaus.groovy.ast.tools.GeneralUtils.declS +import static org.codehaus.groovy.ast.tools.GeneralUtils.ifS +import static org.codehaus.groovy.ast.tools.GeneralUtils.notNullX import static org.codehaus.groovy.ast.tools.GeneralUtils.returnS import static org.codehaus.groovy.ast.tools.GeneralUtils.stmt import static org.codehaus.groovy.ast.tools.GeneralUtils.varX @@ -71,14 +74,27 @@ class FindAndDeleteImplementer extends FindOneImplementer implements SingleResul @Override protected Statement buildReturnStatement(ClassNode targetDomainClass, MethodNode abstractMethodNode, Expression queryMethodCall, Expression args, MethodNode newMethodNode) { VariableExpression var = varX('$obj', targetDomainClass) - MethodCallExpression deleteCall = args != null ? callX(var, 'delete', args) : callX(var, 'delete') - - deleteCall.setSafe(true) // null safe - block( - declS(var, queryMethodCall), - stmt(deleteCall), - returnS(var) - ) + Expression connectionId = findConnectionId(abstractMethodNode) + if (connectionId != null) { + // Route delete through the instance API for the specified connection + block( + declS(var, queryMethodCall), + ifS( + notNullX(var), + stmt(callX(buildInstanceApiLookup(targetDomainClass, connectionId), 'delete', new ArgumentListExpression(var))) + ), + returnS(var) + ) + } + else { + MethodCallExpression deleteCall = args != null ? callX(var, 'delete', args) : callX(var, 'delete') + deleteCall.setSafe(true) // null safe + block( + declS(var, queryMethodCall), + stmt(deleteCall), + returnS(var) + ) + } } @Override diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/SaveImplementer.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/SaveImplementer.groovy index 4c012c220b4..fa35663723e 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/SaveImplementer.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/SaveImplementer.groovy @@ -23,12 +23,14 @@ import org.codehaus.groovy.ast.ClassNode import org.codehaus.groovy.ast.MethodNode import org.codehaus.groovy.ast.Parameter import org.codehaus.groovy.ast.expr.ConstantExpression +import org.codehaus.groovy.ast.expr.Expression import org.codehaus.groovy.ast.expr.VariableExpression import org.codehaus.groovy.ast.stmt.BlockStatement import org.grails.datastore.gorm.GormEntity import org.grails.datastore.mapping.reflect.AstUtils +import static org.codehaus.groovy.ast.tools.GeneralUtils.args import static org.codehaus.groovy.ast.tools.GeneralUtils.callX import static org.codehaus.groovy.ast.tools.GeneralUtils.ctorX import static org.codehaus.groovy.ast.tools.GeneralUtils.declS @@ -63,9 +65,18 @@ class SaveImplementer extends AbstractSaveImplementer implements SingleResultSer Parameter[] parameters = newMethodNode.parameters int parameterCount = parameters.length if (parameterCount == 1 && AstUtils.isDomainClass(parameters[0].type)) { - body.addStatement( - returnS(callX(varX(parameters[0]), 'save', namedArgs(failOnError: ConstantExpression.TRUE))) - ) + Expression connectionId = findConnectionId(abstractMethodNode) + if (connectionId != null) { + // Route save through the instance API for the specified connection + body.addStatement( + returnS(callX(buildInstanceApiLookup(domainClassNode, connectionId), 'save', args(varX(parameters[0]), namedArgs(failOnError: ConstantExpression.TRUE)))) + ) + } + else { + body.addStatement( + returnS(callX(varX(parameters[0]), 'save', namedArgs(failOnError: ConstantExpression.TRUE))) + ) + } } else { VariableExpression entityVar = varX('$entity') @@ -73,7 +84,7 @@ class SaveImplementer extends AbstractSaveImplementer implements SingleResultSer declS(entityVar, ctorX(domainClassNode)) ) body.addStatement( - bindParametersAndSave(domainClassNode, abstractMethodNode, parameters, body, entityVar) + bindParametersAndSave(domainClassNode, abstractMethodNode, newMethodNode, parameters, body, entityVar) ) } diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/UpdateOneImplementer.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/UpdateOneImplementer.groovy index 4710a80501b..c5a563332dd 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/UpdateOneImplementer.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/UpdateOneImplementer.groovy @@ -90,7 +90,7 @@ class UpdateOneImplementer extends AbstractSaveImplementer implements SingleResu declS(entityVar, lookupCall) ) BlockStatement ifBody = block() - Statement saveStmt = bindParametersAndSave(domainClassNode, abstractMethodNode, parameters[1..-1] as Parameter[], ifBody, entityVar) + Statement saveStmt = bindParametersAndSave(domainClassNode, abstractMethodNode, newMethodNode, parameters[1..-1] as Parameter[], ifBody, entityVar) ifBody.addStatement(saveStmt) body.addStatement( ifS(notNullX(entityVar), 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 new file mode 100644 index 00000000000..ff223997e70 --- /dev/null +++ b/grails-datamapping-core/src/test/groovy/grails/gorm/services/ConnectionRoutingServiceTransformSpec.groovy @@ -0,0 +1,380 @@ +/* + * 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.gorm.services + +import grails.gorm.transactions.Transactional +import org.grails.datastore.gorm.services.Implemented +import org.grails.datastore.gorm.services.implementers.DeleteImplementer +import org.grails.datastore.gorm.services.implementers.FindAndDeleteImplementer +import org.grails.datastore.gorm.services.implementers.FindOneImplementer +import org.grails.datastore.gorm.services.implementers.SaveImplementer +import spock.lang.Specification + +/** + * Tests that auto-implemented Data Service methods correctly route through + * connection-aware GormEnhancer APIs when @Transactional(connection=...) is specified. + * + * Covers the fix for: save (single-entity), delete (by-id), find-and-delete (by-id), + * and get/find (by-id) which previously bypassed connection routing and always hit + * the default datasource. + * + * @see org.grails.datastore.gorm.services.implementers.SaveImplementer + * @see org.grails.datastore.gorm.services.implementers.DeleteImplementer + * @see org.grails.datastore.gorm.services.implementers.FindAndDeleteImplementer + * @see org.grails.datastore.gorm.services.implementers.AbstractDetachedCriteriaServiceImplementor + */ +class ConnectionRoutingServiceTransformSpec extends Specification { + + void "test save with @Transactional(connection) routes through connection-aware API"() { + when: "an abstract data service with @Transactional(connection='secondary') declares save(Foo)" + def service = new GroovyClassLoader().parseClass(''' +import grails.gorm.services.Service +import grails.gorm.annotation.Entity +import grails.gorm.transactions.Transactional + +@Service(Foo) +@Transactional(connection = 'secondary') +abstract class FooService { + + abstract Foo save(Foo foo) + + abstract Foo saveFoo(String title) +} + +@Entity +class Foo { + String title + + static mapping = { + datasource 'secondary' + } +} +''') + + then: 'the class compiles without errors' + !service.isInterface() + + when: 'the implementation is loaded' + def impl = service.classLoader.loadClass('$FooServiceImplementation') + + then: 'the implementation exists and inherits the connection-aware @Transactional' + impl != null + impl.getAnnotation(Transactional) != null + impl.getAnnotation(Transactional).connection() == 'secondary' + + and: 'save(Foo) is implemented by SaveImplementer' + impl.getMethod('save', impl.classLoader.loadClass('Foo')) + .getAnnotation(Implemented) + .by() == SaveImplementer + + and: 'saveFoo(String) is also implemented by SaveImplementer' + impl.getMethod('saveFoo', String) + .getAnnotation(Implemented) + .by() == SaveImplementer + } + + void 'test delete by id with @Transactional(connection) routes through connection-aware API'() { + when: "an abstract data service with @Transactional(connection='secondary') declares delete(Serializable)" + def service = new GroovyClassLoader().parseClass(''' +import grails.gorm.services.Service +import grails.gorm.annotation.Entity +import grails.gorm.transactions.Transactional + +@Service(Bar) +@Transactional(connection = 'secondary') +abstract class BarService { + + abstract Bar delete(Serializable id) + + abstract void deleteBar(Serializable id) + + abstract Number deleteMoreBars(String title) +} + +@Entity +class Bar { + String title + + static mapping = { + datasource 'secondary' + } +} +''') + + then: 'the class compiles without errors' + !service.isInterface() + + when: 'the implementation is loaded' + def impl = service.classLoader.loadClass('$BarServiceImplementation') + + then: 'the implementation exists and inherits the connection-aware @Transactional' + impl != null + impl.getAnnotation(Transactional) != null + impl.getAnnotation(Transactional).connection() == 'secondary' + + and: 'delete(Serializable) returning domain type is implemented by FindAndDeleteImplementer' + impl.getMethod('delete', Serializable) + .getAnnotation(Implemented) + .by() == FindAndDeleteImplementer + + and: 'void deleteBar(Serializable) is implemented by DeleteImplementer' + impl.getMethod('deleteBar', Serializable) + .getAnnotation(Implemented) + .by() == DeleteImplementer + + and: 'deleteMoreBars(String) returning Number is implemented by DeleteImplementer' + impl.getMethod('deleteMoreBars', String) + .getAnnotation(Implemented) + .by() == DeleteImplementer + } + + void "test find by id with @Transactional(connection) routes through connection-aware API"() { + when: "an abstract data service with @Transactional(connection='secondary') declares find(Serializable)" + def service = new GroovyClassLoader().parseClass(''' +import grails.gorm.services.Service +import grails.gorm.annotation.Entity +import grails.gorm.transactions.Transactional + +@Service(Baz) +@Transactional(connection = 'secondary') +abstract class BazService { + + abstract Baz find(Serializable id) + + abstract Baz get(Serializable id) + + abstract List findByTitle(String title) +} + +@Entity +class Baz { + String title + + static mapping = { + datasource 'secondary' + } +} +''') + + then: 'the class compiles without errors' + !service.isInterface() + + when: 'the implementation is loaded' + def impl = service.classLoader.loadClass('$BazServiceImplementation') + + then: 'the implementation exists and inherits the connection-aware @Transactional' + impl != null + impl.getAnnotation(Transactional) != null + impl.getAnnotation(Transactional).connection() == 'secondary' + + and: 'find(Serializable) is implemented by FindOneImplementer' + impl.getMethod('find', Serializable) + .getAnnotation(Implemented) + .by() == FindOneImplementer + + and: 'get(Serializable) is implemented by FindOneImplementer' + impl.getMethod('get', Serializable) + .getAnnotation(Implemented) + .by() == FindOneImplementer + } + + void "test interface service with @Transactional(connection) compiles all CRUD methods"() { + when: "an interface data service with @Transactional(connection='secondary') declares CRUD methods" + def service = new GroovyClassLoader().parseClass(''' +import grails.gorm.services.Service +import grails.gorm.annotation.Entity +import grails.gorm.transactions.Transactional + +@Service(Widget) +@Transactional(connection = 'secondary') +interface WidgetService { + + Widget save(Widget widget) + + Widget saveFoo(String name) + + Widget find(Serializable id) + + Widget get(Serializable id) + + Widget delete(Serializable id) + + void deleteWidget(Serializable id) + + Number deleteMoreWidgets(String name) + + List findByName(String name) +} + +@Entity +class Widget { + String name + + static mapping = { + datasource 'secondary' + } +} +''') + + then: 'the interface compiles without errors' + service.isInterface() + + when: 'the implementation is loaded' + def impl = service.classLoader.loadClass('$WidgetServiceImplementation') + + then: 'the implementation exists and inherits the connection-aware @Transactional' + impl != null + impl.getAnnotation(Transactional) != null + impl.getAnnotation(Transactional).connection() == 'secondary' + + and: 'save(Widget) is implemented' + impl.getMethod('save', impl.classLoader.loadClass('Widget')) + .getAnnotation(Implemented) + .by() == SaveImplementer + + and: 'find(Serializable) is implemented' + impl.getMethod('find', Serializable) + .getAnnotation(Implemented) + .by() == FindOneImplementer + + and: 'delete(Serializable) returning domain type is implemented by FindAndDeleteImplementer' + impl.getMethod('delete', Serializable) + .getAnnotation(Implemented) + .by() == FindAndDeleteImplementer + + and: 'void deleteWidget(Serializable) is implemented by DeleteImplementer' + impl.getMethod('deleteWidget', Serializable) + .getAnnotation(Implemented) + .by() == DeleteImplementer + + and: 'deleteMoreWidgets(String) returning Number is implemented by DeleteImplementer' + impl.getMethod('deleteMoreWidgets', String) + .getAnnotation(Implemented) + .by() == DeleteImplementer + } + + void "test service without @Transactional(connection) still compiles CRUD correctly"() { + when: 'a data service WITHOUT connection annotation declares CRUD methods' + def service = new GroovyClassLoader().parseClass(''' +import grails.gorm.services.Service +import grails.gorm.annotation.Entity + +@Service(Thing) +interface ThingService { + + Thing save(Thing thing) + + Thing find(Serializable id) + + Thing delete(Serializable id) + + void deleteThing(Serializable id) + + List findByTitle(String title) +} + +@Entity +class Thing { + String title +} +''') + + then: 'the interface compiles without errors' + service.isInterface() + + when: 'the implementation is loaded' + def impl = service.classLoader.loadClass('$ThingServiceImplementation') + + then: 'the implementation exists' + impl != null + + and: 'save is implemented by SaveImplementer' + impl.getMethod('save', impl.classLoader.loadClass('Thing')) + .getAnnotation(Implemented) + .by() == SaveImplementer + + and: 'find is implemented by FindOneImplementer' + impl.getMethod('find', Serializable) + .getAnnotation(Implemented) + .by() == FindOneImplementer + + and: 'delete returning domain type is implemented by FindAndDeleteImplementer' + impl.getMethod('delete', Serializable) + .getAnnotation(Implemented) + .by() == FindAndDeleteImplementer + + and: 'void deleteThing is implemented by DeleteImplementer' + impl.getMethod("deleteThing", Serializable) + .getAnnotation(Implemented) + .by() == DeleteImplementer + } + + void 'test save/delete/get with connection actually invoke connection-aware API at runtime'() { + when: 'a service with connection routing is instantiated and methods are called' + def service = new GroovyClassLoader().parseClass(''' +import grails.gorm.services.Service +import grails.gorm.annotation.Entity +import grails.gorm.transactions.Transactional + +@Service(Gadget) +@Transactional(connection = 'secondary') +interface GadgetService { + + Gadget save(Gadget gadget) + + Gadget find(Serializable id) + + Gadget delete(Serializable id) +} + +@Entity +class Gadget { + String label + + static mapping = { + datasource 'secondary' + } +} +''') + def impl = service.classLoader.loadClass('$GadgetServiceImplementation') + def instance = impl.getDeclaredConstructor().newInstance() + + then: 'calling save throws IllegalStateException (no GORM backend) rather than routing to wrong datasource' + // This confirms the generated code attempts to use GormEnhancer APIs + // (which require an initialized datastore), rather than calling entity.save() directly + when: + instance.save(service.classLoader.loadClass('Gadget').getDeclaredConstructor().newInstance()) + + then: + thrown(IllegalStateException) + + when: + instance.find(1L) + + then: + thrown(IllegalStateException) + + when: + instance.delete(1L) + + then: + thrown(IllegalStateException) + } + +} diff --git a/grails-test-examples/hibernate5/grails-data-service-multi-datasource/build.gradle b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/build.gradle new file mode 100644 index 00000000000..54947de192d --- /dev/null +++ b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/build.gradle @@ -0,0 +1,49 @@ +/* + * 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. + */ + +plugins { + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.gradle.grails-web' + id 'org.apache.grails.buildsrc.compile' +} + +version = projectVersion +group = 'examples' + +dependencies { + implementation platform(project(':grails-bom')) + + implementation 'org.apache.grails:grails-data-hibernate5' + implementation 'org.apache.grails:grails-core' + + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.zaxxer:HikariCP' + runtimeOnly 'org.apache.grails:grails-databinding' + runtimeOnly 'org.apache.grails:grails-services' + runtimeOnly 'org.springframework.boot:spring-boot-autoconfigure' + runtimeOnly 'org.springframework.boot:spring-boot-starter-logging' + runtimeOnly 'org.springframework.boot:spring-boot-starter-tomcat' + + integrationTestImplementation 'org.apache.grails.testing:grails-testing-support-core' +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') +} diff --git a/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/conf/application.yml b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/conf/application.yml new file mode 100644 index 00000000000..d942b19b728 --- /dev/null +++ b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/conf/application.yml @@ -0,0 +1,58 @@ +# 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. + +info: + app: + name: '@info.app.name@' + version: '@info.app.version@' + grailsVersion: '@info.app.grailsVersion@' +--- +grails: + profile: web + codegen: + defaultPackage: example + mime: + disable: + accept: + header: + userAgents: + - Gecko + - WebKit + - Presto + - Trident + types: + all: '*/*' + html: + - text/html + - application/xhtml+xml + json: + - application/json + - text/json + text: text/plain + xml: + - text/xml + - application/xml +--- +dataSources: + dataSource: + pooled: true + driverClassName: org.h2.Driver + dbCreate: create-drop + url: jdbc:h2:mem:defaultDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE + secondary: + pooled: true + driverClassName: org.h2.Driver + dbCreate: create-drop + url: jdbc:h2:mem:secondaryDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE diff --git a/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/conf/logback.xml b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/conf/logback.xml new file mode 100644 index 00000000000..ca693c3d9ef --- /dev/null +++ b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/conf/logback.xml @@ -0,0 +1,37 @@ + + + + + + + + + + UTF-8 + %clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n%wex + + + + + + + diff --git a/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/domain/example/Product.groovy b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/domain/example/Product.groovy new file mode 100644 index 00000000000..0b8a8b7fdd3 --- /dev/null +++ b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/domain/example/Product.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. + */ + +package example + +import org.grails.datastore.gorm.GormEntity + +/** + * Domain class mapped exclusively to the 'secondary' datasource. + * Used to test that GORM Data Service auto-implemented CRUD methods + * route correctly when @Transactional(connection) is specified. + */ +class Product implements GormEntity { + + String name + Integer amount + + static mapping = { + datasource 'secondary' + } + + static constraints = { + name blank: false + } +} diff --git a/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/init/example/Application.groovy b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/init/example/Application.groovy new file mode 100644 index 00000000000..9bc814ca6c7 --- /dev/null +++ b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/init/example/Application.groovy @@ -0,0 +1,31 @@ +/* + * 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.boot.GrailsApp +import grails.boot.config.GrailsAutoConfiguration +import groovy.transform.CompileStatic + +@CompileStatic +class Application extends GrailsAutoConfiguration { + static void main(String[] args) { + GrailsApp.run(Application) + } +} diff --git a/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/services/example/ProductService.groovy b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/services/example/ProductService.groovy new file mode 100644 index 00000000000..6125988375b --- /dev/null +++ b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/grails-app/services/example/ProductService.groovy @@ -0,0 +1,48 @@ +/* + * 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 +import grails.gorm.transactions.Transactional + +/** + * GORM Data Service for the Product domain, routed to the 'secondary' + * datasource via @Transactional(connection). + * + * All auto-implemented methods (save, get, delete, findByName, count) + * should route through the connection-aware GormEnhancer APIs rather + * than falling through to the default datasource. + */ +@Service(Product) +@Transactional(connection = 'secondary') +abstract class ProductService { + + 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 findAllByName(String name) +} diff --git a/grails-test-examples/hibernate5/grails-data-service-multi-datasource/src/integration-test/groovy/functionaltests/DataServiceMultiDataSourceSpec.groovy b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/src/integration-test/groovy/functionaltests/DataServiceMultiDataSourceSpec.groovy new file mode 100644 index 00000000000..c1e4ed53399 --- /dev/null +++ b/grails-test-examples/hibernate5/grails-data-service-multi-datasource/src/integration-test/groovy/functionaltests/DataServiceMultiDataSourceSpec.groovy @@ -0,0 +1,134 @@ +/* + * 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.ProductService + +import org.springframework.beans.factory.annotation.Autowired + +import grails.testing.mixin.integration.Integration +import org.grails.orm.hibernate.HibernateDatastore +import spock.lang.Specification + +/** + * Integration test verifying that GORM Data Service auto-implemented + * CRUD methods (save, get, delete, findByName, count) route correctly + * to a non-default datasource when @Transactional(connection) is + * specified on the service. + * + * Product is mapped exclusively to the 'secondary' datasource. + * Without the connection-routing fix, auto-implemented save/get/delete + * would use the default datasource where no Product table exists. + * + * The service is obtained from the secondary child datastore + * (not auto-wired by Spring) to ensure proper session binding. + */ +@Integration +class DataServiceMultiDataSourceSpec extends Specification { + + @Autowired + HibernateDatastore hibernateDatastore + + ProductService productService + + void setup() { + productService = hibernateDatastore + .getDatastoreForConnection('secondary') + .getService(ProductService) + } + + void cleanup() { + Product.secondary.withTransaction { + Product.secondary.executeUpdate('delete from Product') + } + } + + void "save routes to secondary datasource"() { + when: + def saved = productService.save(new Product(name: 'Widget', amount: 42)) + + then: + saved != null + saved.id != null + saved.name == 'Widget' + saved.amount == 42 + } + + void "get by ID routes to secondary datasource"() { + given: + def saved = productService.save(new Product(name: 'Gadget', amount: 99)) + + when: + def found = productService.get(saved.id) + + then: + found != null + found.id == saved.id + found.name == 'Gadget' + found.amount == 99 + } + + void "count routes to secondary datasource"() { + given: + productService.save(new Product(name: 'Alpha', amount: 10)) + productService.save(new Product(name: 'Beta', amount: 20)) + + expect: + productService.count() == 2 + } + + void "delete routes to secondary datasource"() { + given: + def saved = productService.save(new Product(name: 'Ephemeral', amount: 1)) + + when: + productService.delete(saved.id) + + then: + productService.get(saved.id) == null + } + + void "findByName routes to secondary datasource"() { + given: + productService.save(new Product(name: 'Unique', amount: 77)) + + when: + def found = productService.findByName('Unique') + + then: + found != null + found.name == 'Unique' + found.amount == 77 + } + + void "findAllByName routes to secondary datasource"() { + given: + productService.save(new Product(name: 'Duplicate', amount: 10)) + productService.save(new Product(name: 'Duplicate', amount: 20)) + productService.save(new Product(name: 'Other', amount: 30)) + + when: + def found = productService.findAllByName('Duplicate') + + then: + found.size() == 2 + found.every { it.name == 'Duplicate' } + } +} diff --git a/settings.gradle b/settings.gradle index 0509aef0414..1eff5b64a02 100644 --- a/settings.gradle +++ b/settings.gradle @@ -275,6 +275,9 @@ project(':grails-test-examples-hibernate5-spring-boot-hibernate').projectDir = n include 'grails-test-examples-hibernate5-grails-data-service' project(':grails-test-examples-hibernate5-grails-data-service').projectDir = new File(settingsDir, 'grails-test-examples/hibernate5/grails-data-service') +include 'grails-test-examples-hibernate5-grails-data-service-multi-datasource' +project(':grails-test-examples-hibernate5-grails-data-service-multi-datasource').projectDir = new File(settingsDir, 'grails-test-examples/hibernate5/grails-data-service-multi-datasource') + include 'grails-test-examples-hibernate5-grails-hibernate-groovy-proxy' project(':grails-test-examples-hibernate5-grails-hibernate-groovy-proxy').projectDir = new File(settingsDir, 'grails-test-examples/hibernate5/grails-hibernate-groovy-proxy')