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
+    }
 }

Reply via email to