This is an automated email from the ASF dual-hosted git repository. jamesfredley pushed a commit to branch test/query-connection-routing in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit fe158099bcdc54d3940c9fd7980d92a1f3c486c5 Author: James Fredley <[email protected]> AuthorDate: Fri Feb 20 12:00:30 2026 -0500 test: add @Query Data Service connection routing tests Add unit and functional tests verifying that @Query-annotated Data Service methods (find-one, find-all, update) correctly route to non-default datasources when @Transactional(connection) is specified. Tests cover both abstract class and interface service patterns using FindOneStringQueryImplementer, FindAllStringQueryImplementer, and UpdateStringQueryImplementer - previously untested code paths. Assisted-by: Claude Code <[email protected]> --- .../DataServiceMultiDataSourceSpec.groovy | 115 +++++++++++++++++++-- .../services/example/ProductService.groovy | 18 ++-- .../DataServiceMultiDataSourceSpec.groovy | 59 ++++++++--- 3 files changed, 162 insertions(+), 30 deletions(-) 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 index b7177c772b..873f93313b 100644 --- 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 @@ -24,6 +24,7 @@ import spock.lang.Shared import spock.lang.Specification import grails.gorm.annotation.Entity +import grails.gorm.services.Query import grails.gorm.services.Service import grails.gorm.transactions.Transactional import org.grails.datastore.gorm.GormEnhancer @@ -298,6 +299,93 @@ class DataServiceMultiDataSourceSpec extends Specification { productService.count() == productDataService.count() } + void "@Query find-one routes to books datasource - abstract service"() { + given: 'a product saved on books' + productService.save(new Product(name: 'QueryOne', amount: 50)) + + when: 'we find one by HQL query' + def found = productService.findOneByQuery('QueryOne') + + then: 'the correct entity is returned from books' + found != null + found.name == 'QueryOne' + found.amount == 50 + } + + void "@Query find-one returns null for non-existent - abstract service"() { + expect: 'null for non-existent product' + productService.findOneByQuery('NonExistent') == null + } + + void "@Query find-all routes to books datasource - abstract service"() { + given: 'products saved on books with varying amounts' + productService.save(new Product(name: 'Expensive1', amount: 500)) + productService.save(new Product(name: 'Expensive2', amount: 600)) + productService.save(new Product(name: 'Cheap1', amount: 10)) + + when: 'we find all by HQL query with threshold' + def found = productService.findAllByQuery(400) + + then: 'only matching products from books are returned' + found.size() == 2 + found*.name.containsAll(['Expensive1', 'Expensive2']) + } + + void "@Query update routes to books datasource - abstract service"() { + given: 'a product saved on books' + productService.save(new Product(name: 'UpdateTarget', amount: 100)) + + when: 'we update amount by HQL query' + def updated = productService.updateAmountByName('UpdateTarget', 999) + + then: 'one row updated' + updated == 1 + + and: 'the change is reflected on books' + productService.findByName('UpdateTarget').amount == 999 + } + + void "@Query find-one routes to books datasource - interface service"() { + given: 'a product saved on books' + productService.save(new Product(name: 'InterfaceQueryOne', amount: 75)) + + when: 'we find one by HQL query through the interface service' + def found = productDataService.findOneByQuery('InterfaceQueryOne') + + then: 'the correct entity is returned from books' + found != null + found.name == 'InterfaceQueryOne' + found.amount == 75 + } + + void "@Query find-all routes to books datasource - interface service"() { + given: 'products saved on books' + productService.save(new Product(name: 'IfaceExpensive1', amount: 500)) + productService.save(new Product(name: 'IfaceExpensive2', amount: 600)) + productService.save(new Product(name: 'IfaceCheap1', amount: 10)) + + when: 'we find all by HQL query through the interface service' + def found = productDataService.findAllByQuery(400) + + then: 'only matching products from books are returned' + found.size() == 2 + found*.name.containsAll(['IfaceExpensive1', 'IfaceExpensive2']) + } + + void "@Query update routes to books datasource - interface service"() { + given: 'a product saved on books' + productService.save(new Product(name: 'InterfaceUpdate', amount: 100)) + + when: 'we update amount by HQL query through the interface service' + def updated = productDataService.updateAmountByName('InterfaceUpdate', 888) + + then: 'one row updated' + updated == 1 + + and: 'the change is reflected on books' + productDataService.findByName('InterfaceUpdate').amount == 888 + } + } @Entity @@ -333,18 +421,18 @@ abstract class ProductService { abstract List<Product> 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) + + @Query("from ${Product p} where $p.name = $name") + abstract Product findOneByQuery(String name) + + @Query("from ${Product p} where $p.amount >= $minAmount") + abstract List<Product> findAllByQuery(Integer minAmount) + + @Query("update ${Product p} set $p.amount = $newAmount where $p.name = $name") + abstract Number updateAmountByName(String name, Integer newAmount) } -/** - * 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 { @@ -362,4 +450,13 @@ interface ProductDataService { Product findByName(String name) List<Product> findAllByName(String name) + + @Query("from ${Product p} where $p.name = $name") + Product findOneByQuery(String name) + + @Query("from ${Product p} where $p.amount >= $minAmount") + List<Product> findAllByQuery(Integer minAmount) + + @Query("update ${Product p} set $p.amount = $newAmount where $p.name = $name") + Number updateAmountByName(String name, Integer newAmount) } 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 index 6125988375..53f42bb80a 100644 --- 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 @@ -19,17 +19,10 @@ package example +import grails.gorm.services.Query 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 { @@ -45,4 +38,13 @@ abstract class ProductService { abstract Product findByName(String name) abstract List<Product> findAllByName(String name) + + @Query("from ${Product p} where $p.name = $name") + abstract Product findOneByQuery(String name) + + @Query("from ${Product p} where $p.amount >= $minAmount") + abstract List<Product> findAllByQuery(Integer minAmount) + + @Query("update ${Product p} set $p.amount = $newAmount where $p.name = $name") + abstract Number updateAmountByName(String name, Integer newAmount) } 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 index c1e4ed5339..ccdc2c31cf 100644 --- 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 @@ -27,19 +27,6 @@ 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 { @@ -131,4 +118,50 @@ class DataServiceMultiDataSourceSpec extends Specification { found.size() == 2 found.every { it.name == 'Duplicate' } } + + void "@Query find-one routes to secondary datasource"() { + given: + productService.save(new Product(name: 'QueryOne', amount: 50)) + + when: + def found = productService.findOneByQuery('QueryOne') + + then: + found != null + found.name == 'QueryOne' + found.amount == 50 + } + + void "@Query find-one returns null for non-existent"() { + expect: + productService.findOneByQuery('NonExistent') == null + } + + void "@Query find-all routes to secondary datasource"() { + given: + productService.save(new Product(name: 'Expensive1', amount: 500)) + productService.save(new Product(name: 'Expensive2', amount: 600)) + productService.save(new Product(name: 'Cheap1', amount: 10)) + + when: + def found = productService.findAllByQuery(400) + + then: + found.size() == 2 + found*.name.containsAll(['Expensive1', 'Expensive2']) + } + + void "@Query update routes to secondary datasource"() { + given: + productService.save(new Product(name: 'UpdateTarget', amount: 100)) + + when: + def updated = productService.updateAmountByName('UpdateTarget', 999) + + then: + updated == 1 + + and: + productService.findByName('UpdateTarget').amount == 999 + } }
