This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch test/tck-data-service-connection-routing
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit b10d2bc0604bf416b64e2e96fa1f378810fa67b3
Author: James Fredley <[email protected]>
AuthorDate: Sat Feb 21 08:41:50 2026 -0500

    test(tck): add Data Service connection routing spec to TCK
    
    Port the multi-datasource Data Service CRUD routing tests from the
    Hibernate5-specific DataServiceMultiDataSourceSpec (PR #15395) into the
    datastore-agnostic TCK so all GORM implementations can validate
    connection routing for @Service + @Transactional(connection).
    
    Tests cover save, get, delete (both FindAndDeleteImplementer and
    DeleteImplementer), count, findByName, findAllByName, constructor-style
    save, round-trip operations, and data isolation between datasources.
    Both abstract class and interface Data Service patterns are tested.
    
    Assisted-by: Claude Code <[email protected]>
---
 .../tck/domains/DataServiceRoutingProduct.groovy   |  39 +++
 .../DataServiceRoutingProductDataService.groovy    |  41 +++
 .../DataServiceRoutingProductService.groovy        |  43 +++
 .../tests/DataServiceConnectionRoutingSpec.groovy  | 292 +++++++++++++++++++++
 4 files changed, 415 insertions(+)

diff --git 
a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/DataServiceRoutingProduct.groovy
 
b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/DataServiceRoutingProduct.groovy
new file mode 100644
index 0000000000..438ef782f5
--- /dev/null
+++ 
b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/DataServiceRoutingProduct.groovy
@@ -0,0 +1,39 @@
+/*
+ * 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.apache.grails.data.testing.tck.domains
+
+import grails.gorm.annotation.Entity
+import org.grails.datastore.gorm.GormEntity
+
+@Entity
+class DataServiceRoutingProduct implements 
GormEntity<DataServiceRoutingProduct> {
+
+    Long id
+    Long version
+    String name
+    Integer amount
+
+    static mapping = {
+        datasource 'ALL'
+    }
+
+    static constraints = {
+        name blank: false
+    }
+}
diff --git 
a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/DataServiceRoutingProductDataService.groovy
 
b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/DataServiceRoutingProductDataService.groovy
new file mode 100644
index 0000000000..2c869c3ffe
--- /dev/null
+++ 
b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/DataServiceRoutingProductDataService.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 org.apache.grails.data.testing.tck.domains
+
+import grails.gorm.services.Service
+import grails.gorm.transactions.Transactional
+
+@Service(DataServiceRoutingProduct)
+@Transactional(connection = 'secondary')
+interface DataServiceRoutingProductDataService {
+
+    DataServiceRoutingProduct get(Serializable id)
+
+    DataServiceRoutingProduct save(DataServiceRoutingProduct product)
+
+    DataServiceRoutingProduct delete(Serializable id)
+
+    void deleteProduct(Serializable id)
+
+    Number count()
+
+    DataServiceRoutingProduct findByName(String name)
+
+    List<DataServiceRoutingProduct> findAllByName(String name)
+}
diff --git 
a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/DataServiceRoutingProductService.groovy
 
b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/DataServiceRoutingProductService.groovy
new file mode 100644
index 0000000000..903ab88d8c
--- /dev/null
+++ 
b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/DataServiceRoutingProductService.groovy
@@ -0,0 +1,43 @@
+/*
+ * 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.apache.grails.data.testing.tck.domains
+
+import grails.gorm.services.Service
+import grails.gorm.transactions.Transactional
+
+@Service(DataServiceRoutingProduct)
+@Transactional(connection = 'secondary')
+abstract class DataServiceRoutingProductService {
+
+    abstract DataServiceRoutingProduct get(Serializable id)
+
+    abstract DataServiceRoutingProduct save(DataServiceRoutingProduct product)
+
+    abstract DataServiceRoutingProduct delete(Serializable id)
+
+    abstract void deleteProduct(Serializable id)
+
+    abstract Number count()
+
+    abstract DataServiceRoutingProduct findByName(String name)
+
+    abstract List<DataServiceRoutingProduct> findAllByName(String name)
+
+    abstract DataServiceRoutingProduct saveProduct(String name, Integer amount)
+}
diff --git 
a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/DataServiceConnectionRoutingSpec.groovy
 
b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/DataServiceConnectionRoutingSpec.groovy
new file mode 100644
index 0000000000..261f430156
--- /dev/null
+++ 
b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/DataServiceConnectionRoutingSpec.groovy
@@ -0,0 +1,292 @@
+/*
+ * 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.apache.grails.data.testing.tck.tests
+
+import spock.lang.Issue
+import spock.lang.Requires
+
+import org.grails.datastore.gorm.GormEnhancer
+
+import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec
+import org.apache.grails.data.testing.tck.domains.DataServiceRoutingProduct
+import 
org.apache.grails.data.testing.tck.domains.DataServiceRoutingProductDataService
+import 
org.apache.grails.data.testing.tck.domains.DataServiceRoutingProductService
+
+@Issue('https://github.com/apache/grails-core/issues/15394')
+@Requires({ instance.manager?.supportsMultipleDataSources() })
+class DataServiceConnectionRoutingSpec extends GrailsDataTckSpec {
+
+    DataServiceRoutingProductService productService
+    DataServiceRoutingProductDataService productDataService
+
+    void setup() {
+        manager.setupMultiDataSource(DataServiceRoutingProduct)
+        productService = 
manager.getServiceForConnection(DataServiceRoutingProductService, 'secondary')
+        productDataService = 
manager.getServiceForConnection(DataServiceRoutingProductDataService, 
'secondary')
+    }
+
+    void cleanup() {
+        deleteAllFromConnection('secondary')
+        deleteAllFromConnection(null)
+        manager.cleanupMultiDataSource()
+    }
+
+    // ---- Abstract class service tests ----
+
+    void "save routes to secondary datasource"() {
+        when: 'a product is saved through the abstract Data Service'
+        def saved = productService.save(new DataServiceRoutingProduct(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 secondary datasource'
+        countOnConnection('secondary') == 1
+    }
+
+    void "get by ID routes to secondary datasource"() {
+        given: 'a product saved on secondary'
+        def saved = productService.save(new DataServiceRoutingProduct(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 secondary datasource"() {
+        given: 'two products saved on secondary'
+        productService.save(new DataServiceRoutingProduct(name: 'Alpha', 
amount: 10))
+        productService.save(new DataServiceRoutingProduct(name: 'Beta', 
amount: 20))
+
+        and: 'a product saved on default that should not be counted'
+        saveToConnection(null, 'DefaultOnly', 99)
+
+        expect: 'count returns only secondary items'
+        productService.count() == 2
+    }
+
+    void "delete by ID routes to secondary datasource - 
FindAndDeleteImplementer"() {
+        given: 'a product saved on secondary'
+        def saved = productService.save(new DataServiceRoutingProduct(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 secondary datasource - DeleteImplementer"() {
+        given: 'a product saved on secondary'
+        def saved = productService.save(new DataServiceRoutingProduct(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 secondary datasource"() {
+        given: 'products saved on secondary'
+        productService.save(new DataServiceRoutingProduct(name: 'Unique', 
amount: 77))
+        productService.save(new DataServiceRoutingProduct(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 secondary datasource"() {
+        given: 'products with duplicate names on secondary'
+        productService.save(new DataServiceRoutingProduct(name: 'Duplicate', 
amount: 10))
+        productService.save(new DataServiceRoutingProduct(name: 'Duplicate', 
amount: 20))
+        productService.save(new DataServiceRoutingProduct(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 "constructor-style save routes to secondary datasource"() {
+        when: 'a product is saved using property arguments'
+        def saved = productService.saveProduct('Constructed', 55)
+
+        then: 'it is persisted on secondary'
+        saved != null
+        saved.id != null
+        saved.name == 'Constructed'
+        saved.amount == 55
+
+        and: 'retrievable'
+        productService.get(saved.id) != null
+    }
+
+    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 DataServiceRoutingProduct(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
+    }
+
+    // ---- Interface service tests ----
+
+    void "interface service: save routes to secondary datasource"() {
+        when: 'a product is saved through the interface Data Service'
+        def saved = productDataService.save(new 
DataServiceRoutingProduct(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 secondary datasource'
+        countOnConnection('secondary') == 1
+    }
+
+    void "interface service: get by ID routes to secondary datasource"() {
+        given: 'a product saved on secondary via abstract service'
+        def saved = productService.save(new DataServiceRoutingProduct(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 secondary datasource"() {
+        given: 'a product saved on secondary'
+        def saved = productService.save(new DataServiceRoutingProduct(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 secondary datasource"() {
+        given: 'a product saved on secondary'
+        def saved = productService.save(new DataServiceRoutingProduct(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 DataServiceRoutingProduct(name: 
'CrossService', amount: 77))
+
+        expect: 'the interface service can find it'
+        productDataService.findByName('CrossService') != null
+        productDataService.findByName('CrossService').id == saved.id
+
+        and: 'counts match across both service patterns'
+        productService.count() == productDataService.count()
+    }
+
+    void "secondary data is not visible on default datasource"() {
+        given: 'a product saved on secondary'
+        productService.save(new DataServiceRoutingProduct(name: 
'SecondaryOnly', amount: 42))
+
+        expect: 'it is not visible on the default datasource'
+        countOnConnection(null) == 0
+    }
+
+    void "default data is not visible on secondary datasource"() {
+        given: 'a product saved on default'
+        saveToConnection(null, 'DefaultOnly', 42)
+
+        expect: 'it is not visible through the secondary-bound service'
+        productService.count() == 0
+        productService.findByName('DefaultOnly') == null
+    }
+
+    // ---- Helper methods ----
+
+    private void saveToConnection(String connectionName, String name, Integer 
amount) {
+        def api = connectionName
+                ? GormEnhancer.findStaticApi(DataServiceRoutingProduct, 
connectionName)
+                : GormEnhancer.findStaticApi(DataServiceRoutingProduct)
+        api.withNewTransaction {
+            def instanceApi = connectionName
+                    ? GormEnhancer.findInstanceApi(DataServiceRoutingProduct, 
connectionName)
+                    : GormEnhancer.findInstanceApi(DataServiceRoutingProduct)
+            def item = new DataServiceRoutingProduct(name: name, amount: 
amount)
+            instanceApi.save(item, [flush: true])
+        }
+    }
+
+    private long countOnConnection(String connectionName) {
+        def api = connectionName
+                ? GormEnhancer.findStaticApi(DataServiceRoutingProduct, 
connectionName)
+                : GormEnhancer.findStaticApi(DataServiceRoutingProduct)
+        api.withNewTransaction {
+            api.count()
+        }
+    }
+
+    private void deleteAllFromConnection(String connectionName) {
+        def api = connectionName
+                ? GormEnhancer.findStaticApi(DataServiceRoutingProduct, 
connectionName)
+                : GormEnhancer.findStaticApi(DataServiceRoutingProduct)
+        def instanceApi = connectionName
+                ? GormEnhancer.findInstanceApi(DataServiceRoutingProduct, 
connectionName)
+                : GormEnhancer.findInstanceApi(DataServiceRoutingProduct)
+        api.withNewTransaction {
+            for (item in api.list()) {
+                instanceApi.delete(item, [flush: true])
+            }
+        }
+    }
+}

Reply via email to