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

borinquenkid pushed a commit to branch feat/gorm-registry-core
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 1d36409994431e07e0f7ebc94260054a1efe36b6
Author: Walter Duque de Estrada <[email protected]>
AuthorDate: Sat Jun 27 09:41:43 2026 -0500

    fix: distinguish datasource connection qualifier from tenant ID in 
AbstractGormApi.execute()
    
    The GORM scaling commit introduced a non-default qualifier path in 
execute() that
    unconditionally called Tenants.withId(datastore, qualifier) for 
multi-tenant entities.
    This was correct for DATABASE mode (qualifier == tenant ID == connection 
name) but
    broke DISCRIMINATOR mode: when a @Service with 
@Transactional(connection='secondary')
    executed a query, 'secondary' was bound as the current tenant ID instead of 
the real
    tenant from the TenantResolver, causing discriminator filters to match 
'secondary' and
    return 0 rows.
    
    Fix: probe getDatastoreForConnection(qualifier) to determine whether the 
qualifier
    names a real datasource connection. If it resolves (non-null), it is a 
connection name
    — fall through to executeQualified without touching the tenant context. If 
it throws or
    returns null, the qualifier is a tenant ID (e.g. from withTenant()) — bind 
it via
    Tenants.withId as before.
    
    Update GormRegistrySpec to explicitly stub getDatastoreForConnection(_) >> 
null on the
    DISCRIMINATOR-mode test stub, mirroring real HibernateDatastore behaviour 
(which throws
    ConfigurationException for unknown connection names) and avoiding Spock's 
covariant-
    interface default of returning the stub itself.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---
 .../grails/datastore/gorm/AbstractGormApi.groovy   | 28 +++++++-
 .../grails/datastore/gorm/GormRegistrySpec.groovy  | 79 ++++++++++++++++++++++
 2 files changed, 104 insertions(+), 3 deletions(-)

diff --git 
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApi.groovy
 
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApi.groovy
index a3bebc24cd..614f9302c1 100644
--- 
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApi.groovy
+++ 
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApi.groovy
@@ -35,6 +35,7 @@ import org.grails.datastore.mapping.core.Session
 import org.grails.datastore.mapping.core.SessionCallback
 import org.grails.datastore.mapping.core.VoidSessionCallback
 import org.grails.datastore.mapping.core.connections.ConnectionSource
+import 
org.grails.datastore.mapping.core.connections.MultipleConnectionSourceCapableDatastore
 import org.grails.datastore.mapping.model.MappingContext
 import org.grails.datastore.mapping.model.PersistentEntity
 import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
