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)
