fix: Auto-implemented Data Service CRUD methods ignore @Transactional(connection)#15395
fix: Auto-implemented Data Service CRUD methods ignore @Transactional(connection)#15395jamesfredley wants to merge 8 commits intoapache:7.0.xfrom
Conversation
SaveImplementer, DeleteImplementer, and AbstractDetachedCriteriaServiceImplementor generated code that called entity.save(), obj.delete(), and DomainClass.get() directly, bypassing the connection routing specified by @transactional(connection). The finder implementers (FindAllByImplementer, FindOneByImplementer) already used findStaticApiForConnectionId() to route through GormEnhancer.findStaticApi() with the correct connection qualifier. This fix applies the same pattern to save, delete, and get-by-id operations: - SaveImplementer: single-entity save now routes through GormEnhancer.findInstanceApi(DomainClass, connectionId).save() when a connection qualifier is present. (The multi-param constructor save in AbstractSaveImplementer already had this fix.) - DeleteImplementer.implementById: delete now routes through GormEnhancer.findInstanceApi(DomainClass, connectionId).delete() when a connection qualifier is present. - AbstractDetachedCriteriaServiceImplementor: get-by-id optimization now routes through GormEnhancer.findStaticApi(DomainClass, connectionId).get() when a connection qualifier is present. (The detached criteria query path already called withConnection() correctly.) Fixes: auto-implemented Data Service CRUD methods ignore @transactional(connection = 'secondary') and always route to the default datasource.
…d tests FindAndDeleteImplementer.buildReturnStatement() called entity.delete() directly, bypassing connection routing — same class of bug as SaveImplementer, DeleteImplementer, and AbstractDetachedCriteriaServiceImplementor. Now checks findConnectionId() and routes through GormEnhancer.findInstanceApi(domainClass, connectionId).delete() when a connection qualifier is present. Added ConnectionRoutingServiceTransformSpec with 6 Spock tests covering: - save (single-entity + multi-param) with @transactional(connection) - delete by id (void, Number, domain-returning) with connection - find/get by id with connection - interface service with connection (all CRUD methods) - regression: service without connection still compiles correctly - runtime: connection-routed methods invoke GormEnhancer APIs
...pping-core/src/test/groovy/grails/gorm/services/ConnectionRoutingServiceTransformSpec.groovy
Show resolved
Hide resolved
...pping-core/src/test/groovy/grails/gorm/services/ConnectionRoutingServiceTransformSpec.groovy
Show resolved
Hide resolved
|
We should add the example app scenario as a functional test |
…connection routing Add 11 Spock tests (DataServiceMultiDataSourceSpec) covering GORM Data Service auto-implemented CRUD methods routing to a non-default datasource via @transactional(connection). Tests save, get, delete (both FindAndDeleteImplementer and DeleteImplementer), count, findByName, findAllByName, constructor-style save, and round-trip operations. Add functional test app grails-data-service-multi-datasource with 6 integration tests exercising the same Data Service patterns in a full Grails application context with two H2 datasources. Assisted-by: OpenCode <opencode@opencode.ai> Assisted-by: Claude Opus 4 <claude@anthropic.com>
Add logback.xml matching the pattern used by other hibernate5 functional test apps for consistent logging configuration. Assisted-by: OpenCode <opencode@opencode.ai> Assisted-by: Claude <claude@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes GORM Data Service auto-implemented CRUD methods so @Transactional(connection = ...) is honored, ensuring CRUD routes through connection-aware GormEnhancer APIs instead of default-datasource instance/static calls.
Changes:
- Update Data Service implementers (save/delete/get-by-id) to route via connection-qualified
GormEnhancerAPIs. - Add new AST/unit and Hibernate integration tests covering multi-datasource CRUD routing.
- Add a new functional test example app/module demonstrating multi-datasource Data Service behavior.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
settings.gradle |
Adds the new multi-datasource data-service example subproject. |
grails-test-examples/hibernate5/grails-data-service-multi-datasource/** |
New example Grails app + integration spec validating CRUD routing to a secondary datasource. |
grails-datamapping-core/src/main/groovy/.../SaveImplementer.groovy |
Routes single-entity save() through connection-qualified instance API. |
grails-datamapping-core/src/main/groovy/.../DeleteImplementer.groovy |
Routes delete-by-id through connection-qualified instance API. |
grails-datamapping-core/src/main/groovy/.../FindAndDeleteImplementer.groovy |
Routes delete call through connection-qualified instance API for find-and-delete. |
grails-datamapping-core/src/main/groovy/.../AbstractDetachedCriteriaServiceImplementor.groovy |
Routes get-by-id optimization through connection-qualified static API. |
grails-datamapping-core/src/test/groovy/.../ConnectionRoutingServiceTransformSpec.groovy |
New Spock spec validating implementer selection + connection annotation propagation. |
grails-data-hibernate5/core/src/test/groovy/.../DataServiceMultiDataSourceSpec.groovy |
New integration test validating runtime CRUD routing against a secondary datasource. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
...-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/SaveImplementer.groovy
Show resolved
Hide resolved
...ore/src/main/groovy/org/grails/datastore/gorm/services/implementers/DeleteImplementer.groovy
Show resolved
Hide resolved
.../main/groovy/org/grails/datastore/gorm/services/implementers/FindAndDeleteImplementer.groovy
Show resolved
Hide resolved
...pping-core/src/test/groovy/grails/gorm/services/ConnectionRoutingServiceTransformSpec.groovy
Show resolved
Hide resolved
…e TCK tests Harmonize findConnectionId() to consistently use newMethodNode across SaveImplementer, DeleteImplementer, and AbstractSaveImplementer for consistency with AbstractDetachedCriteriaServiceImplementor. Add interface-pattern ProductDataService to TCK tests to verify connection routing works identically for both abstract class and interface Data Service declarations. Add cross-service sharing test. Assisted-by: OpenCode <opencode@opencode.ai>
…iTenant routing, and CRUD connection fixes Add documentation reflecting the post-fix state after PRs apache#15393, apache#15395, and apache#15396 are merged: - Add @CompileStatic + injected @service property example (PR apache#15396) - Add Multi-Tenancy with explicit datasource section (PR apache#15393) - List all CRUD methods that respect connection routing (PR apache#15395) - Soften IMPORTANT boxes to NOTE with authoritative tone Assisted-by: Claude Code <Claude@Claude.ai>
@transactional annotations findConnectionId resolves @transactional(connection) by falling back to methodNode.getDeclaringClass(). The abstractMethodNode declares on the original interface/abstract class which carries the annotation, while newMethodNode declares on the generated impl class which does not. Using newMethodNode caused findConnectionId to return null, silently routing all CRUD operations to the default datasource instead of the specified connection. Assisted-by: Claude Code <Claude@Claude.ai>
|
#15395 (comment) led me in the wrong direction and caused a legitimate test failure that was only caught in the joint validation build (had nothing to do with groovy version). I am skeptical of the github copilot PR reviews, but this one looked correct, but was not. @jdaugherty @matrei Sorry this will need another review. |
Summary
Auto-implemented CRUD methods on GORM Data Services (
save(),delete(),get()) ignore@Transactional(connection = 'secondary')and always route to the default datasource.The finder implementers (
FindAllByImplementer,FindOneByImplementer) already correctly usefindStaticApiForConnectionId()to route throughGormEnhancer.findStaticApi()with the correct connection qualifier. However,SaveImplementer,DeleteImplementer,FindAndDeleteImplementer, and the get-by-id optimization inAbstractDetachedCriteriaServiceImplementorbypass this mechanism entirely and call instance/static methods directly on the domain class.Bug Description
Given a Data Service with
@Transactional(connection = 'secondary'):findAllBy*()- correctly routes to secondary (usesfindStaticApiForConnectionId)save(Metric m)- routes to DEFAULT (callsentity.save()directly)delete(Serializable id)returning void/Number - routes to DEFAULT (callsobj.delete()directly)delete(Serializable id)returning domain type - routes to DEFAULT (FindAndDeleteImplementer callsobj.delete()directly)get(Serializable id)- routes to DEFAULT (callsDomainClass.get(id)directly)Both patterns are affected:
@Service+@Transactional(connection)implementing a separate interface@Service+@Transactional(connection)directly on the interface (no abstract class)Root Cause
Four code paths in the auto-implementers skip connection routing:
SaveImplementer.doImplement()- single-entity parameter path generatesentity.save(failOnError: true), which goes throughGormEntity.save()->GormEnhancer.findInstanceApi(class)without a connection qualifier.AbstractDetachedCriteriaServiceImplementor.doImplement()- the "optimize by id" path generatesDomainClass.get(id), a static call that routes to the default datastore. (The detached criteria query path already callsfindConnectionId()+withConnection()correctly.)DeleteImplementer.implementById()- generatesobj.delete(), which goes throughGormEntity.delete()-> default instance API.FindAndDeleteImplementer.buildReturnStatement()- generatesobj?.delete(), which goes throughGormEntity.delete()-> default instance API. (The find part routes correctly viaAbstractDetachedCriteriaServiceImplementor, but the delete call bypasses connection routing.)Note:
AbstractSaveImplementer.bindParametersAndSave()(the multi-parameter constructor-style save) already had the correctfindConnectionId()check - only the single-entity path inSaveImplementerwas missing it.Fix
Apply the same
findConnectionId()pattern already used byFindAllByImplementerandAbstractSaveImplementer.bindParametersAndSave():SaveImplementer: WhenfindConnectionId()returns a connection, route throughGormEnhancer.findInstanceApi(DomainClass, connectionId).save(entity, args)instead ofentity.save(args)AbstractDetachedCriteriaServiceImplementor: WhenfindConnectionId()returns a connection, route get-by-id throughGormEnhancer.findStaticApi(DomainClass, connectionId).get(id)instead ofDomainClass.get(id)DeleteImplementer: WhenfindConnectionId()returns a connection, route throughGormEnhancer.findInstanceApi(DomainClass, connectionId).delete(entity)instead ofentity.delete()FindAndDeleteImplementer: WhenfindConnectionId()returns a connection, route delete throughGormEnhancer.findInstanceApi(DomainClass, connectionId).delete(entity)with a null-check guard instead ofentity?.delete()Additionally, harmonized
findConnectionId()to consistently usenewMethodNode(the generated implementation method) acrossSaveImplementer,DeleteImplementer, andAbstractSaveImplementer, matching the convention already used byAbstractDetachedCriteriaServiceImplementor. Also renamed theabstractMethodNodeparameter inAbstractSaveImplementer.bindParametersAndSave()tonewMethodNodefor consistency.Test Coverage
Unit Tests - ConnectionRoutingServiceTransformSpec (6 tests)
@Transactional(connection='secondary'), verifiessave(Foo)andsaveFoo(String)compile with correct implementers and connection annotation propagateddelete(Serializable)returning domain type usesFindAndDeleteImplementer,void delete(Serializable)usesDeleteImplementer,Number delete(String)usesDeleteImplementerfind(Serializable)andget(Serializable)useFindOneImplementerwith connection propagated@Transactional(connection)propagation@Transactional(connection)still compiles all CRUD correctlyIllegalStateException(proving they invokeGormEnhancerAPIs that require an initialized datastore, rather than callingentity.save()/entity.delete()directly)TCK Integration Tests - DataServiceMultiDataSourceSpec (16 tests)
Added to
grails-data-hibernate5with a real HibernateDatastore, two H2 in-memory databases (default + books), and aProductdomain mapped exclusively to the 'books' datasource:Abstract class pattern (ProductService):
Interface-only pattern (ProductDataService):
8. Interface service: save routes to books datasource
9. Interface service: get by ID routes to books datasource
10. Interface service: delete routes to books datasource
11. Interface service: void delete routes to books datasource
12-16. Cross-service sharing, findByName, count, findAll, list operations
Functional Test App
A standalone Grails functional test app in
grails-test-examples/datasources/multi-datasource-data-service/with full boot-up and HTTP endpoint testing.All tests pass. All 30 existing
ServiceTransformSpectests pass (no regressions).Example Application
https://github.com/jamesfredley/grails-auto-crud-datasource-routing
A minimal Grails 7 app with two H2 in-memory databases that proves the bug. The METRIC table exists only on the secondary database. Two Data Service patterns are tested:
MetricService- abstract class with@Transactional(connection = 'secondary')implementingMetricDataServiceinterfaceMetricInterfaceOnlyDataService- interface-only with@Service(Metric)+@Transactional(connection = 'secondary')directly on the interface, no abstract classBoth patterns demonstrate the same bug: auto-implemented
save()tries to write to primary (where there's no METRIC table), causing a SQL error.Run
./gradlew bootRunand visithttp://localhost:8080/bugDemo/indexto see the verified output.Environment Information
Version
7.0.7