@@ -101,9 +102,30 @@ abstract class AbstractGormApi<D> extends 
AbstractDatastoreApi {
         // Check if we have a non-default qualifier
         if (currentQualifier != null && 
!ConnectionSource.DEFAULT.equals(currentQualifier) && 
!ConnectionSource.OLD_DEFAULT.equalsIgnoreCase(currentQualifier)) {
             if (isMultiTenantEntity && isMultiTenantCapable) {
-                // If it's a multi-tenant entity and we have a qualifier, bind 
it as the tenant ID
-                return (T1) Tenants.withId((MultiTenantCapableDatastore)ds, 
(Serializable)currentQualifier) {
-                    DatastoreUtils.execute(ds, callback)
+                // Determine whether the qualifier names a datasource 
connection or is a tenant ID.
+                // A datasource connection qualifier resolves via 
getDatastoreForConnection(); a tenant ID
+                // (e.g. from withTenant("t1")) does not. When it IS a 
connection qualifier we must not
+                // bind it as the tenant ID — doing so overwrites the tenant 
context set by the
+                // TenantResolver (e.g. SystemPropertyTenantResolver) and 
causes discriminator filters to
+                // match the connection name instead of the real tenant.
+                boolean isConnectionQualifier = false
+                if (ds instanceof MultipleConnectionSourceCapableDatastore) {
+                    try {
+                        Datastore resolved = 
((MultipleConnectionSourceCapableDatastore) ds)
+                                .getDatastoreForConnection(currentQualifier)
+                        if (resolved != null) {
+                            isConnectionQualifier = true
+                        }
+                    } catch (Exception ignored) {
+                        // qualifier is not a known datasource name; treat it 
as a tenant ID below
+                    }
+                }
+                if (!isConnectionQualifier) {
+                    // Qualifier is a tenant ID — bind it so the session and 
any discriminator filter
+                    // both see the correct tenant for this operation.
+                    return (T1) 
Tenants.withId((MultiTenantCapableDatastore)ds, (Serializable)currentQualifier) 
{
+                        DatastoreUtils.execute(ds, callback)
+                    }
                 }
             }
             return executeQualified(currentQualifier, callback)
diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistrySpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistrySpec.groovy
index 8cd18e1a6a..d4fc9298dd 100644
--- 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistrySpec.groovy
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistrySpec.groovy
@@ -229,6 +229,9 @@ class GormRegistrySpec extends Specification {
                     getName() >> "default"
                 }
             }
+            // Spock stubs return themselves for covariant interface methods; 
explicit null here mirrors
+            // the real HibernateDatastore behavior where unknown connection 
names throw.
+            getDatastoreForConnection(_) >> null
         }
         def mappingContext = 
Stub(org.grails.datastore.mapping.model.MappingContext)
         def entity = Stub(PersistentEntity) {
@@ -270,6 +273,82 @@ class GormRegistrySpec extends Specification {
         TestEntity.metaClass = null
     }
 
+    void "execute with connection-name qualifier on DISCRIMINATOR multi-tenant 
entity does not override tenant context"() {
+        given: "a DISCRIMINATOR-mode child datastore that resolves its own 
connection qualifier"
+        def session = Stub(Session)
+        // childDatastore simulates a ChildHibernateDatastore: 
getDatastoreForConnection('secondary') returns itself
+        def childDatastore = Stub(MixedDatastore)
+        childDatastore.getMultiTenancyMode() >> 
MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR
+        childDatastore.hasCurrentSession() >> false
+        childDatastore.connect() >> session
+        childDatastore.getDatastoreForConnection("secondary") >> childDatastore
+        childDatastore.getConnectionSources() >> Stub(ConnectionSources) {
+            getDefaultConnectionSource() >> Stub(ConnectionSource) {
+                getName() >> "secondary"
+            }
+        }
+        session.getDatastore() >> childDatastore
+
+        def registry = GormRegistry.instance
+        registry.registerDatastore("secondary", childDatastore)
+
+        // The secondary static API has qualifier="secondary" and 
datastore=childDatastore,
+        // matching what GormRegistry.findStaticApi(entity, "secondary") 
returns in practice.
+        def secondaryApi = new DummyStaticApiForTest(TestEntity, 
childDatastore, [:], "secondary")
+        registry.registerEntityApis(TestEntity, secondaryApi, null, null)
+
+        when: "the secondary API executes inside an outer tenant context 
('tenant1')"
+        def capturedTenantId = null
+        Tenants.withId(childDatastore, "tenant1") {
+            secondaryApi.withDatastoreSession { Session sess ->
+                capturedTenantId = CurrentTenantHolder.get(childDatastore)
+            }
+        }
+
+        then: "the connection qualifier does NOT override the enclosing tenant 
context"
+        capturedTenantId == "tenant1"
+
+        cleanup:
+        TestEntity.metaClass = null
+    }
+
+    void "execute with tenant-ID qualifier on DISCRIMINATOR multi-tenant 
entity binds that qualifier as tenant"() {
+        given: "a DISCRIMINATOR-mode parent datastore where 'tenant1' is not a 
known connection name"
+        def session = Stub(Session)
+        def datastore = Stub(MixedDatastore) {
+            getMultiTenancyMode() >> 
MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR
+            hasCurrentSession() >> false
+            connect() >> session
+            getConnectionSources() >> Stub(ConnectionSources) {
+                getDefaultConnectionSource() >> Stub(ConnectionSource) {
+                    getName() >> "default"
+                }
+            }
+            // 'tenant1' is not a datasource connection — 
getDatastoreForConnection returns null
+            getDatastoreForConnection("tenant1") >> null
+        }
+        session.getDatastore() >> datastore
+
+        def registry = GormRegistry.instance
+        registry.registerDatastore("default", datastore)
+
+        // withTenant("tenant1") produces an API with qualifier="tenant1"
+        def tenantApi = new DummyStaticApiForTest(TestEntity, datastore, [:], 
"tenant1")
+        registry.registerEntityApis(TestEntity, tenantApi, null, null)
+
+        when: "the tenant-qualified API executes a session callback"
+        def capturedTenantId = null
+        tenantApi.withDatastoreSession { Session sess ->
+            capturedTenantId = CurrentTenantHolder.get(datastore)
+        }
+
+        then: "the tenant ID qualifier is correctly bound as the current 
tenant"
+        capturedTenantId == "tenant1"
+
+        cleanup:
+        TestEntity.metaClass = null
+    }
+
     void "findTransactionManager with qualifier returns transaction manager"() 
{
         given:
         def txManager = Stub(PlatformTransactionManager)

Reply via email to