borinquenkid commented on code in PR #16066:
URL: https://github.com/apache/grails-core/pull/16066#discussion_r3730551105


##########
grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/Tenants.groovy:
##########
@@ -193,14 +211,15 @@ class Tenants {
             throw new UnsupportedOperationException('Datastore implementation 
does not support multi-tenancy')
         }
     }
+
     /**
      * Execute the given closure with given tenant id
      * @param tenantId The tenant id
      * @param callable The closure
      * @return The result of the closure
      */
-    static <T> T withId(Class<? extends Datastore> datastoreClass, 
Serializable tenantId, Closure<T> callable) {
-        Datastore datastore = GormEnhancer.findDatastoreByType(datastoreClass)
+    static <T> T withId(Class domainClass, Serializable tenantId, Closure<T> 
callable) {

Review Comment:
   Traced the actual blast radius before picking a fix. The ~40 call sites 
cited as evidence (`RxGormStaticApi`, `TenantDelegatingRxGormOperations`) 
turned out to be a false positive — `grails-datamapping-rx` has its own, fully 
independent `grails.gorm.rx.multitenancy.Tenants` operating on 
`RxDatastoreClient`, untouched by this PR (grepped every import in that module 
to confirm zero references to the core `Tenants` class).
   
   The real clash is in `TenantDelegatingGormOperations`'s ~100 
`Tenants.withId((Class<Datastore>) datastore.getClass(), ...)` calls, but that 
class turns out to be dead code on this branch: 
`GormStaticApi.withTenant(Serializable)` used to construct it directly, and 
this PR's rewrite replaced that with `forQualifier(tenantId.toString())` 
instead — nothing else in the tree constructs it except its own unit test. 
Fixed anyway since it's cheap and removes the landmine if it's ever wired back 
up: added `requireMultiTenantCapableDatastore()` and switched all 100 sites to 
the existing, unambiguous `Tenants.withId(MultiTenantCapableDatastore, 
Serializable, Closure)` overload rather than a `withIdForDomain` rename — the 
domain-class overload has no live erasure conflict anywhere reachable, so a 
rename felt like a needless public API break for a theoretical case. 
`Tenants.withId(Class domainClass, ...)` itself is untouched.
   
   Commit `4b116c2166`. Also verified the `withTenant()` 
decorator→`forQualifier` mechanism swap doesn't change runtime behavior — 
`GormRegistrySpec`'s DISCRIMINATOR-mode tests and 
`PartitionedMultiTenancySpec`'s DATABASE-mode assertion both exercise the 
no-closure chained form against it and pass.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy:
##########
@@ -19,1019 +19,719 @@
 package org.grails.datastore.gorm
 
 import groovy.transform.CompileDynamic
-import groovy.transform.CompileStatic
-import groovy.transform.TypeCheckingMode
-import org.codehaus.groovy.runtime.InvokerHelper
+import groovy.util.logging.Slf4j
 
-import org.springframework.beans.PropertyAccessorFactory
-import org.springframework.beans.factory.config.AutowireCapableBeanFactory
 import org.springframework.transaction.PlatformTransactionManager
 import org.springframework.transaction.TransactionDefinition
 import org.springframework.transaction.support.DefaultTransactionDefinition
-import org.springframework.util.Assert
 
 import grails.gorm.CriteriaBuilder
 import grails.gorm.DetachedCriteria
-import grails.gorm.MultiTenant
-import grails.gorm.PagedResultList
 import grails.gorm.api.GormAllOperations
+import grails.gorm.api.GormInstanceOperations
+import grails.gorm.api.GormStaticOperations
 import grails.gorm.multitenancy.Tenants
 import grails.gorm.transactions.GrailsTransactionTemplate
-import org.grails.datastore.gorm.finders.DynamicFinder
 import org.grails.datastore.gorm.finders.FinderMethod
-import org.grails.datastore.gorm.multitenancy.TenantDelegatingGormOperations
-
+import org.grails.datastore.gorm.transactions.DefaultTransactionTemplateFactory
+import org.grails.datastore.gorm.transactions.TransactionTemplateFactory
 import org.grails.datastore.mapping.core.Datastore
 import org.grails.datastore.mapping.core.DatastoreUtils
 import org.grails.datastore.mapping.core.Session
 import org.grails.datastore.mapping.core.SessionCallback
 import org.grails.datastore.mapping.core.StatelessDatastore
 import org.grails.datastore.mapping.core.connections.ConnectionSource
-import org.grails.datastore.mapping.core.connections.ConnectionSourceSettings
 import org.grails.datastore.mapping.core.connections.ConnectionSources
 import org.grails.datastore.mapping.core.connections.ConnectionSourcesProvider
+import org.grails.datastore.mapping.model.MappingContext
 import org.grails.datastore.mapping.model.PersistentEntity
-import org.grails.datastore.mapping.model.PersistentProperty
-import org.grails.datastore.mapping.model.types.Association
-import 
org.grails.datastore.mapping.multitenancy.MultiTenancySettings.MultiTenancyMode
-import org.grails.datastore.mapping.query.Query
+import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
 import org.grails.datastore.mapping.query.api.BuildableCriteria
-import org.grails.datastore.mapping.query.api.Criteria
+import org.grails.datastore.mapping.transactions.TransactionCapableDatastore
 
 /**
- * Static methods of the GORM API.
+ * Static methods for GORM
  *
  * @author Graeme Rocher
- * @param <D> the entity/domain class
  */
-@CompileStatic
+@CompileDynamic
+@Slf4j
 class GormStaticApi<D> extends AbstractGormApi<D> implements 
GormAllOperations<D> {
 
-    protected final List<FinderMethod> gormDynamicFinders
+    private static final TransactionTemplateFactory 
DEFAULT_TRANSACTION_TEMPLATE_FACTORY = new DefaultTransactionTemplateFactory()
 
-    protected final PlatformTransactionManager transactionManager
-    protected final String defaultQualifier
-    protected final MultiTenancyMode multiTenancyMode
-    protected final ConnectionSources connectionSources
+    protected final List<FinderMethod> finders
 
+    @Deprecated
     GormStaticApi(Class<D> persistentClass, Datastore datastore, 
List<FinderMethod> finders) {
-        this(persistentClass, datastore, finders, null)
+        this(persistentClass, datastore?.mappingContext, finders, datastore != 
null ? ({ datastore } as DatastoreResolver) : null, ConnectionSource.DEFAULT, 
null)
     }
 
+    @Deprecated
     GormStaticApi(Class<D> persistentClass, Datastore datastore, 
List<FinderMethod> finders, PlatformTransactionManager transactionManager) {
-        super(persistentClass, datastore)
-        gormDynamicFinders = finders
-        this.transactionManager = transactionManager
-        String qualifier = ConnectionSource.DEFAULT
-        if (datastore instanceof ConnectionSourcesProvider) {
-            this.connectionSources = ((ConnectionSourcesProvider) 
datastore).connectionSources
-            ConnectionSource<?, ? extends ConnectionSourceSettings> 
defaultConnectionSource = connectionSources.defaultConnectionSource
-            qualifier = defaultConnectionSource.name
-            multiTenancyMode = 
defaultConnectionSource.settings.multiTenancy.mode
-
-        }
-        else {
-            connectionSources = null
-            multiTenancyMode = MultiTenancyMode.NONE
-        }
-        this.defaultQualifier = qualifier
+        this(persistentClass, datastore?.mappingContext, finders, datastore != 
null ? ({ datastore } as DatastoreResolver) : null, ConnectionSource.DEFAULT, 
null)
     }
 
-    /**
-     * @return The PersistentEntity for this class
-     */
-    PersistentEntity getGormPersistentEntity() {
-        persistentEntity
+    GormStaticApi(Class<D> persistentClass, MappingContext mappingContext, 
List<FinderMethod> finders) {
+        this(persistentClass, mappingContext, finders, null, 
ConnectionSource.DEFAULT, null)
     }
 
-    List<FinderMethod> getGormDynamicFinders() {
-        gormDynamicFinders
+    GormStaticApi(Class<D> persistentClass, MappingContext mappingContext, 
List<FinderMethod> finders, String qualifier) {
+        this(persistentClass, mappingContext, finders, null, qualifier, null)
     }
 
-    /**
-     * Property missing handler
-     *
-     * @param name The name of the property
-     */
-    def propertyMissing(String name) {
-        if (datastore instanceof ConnectionSourcesProvider) {
-            return GormEnhancer.findStaticApi(persistentClass, name)
-        }
-        else {
-            throw new MissingPropertyException(name, persistentClass)
-        }
+    GormStaticApi(Class<D> persistentClass, MappingContext mappingContext, 
List<FinderMethod> finders, DatastoreResolver resolver, String qualifier) {
+        this(persistentClass, mappingContext, finders, resolver, qualifier, 
null)
     }
 
-    /**
-     * Property missing handler
-     *
-     * @param name The name of the property
-     */
-    void propertyMissing(String name, value) {
-        throw new MissingPropertyException(name, persistentClass)
+    GormStaticApi(Class<D> persistentClass, MappingContext mappingContext, 
List<FinderMethod> finders, DatastoreResolver resolver, String qualifier, 
GormRegistry registry) {
+        super(persistentClass, mappingContext, resolver, qualifier, registry)
+        this.finders = finders
     }
 
-    /**
-     * Method missing handler that deals with the invocation of dynamic finders
-     *
-     * @param methodName The method name
-     * @param args The arguments
-     * @return The result of the method call
-     */
-    @CompileDynamic
-    def methodMissing(String methodName, Object args) {
-        FinderMethod method = gormDynamicFinders.find { FinderMethod f -> 
f.isMethodMatch(methodName) }
-        if (!method) {
-            throw new MissingMethodException(methodName, persistentClass, args)
-        }
-
-        // if the class is multi tenant, don't cache the method because the 
tenant will need to be resolved
-        // for each method call
-        if (!MultiTenant.isAssignableFrom(persistentClass)) {
-
-            def mc = persistentClass.getMetaClass()
-
-            // register the method invocation for next time
-            mc.static."$methodName" = { Object[] varArgs ->
-                // FYI... This is relevant to 
http://jira.grails.org/browse/GRAILS-3463 and may
-                // become problematic if 
http://jira.codehaus.org/browse/GROOVY-5876 is addressed...
-                final argumentsForMethod
-                if (varArgs == null) {
-                    argumentsForMethod = [null] as Object[]
-                }
-                // if the argument component type is not an Object then we 
have an array passed that is the actual argument
-                else if (varArgs.getClass().componentType != Object) {
-                    // so we wrap it in an object array
-                    argumentsForMethod = [varArgs] as Object[]
-                }
-                else {
-
-                    if (varArgs.length == 1 && 
varArgs[0].getClass().isArray()) {
-                        argumentsForMethod = varArgs[0]
-                    } else {
-
-                        argumentsForMethod = varArgs
-                    }
-                }
-                method.invoke(delegate, methodName, argumentsForMethod)
-            }
+    @Override
+    PlatformTransactionManager getTransactionManager() {
+        Datastore ds = getDatastore()
+        if (ds instanceof TransactionCapableDatastore) {
+            return ((TransactionCapableDatastore)ds).getTransactionManager()
         }
-
-        return method.invoke(persistentClass, methodName, args)
-    }
-
-    /**
-     *
-     * @param callable Callable closure containing detached criteria definition
-     * @return The DetachedCriteria instance
-     */
-    DetachedCriteria<D> where(Closure callable) {
-        new DetachedCriteria<D>(persistentClass).build(callable)
-    }
-
-    /**
-     *
-     * @param callable Callable closure containing detached criteria definition
-     * @return The DetachedCriteria instance that is lazily initialized
-     */
-    DetachedCriteria<D> whereLazy(Closure callable) {
-        new DetachedCriteria<D>(persistentClass).buildLazy(callable)
-    }
-    /**
-     *
-     * @param callable Callable closure containing detached criteria definition
-     * @return The DetachedCriteria instance
-     */
-    DetachedCriteria<D> whereAny(Closure callable) {
-        (DetachedCriteria<D>) new 
DetachedCriteria<D>(persistentClass).or(callable)
-    }
-
-    /**
-     * Uses detached criteria to build a query and then execute it returning a 
list
-     *
-     * @param callable The callable
-     * @return A List of entities
-     */
-    List<D> findAll(Closure callable) {
-        def criteria = new DetachedCriteria<D>(persistentClass).build(callable)
-        return criteria.list()
-    }
-
-    /**
-     * Uses detached criteria to build a query and then execute it returning a 
list
-     *
-     * @param args pagination parameters
-     * @param callable The callable
-     * @return A List of entities
-     */
-    List<D> findAll(Map args, Closure callable) {
-        def criteria = new DetachedCriteria<D>(persistentClass).build(callable)
-        return criteria.list(args)
-    }
-
-    /**
-     * Uses detached criteria to build a query and then execute it returning a 
list
-     *
-     * @param callable The callable
-     * @return A single entity
-     */
-    D find(Closure callable) {
-        def criteria = new DetachedCriteria<D>(persistentClass).build(callable)
-        return criteria.find()
+        return null
     }
 
-    /**
-     * Saves a list of objects in one go
-     * @param objectsToSave The objects to save
-     * @return A list of object identifiers
-     */
-    List<Serializable> saveAll(Object... objectsToSave) {
-        (List<Serializable>) execute({ Session session ->
-            session.persist(Arrays.asList(objectsToSave))
-        } as SessionCallback)
+    @Override
+    protected <T1> T1 executeQualified(String qualifier, SessionCallback<T1> 
callback) {
+        GormStaticApi<D> qualifiedApi = 
registry.findStaticApi(persistentClass, qualifier)
+        if (qualifiedApi != null && qualifiedApi != this) {
+            return (T1) qualifiedApi.execute(callback)
+        }
+        return DatastoreUtils.execute(getDatastore(), callback)
     }
 
-    /**
-     * Saves a list of objects in one go
-     * @param objectToSave Collection of objects to save
-     * @return A list of object identifiers
-     */
-    List<Serializable> saveAll(Iterable<?> objectsToSave) {
-        (List<Serializable>) execute({ Session session ->
-            session.persist(objectsToSave)
-        } as SessionCallback)
+    @Override
+    PersistentEntity getGormPersistentEntity() {
+        PersistentEntity entity = qualifier != null ? 
registry.apiResolver.findEntity(persistentClass, qualifier) : null
+        if (entity == null) {
+            entity = super.getGormPersistentEntity()
+        }
+        if (entity == null) {
+            entity = registry.apiResolver.findEntity(persistentClass)
+        }
+        // Final fallback: resolve from the mapping context captured when this 
API was constructed.
+        // Entity metadata is identical across tenants/connections (only the 
datastore/session differs),
+        // so this stable reference is the most reliable source and avoids a 
null entity when runtime
+        // registry resolution is disturbed by cross-spec state — e.g. a 
qualified API created by
+        // withTenant(tenantId) for DISCRIMINATOR/SCHEMA multi-tenancy.
+        if (entity == null && mappingContext != null) {
+            entity = mappingContext.getPersistentEntity(persistentClass.name)
+        }
+        entity
     }
 
-    /**
-     * Deletes a list of objects in one go
-     * @param objectsToDelete The objects to delete
-     */
-    void deleteAll(Object... objectsToDelete) {
-        execute({ Session session ->
-            session.delete(Arrays.asList(objectsToDelete))
-        } as SessionCallback)
+    @Override
+    List<FinderMethod> getGormDynamicFinders() {
+        return finders
     }
 
-    /**
-     * Deletes a list of objects in one go and flushes when param is set
-     * @param objectsToDelete The objects to delete
-     */
-    void deleteAll(Map params, Object... objectsToDelete) {
-        execute({ Session session ->
-            session.delete(Arrays.asList(objectsToDelete))
-            if (params?.flush) {
-                session.flush()
-            }
-        } as SessionCallback)
+    GormStaticApi<D> forQualifier(String qualifier) {
+        Datastore ds = getDatastore()
+        DatastoreResolver resolver = new DatastoreResolver() {
+            @Override Datastore resolve() { 
registry.apiResolver.findDatastore(persistentClass, qualifier) }
+        }
+        List<FinderMethod> qualifiedFinders = 
registry.createDynamicFinders(resolver, ds.mappingContext)
+        createStaticApi(persistentClass, ds.mappingContext, qualifiedFinders, 
resolver, qualifier)
     }
 
-    /**
-     * Deletes a list of objects in one go
-     * @param objectsToDelete Collection of objects to delete
-     */
-    void deleteAll(Iterable objectToDelete) {
-        execute({ Session session ->
-            session.delete(objectToDelete)
-        } as SessionCallback)
+    protected GormStaticApi<D> createStaticApi(Class<D> persistentClass, 
MappingContext mappingContext, List<FinderMethod> finders, DatastoreResolver 
resolver, String qualifier) {
+        new GormStaticApi<D>(persistentClass, mappingContext, finders, 
resolver, qualifier, registry)
     }
 
-    /**
-     * Deletes a list of objects in one go and flushes when param is set
-     * @param objectsToDelete Collection of objects to delete
-     */
-    void deleteAll(Map params, Iterable objectToDelete) {
-        execute({ Session session ->
-            session.delete(objectToDelete)
-            if (params?.flush) {
-                session.flush()
+    @Override
+    Object methodMissing(String name, Object args) {
+        Object[] argsArray = (args instanceof Object[]) ? (Object[]) args : 
([args] as Object[])
+        for (FinderMethod fm : finders) {
+            if (fm.isMethodMatch(name)) {
+                return execute({ Session session ->
+                    fm.invoke(persistentClass, name, argsArray)
+                } as SessionCallback)
             }
-        } as SessionCallback)
-    }
-
-    /**
-     * Creates an instance of this class
-     * @return The created instance
-     */
-    @CompileStatic(TypeCheckingMode.SKIP)
-    D create() {
-        D d = persistentClass.newInstance()
-
-        def applicationContext = datastore.applicationContext
-
-        if (applicationContext != null) {
-            
applicationContext.autowireCapableBeanFactory.autowireBeanProperties(
-                    d, AutowireCapableBeanFactory.AUTOWIRE_BY_NAME, false)
         }
-
-        return d
-    }
-
-    /**
-     * Retrieves an object from the datastore. eg. Book.get(1)
-     */
-    D get(Serializable id) {
-        (D) execute({ Session session ->
-            session.retrieve((Class)persistentClass, id)
-        } as SessionCallback)
-    }
-
-    /**
-     * Retrieves an object from the datastore. eg. Book.read(1)
-     *
-     * Since the datastore abstraction doesn't support dirty checking yet this
-     * just delegates to {@link #get(Serializable)}
-     */
-    D read(Serializable id) {
-        (D) execute({ Session session ->
-            session.retrieve((Class)persistentClass, id)
-        } as SessionCallback)
-    }
-
-    /**
-     * Retrieves an object from the datastore as a proxy. eg. Book.load(1)
-     */
-    D load(Serializable id) {
-        (D) execute({ Session session ->
-            session.proxy((Class)persistentClass, id)
-        } as SessionCallback)
-    }
-
-    /**
-     * Retrieves an object from the datastore as a proxy. eg. Book.proxy(1)
-     */
-    D proxy(Serializable id) {
-        load(id)
-    }
-
-    /**
-     * Retrieve all the objects for the given identifiers
-     * @param ids The identifiers to operate against
-     * @return A list of identifiers
-     */
-    List<D> getAll(Iterable<Serializable> ids) {
-        return getAll(ids as Serializable[])
-    }
-
-    /**
-     * Retrieve all the objects for the given identifiers
-     * @param ids The identifiers to operate against
-     * @return A list of identifiers
-     */
-    List<D> getAll(Serializable... ids) {
-        (List<D>) execute({ Session session ->
-            session.retrieveAll(persistentClass, ids.flatten())
-        } as SessionCallback)
-    }
-
-    /**
-     * @return Synonym for {@link #list()}
-     */
-    List<D> getAll() {
-        list()
-    }
-
-    /**
-     * Creates a criteria builder instance
-     */
-    BuildableCriteria createCriteria() {
-        new CriteriaBuilder(persistentClass, datastore.currentSession)
+        throw new MissingMethodException(name, persistentClass, argsArray)
     }
 
-    /**
-     * Creates a criteria builder instance
-     */
-    def withCriteria(@DelegatesTo(Criteria) Closure callable) {
-        execute({ Session session ->
-            InvokerHelper.invokeMethod(createCriteria(), 'call', callable)
-        } as SessionCallback)
-    }
-
-    /**
-     * Creates a criteria builder instance
-     */
-    def withCriteria(Map builderArgs, @DelegatesTo(Criteria) Closure callable) 
{
-        def criteriaBuilder = createCriteria()
-        def builderBean = 
PropertyAccessorFactory.forBeanPropertyAccess(criteriaBuilder)
-        for (entry in builderArgs.entrySet()) {
-            String propertyName = entry.key.toString()
-            if (builderBean.isWritableProperty(propertyName)) {
-                builderBean.setPropertyValue(propertyName, entry.value)
+    @Override
+    Object propertyMissing(String name) {
+        for (FinderMethod fm : finders) {
+            if (fm.isMethodMatch(name)) {
+                return { Object... args ->
+                    Object[] finderArgs = args == null ? ([null] as Object[]) 
: args
+                    execute({ Session session ->
+                        fm.invoke(persistentClass, name, finderArgs)
+                    } as SessionCallback)
+                }
             }
         }
-
-        if (builderArgs?.uniqueResult) {
-            execute({ Session session ->
-                InvokerHelper.invokeMethod(criteriaBuilder, 'get', callable)
-            } as SessionCallback)
-
+        
+        Datastore ds = getDatastore()
+        if (ds instanceof ConnectionSourcesProvider) {
+            ConnectionSources sources = ((ConnectionSourcesProvider) 
ds).connectionSources
+            if (sources != null) {
+                if (sources.getConnectionSource(name) != null) {
+                    return registry.findStaticApi(persistentClass, name)
+                }
+                if (name.equalsIgnoreCase(ConnectionSource.DEFAULT) || 
name.equalsIgnoreCase(ConnectionSource.OLD_DEFAULT)) {
+                    return registry.findStaticApi(persistentClass, 
ConnectionSource.DEFAULT)
+                }
+            }
         }
-        else {
-            execute({ Session session ->
-                InvokerHelper.invokeMethod(criteriaBuilder, 'list', callable)
-            } as SessionCallback)
+        // Fallback: the preferred/transactional datastore may be a 
single-datasource datastore
+        // that doesn't expose the named qualifier in its connectionSources. 
Check the registry
+        // directly so that entities mapped to multiple datasources (e.g. 
datasource 'ALL') can
+        // still be accessed via the qualifier even when a single-datasource 
transaction is active.
+        if (registry.getDatastoreByString(persistentClass.name, name) != null) 
{
+            return registry.findStaticApi(persistentClass, name)
         }
-
+        throw new MissingPropertyException(name, persistentClass)
     }
 
-    /**
-     * Locks an instance for an update
-     * @param id The identifier
-     * @return The instance
-     */
-    D lock(Serializable id) {
-        (D) execute({ Session session ->
-            session.lock((Class)persistentClass, id)
-        } as SessionCallback)
+    @Override
+    void propertyMissing(String name, Object val) {
+        throw new MissingPropertyException(name, persistentClass)
     }
 
-    /**
-     * Merges an instance with the current session
-     * @param d The object to merge
-     * @return The instance
-     */
+    // GormInstanceOperations delegation
     @Override
     def propertyMissing(D instance, String name) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).propertyMissing(instance, name)
+        registry.findInstanceApi(persistentClass, 
null).propertyMissing(instance, name)
     }
 
     @Override
     boolean instanceOf(D instance, Class cls) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).instanceOf(instance, cls)
+        registry.findInstanceApi(persistentClass, null).instanceOf(instance, 
cls)
     }
 
     @Override
     D lock(D instance) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).lock(instance)
+        registry.findInstanceApi(persistentClass, null).lock(instance)
     }
 
     @Override
-    def <T> T mutex(D instance, Closure<T> callable) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).mutex(instance, callable)
+    def <T1> T1 mutex(D instance, Closure<T1> callable) {
+        registry.findInstanceApi(persistentClass, null).mutex(instance, 
callable)
     }
 
     @Override
     D refresh(D instance) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).refresh(instance)
+        registry.findInstanceApi(persistentClass, null).refresh(instance)
     }
 
     @Override
     D save(D instance) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).save(instance)
+        registry.findInstanceApi(persistentClass, null).save(instance)
     }
 
     @Override
     D insert(D instance) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).insert(instance)
+        registry.findInstanceApi(persistentClass, null).insert(instance)
     }
 
     @Override
     D insert(D instance, Map params) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).insert(instance, params)
+        registry.findInstanceApi(persistentClass, null).insert(instance, 
params)
     }
 
-    D merge(D d) {
-        execute({ Session session ->
-            session.persist(d)
-            return d
-        } as SessionCallback)
+    @Override
+    D merge(D instance) {
+        registry.findInstanceApi(persistentClass, null).merge(instance)
     }
 
     @Override
     D merge(D instance, Map params) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).merge(instance, params)
+        registry.findInstanceApi(persistentClass, null).merge(instance, params)
     }
 
     @Override
     D save(D instance, boolean validate) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).save(instance, validate)
+        registry.findInstanceApi(persistentClass, null).save(instance, 
validate)
     }
 
     @Override
     D save(D instance, Map params) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).save(instance, params)
+        registry.findInstanceApi(persistentClass, null).save(instance, params)
     }
 
     @Override
     Serializable ident(D instance) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).ident(instance)
+        registry.findInstanceApi(persistentClass, null).ident(instance)
     }
 
     @Override
     D attach(D instance) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).attach(instance)
+        registry.findInstanceApi(persistentClass, null).attach(instance)
     }
 
     @Override
     boolean isAttached(D instance) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).isAttached(instance)
+        registry.findInstanceApi(persistentClass, null).isAttached(instance)
     }
 
     @Override
     void discard(D instance) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).discard(instance)
+        registry.findInstanceApi(persistentClass, null).discard(instance)
     }
 
     @Override
     void delete(D instance) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).delete(instance)
+        registry.findInstanceApi(persistentClass, null).delete(instance)
     }
 
     @Override
     void delete(D instance, Map params) {
-        GormEnhancer.findInstanceApi(persistentClass, 
defaultQualifier).delete(instance, params)
+        registry.findInstanceApi(persistentClass, null).delete(instance, 
params)
     }
-    /**
-     * Counts the number of persisted entities
-     * @return The number of persisted entities
-     */
-    Integer count() {
-        (Integer) execute({ Session session ->
 
-            def q = session.createQuery(persistentClass)
-            q.projections().count()
-            def result = q.singleResult()
-            if (!(result instanceof Number)) {
-                result = result.toString()
-            }
-            try {
-                return result as Integer
-            }
-            catch (NumberFormatException e) {
-                return 0
-            }
-        } as SessionCallback)
+    // GormStaticOperations
+    @Override
+    D get(Serializable id) {
+        execute({ Session session ->
+            session.retrieve(persistentClass, id)
+        } as SessionCallback<D>)
     }
 
-    /**
-     * Same as {@link #count()} but allows property-style syntax (Foo.count)
-     */
-    Integer getCount() {
-        count()
+    @Override
+    D read(Serializable id) {
+        get(id)
     }
 
-    /**
-     * Checks whether an entity exists
-     */
-    boolean exists(Serializable id) {
-        get(id) != null
+    @Override
+    D load(Serializable id) {
+        execute({ Session session ->
+            session.proxy(persistentClass, id)
+        } as SessionCallback<D>)
     }
 
-    /**
-     * Lists objects in the datastore. eg. Book.list(max:10)
-     *
-     * @param params Any parameters such as offset, max etc.
-     * @return A list of results
-     */
-    List<D> list(Map params) {
-        (List<D>) execute({ Session session ->
-            Query q = session.createQuery(persistentClass)
-            DynamicFinder.populateArgumentsForCriteria(persistentClass, q, 
params)
-            if (params?.max) {
-                return new PagedResultList(q)
-            }
-            return q.list()
-        } as SessionCallback)
+    @Override
+    D proxy(Serializable id) {
+        load(id)
+    }
+
+    @Override
+    List<D> getAll(Serializable... ids) {
+        execute({ Session session ->
+            session.retrieveAll(persistentClass, ids)
+        } as SessionCallback<List<D>>)
+    }
+
+    @Override
+    List<D> getAll(Iterable<Serializable> ids) {
+        execute({ Session session ->
+            session.retrieveAll(persistentClass, ids)
+        } as SessionCallback<List<D>>)
+    }
+
+    @Override
+    List<D> getAll() {
+        list()
     }
 
-    /**
-     * List all entities
-     *
-     * @return The list of all entities
-     */
+    @Override
     List<D> list() {
-        (List<D>) execute({ Session session ->
-            session.createQuery(persistentClass).list()
-        } as SessionCallback)
+        list(Collections.emptyMap())
     }
 
-    /**
-     * The same as {@link #list()}
-     *
-     * @return The list of all entities
-     */
-    List<D> findAll(Map params = Collections.emptyMap()) {
-        list(params)
+    @Override
+    List<D> list(Map params) {
+        execute({ Session session ->
+            org.grails.datastore.mapping.query.Query q = 
session.createQuery(persistentClass)
+            
org.grails.datastore.gorm.finders.DynamicFinder.populateArgumentsForCriteria(persistentClass,
 q, params)
+            if (params?.containsKey('max')) {
+                return new grails.gorm.PagedResultList(q)
+            }
+            q.list()
+        } as SessionCallback<List<D>>)
     }
 
-    /**
-     * Finds an object by example
-     *
-     * @param example The example
-     * @return A list of matching results
-     */
-    List<D> findAll(D example) {
-        findAll(example, Collections.emptyMap())
+    @Override
+    Integer count() {
+        log.debug('GormStaticApi.count() called for {}', persistentClass.name)
+        Integer result = execute({ Session session ->
+            def query = session.createQuery(persistentClass)
+            query.projections().count()
+            def res = query.singleResult()
+            log.debug('Query singleResult returned {}', res)
+            res instanceof Number ? ((Number)res).intValue() : 0
+        } as SessionCallback<Integer>)
+        log.debug('count() result is {}', result)
+        return result
     }
 
-    /**
-     * Finds an object by example using the given arguments for pagination
-     *
-     * @param example The example
-     * @param args The arguments
-     *
-     * @return A list of matching results
-     */
-    List<D> findAll(D example, Map args) {
-        if (!persistentEntity.isInstance(example)) {
-            return Collections.emptyList()
-        }
+    @Override
+    Integer getCount() {
+        count()
+    }
 
-        def queryMap = createQueryMapForExample(persistentEntity, example)
-        return findAllWhere(queryMap, args)
+    @Override
+    boolean exists(Serializable id) {
+        get(id) != null
     }
 
-    /**
-     * Finds the first object using the natural sort order
-     *
-     * @return the first object in the datastore, null if none exist
-     */
+    @Override
     D first() {
         first([:])
     }
 
-    /**
-     * Finds the first object sorted by propertyName
-     *
-     * @param propertyName the name of the property to sort by
-     *
-     * @return the first object in the datastore sorted by propertyName, null 
if none exist
-     */
+    @Override
     D first(String propertyName) {
         first(sort: propertyName)
     }
 
-    /**
-     * Finds the first object.  If queryParams includes 'sort', that will
-     * dictate the sort order, otherwise natural sort order will be used.
-     * queryParams may include any of the same parameters that might be passed
-     * to the list(Map) method.  This method will ignore 'order' and 'max' as
-     * those are always 'asc' and 1, respectively.
-     *
-     * @return the first object in the datastore, null if none exist
-     */
-    D first(Map queryParams) {
+    @Override
+    D first(Map params) {
+        Map queryParams = new LinkedHashMap(params ?: [:])
         queryParams.max = 1
         queryParams.order = 'asc'
         if (!queryParams.containsKey('sort')) {
-            def idPropertyName = persistentEntity.identity?.name
+            String idPropertyName = getGormPersistentEntity()?.identity?.name
             if (idPropertyName) {
                 queryParams.sort = idPropertyName
             }
         }
-        def resultList = list(queryParams)
+        List<D> resultList = list(queryParams)
         resultList ? resultList[0] : null
     }
 
-    /**
-     * Finds the last object using the natural sort order
-     *
-     * @return the last object in the datastore, null if none exist
-     */
+    @Override
     D last() {
         last([:])
     }
 
-    /**
-     * Finds the last object sorted by propertyName
-     *
-     * @param propertyName the name of the property to sort by
-     *
-     * @return the last object in the datastore sorted by propertyName, null 
if none exist
-     */
+    @Override
     D last(String propertyName) {
-        last(sort: propertyName)
+        last(sort: propertyName, order: 'desc')
     }
 
-/**
- * Finds the last object.  If queryParams includes 'sort', that will
- * dictate the sort order, otherwise natural sort order will be used.
- * queryParams may include any of the same parameters that might be passed
- * to the list(Map) method.  This method will ignore 'order' and 'max' as
- * those are always 'asc' and 1, respectively.
- *
- * @return the last object in the datastore, null if none exist
- */
-    D last(Map queryParams) {
+    @Override
+    D last(Map params) {
+        Map queryParams = new LinkedHashMap(params ?: [:])
         queryParams.max = 1
         queryParams.order = 'desc'
         if (!queryParams.containsKey('sort')) {
-            def idPropertyName = persistentEntity.identity?.name
+            String idPropertyName = getGormPersistentEntity()?.identity?.name
             if (idPropertyName) {
                 queryParams.sort = idPropertyName
             }
         }
-        def resultList = list(queryParams)
+        List<D> resultList = list(queryParams)
         resultList ? resultList[0] : null
     }
 
-    /**
-     * Finds all results matching all of the given conditions. Eg. 
Book.findAllWhere(author:"Stephen King", title:"The Stand")
-     *
-     * @param queryMap The map of conditions
-     * @return A list of results
-     */
-    List<D> findAllWhere(Map queryMap) {
-        findAllWhere(queryMap, Collections.emptyMap())
+    @Override
+    BuildableCriteria createCriteria() {
+        execute({ Session session ->
+            new CriteriaBuilder(persistentClass, session)
+        } as SessionCallback<BuildableCriteria>)
     }
 
-    /**
-     * Finds all results matching all of the given conditions. Eg. 
Book.findAllWhere(author:"Stephen King", title:"The Stand")
-     *
-     * @param queryMap The map of conditions
-     * @param args The Query arguments
-     *
-     * @return A list of results
-     */
-    List<D> findAllWhere(Map queryMap, Map args) {
-        (List<D>) execute({ Session session ->
-            Query q = session.createQuery(persistentClass)
+    @Override
+    def <T1> T1 withCriteria(Closure<T1> callable) {
+        createCriteria().list(callable)
+    }
 
-            Map<String, Object> processedQueryMap = [:]
-            queryMap.each { key, value -> processedQueryMap[key.toString()] = 
value }
-            q.allEq(processedQueryMap)
+    @Override
+    def <T1> T1 withCriteria(Map builderArgs, Closure callable) {
+        createCriteria().list(builderArgs, callable)
+    }
 
-            DynamicFinder.populateArgumentsForCriteria(persistentClass, q, 
args)
-            q.list()
-        } as SessionCallback<List>)
+    @Override
+    D lock(Serializable id) {
+        execute({ Session session ->
+            session.lock(persistentClass, id)
+        } as SessionCallback<D>)
+    }
+
+    @Override
+    grails.gorm.DetachedCriteria<D> where(Closure callable) {
+        new 
grails.gorm.DetachedCriteria<D>(persistentClass).withConnection(qualifier).where(callable)
+    }
+
+    @Override
+    grails.gorm.DetachedCriteria<D> whereLazy(Closure callable) {
+        where(callable)
+    }
+
+    @Override
+    grails.gorm.DetachedCriteria<D> whereAny(Closure callable) {
+        new grails.gorm.DetachedCriteria<D>(persistentClass).or(callable)
+    }
+
+    @Override
+    List<Serializable> saveAll(Iterable<?> objectsToSave) {
+        execute({ Session session ->
+            session.persist(objectsToSave)
+        } as SessionCallback<List<Serializable>>)
+    }
+
+    @Override
+    List<Serializable> saveAll(Object... objectsToSave) {
+        saveAll(Arrays.asList(objectsToSave))
+    }
+
+    @Override
+    Number deleteAll() {
+        execute({ Session session ->
+            session.deleteAll(new DetachedCriteria(persistentClass))
+        } as SessionCallback<Number>)
+    }
+
+    @Override
+    Number deleteAll(Map params) {

Review Comment:
   Took the implement option, not drop — this is your own stated bar, and your 
`fix/gorm-registry-review-feedback` branch already met it, so matched it rather 
than reinventing: `deleteAll(Map params)` now honors `params?.flush` instead of 
discarding params, both `deleteAll()`/`deleteAll(Map)` are documented in 
`grails-doc` (new 'Deleting Every Instance' section in `basicCRUD.adoc` with an 
explicit destructive-semantics warning and a pointer to `.where{}.deleteAll()` 
for partial deletes), and `TenantDelegatingGormOperations` gets matching 
delegators via its own `requireMultiTenantCapableDatastore()` pattern (see the 
`Tenants.withId` thread) rather than a class-based fallback.
   
   Commit `e011e0896c`. 3 new specs in `GormStaticApiSpec` (basic 
delete-everything, `Map`-returns-count, flush-honored via a `Session` mock) + 2 
in `TenantDelegatingGormOperationsSpec`.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy:
##########
@@ -540,162 +253,139 @@ class GormEnhancer implements Closeable {
         }
     }
 
-    @CompileStatic
-    List<FinderMethod> getFinders() {
-        if (finders == null) {
-            finders = Collections.unmodifiableList(createDynamicFinders())
+    @CompileDynamic
+    protected void addStaticMethods(PersistentEntity e) {
+        def cls = e.javaClass
+        ExpandoMetaClass mc = MetaClassUtils.getExpandoMetaClass(cls)
+        
+        mc.static.methodMissing = { String name, args ->

Review Comment:
   Restored the `dynamicEnhance`-gated bootstrap exactly as it is on 8.0.x: 
`registerEntity` no longer calls `addStaticMethods`/`addInstanceMethods` 
unconditionally, `enhance()`/`enhance(entity)` are back and gated on 
`dynamicEnhance` (hard-coded `false` in the settings constructor, matching 
8.0.x's own quirk), and nothing in the tree calls `enhance(..)` — confirmed by 
grepping the whole real 8.0.x tree, not just this PR's diff, for any caller 
that ever flips `dynamicEnhance` true. `GormEntity`'s own trait 
`methodMissing`/`propertyMissing` hooks already cover all dispatch (83 spec 
classes stayed green with zero `ExpandoMetaClass` installed). Commit 
`da09401c4d`.
   
   Separately adopted your instance-side clobber guard on `addInstanceMethods` 
(backs off with a debug log if a `methodMissing` handler is already installed) 
— commit `2dd18808ff`. I did not adopt the static-side guard 
(`getStaticMetaMethod('methodMissing', ...)`): wrote a few isolated 
`ExpandoMetaClass` probe scripts (no GORM involved) and confirmed 
`getStaticMetaMethod` can never see a static `methodMissing` handler installed 
via the standard `mc.static.methodMissing = {...}` idiom, regardless of install 
order — even though the handler is functionally live (`invokeStaticMethod` 
proves it dispatches). Your branch has the identical static-side check; it 
looks like it never actually fires there either. Happy to share the repro 
scripts if useful — didn't want to ship a check that looks protective but is 
dead code.



##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java:
##########
@@ -170,8 +215,13 @@ public Session getCurrentSession() throws 
ConnectionNotFoundException {
         return DatastoreUtils.doGetSession(this, false);
     }
 
+    /**
+     * @return Whether {@link #getCurrentSession()} would return an existing 
session rather than
+     * opening a new one: a validated (still connected) session is bound to 
the current thread.
+     * Delegates to {@link #getSessionResolver()} so both methods read the 
same state.
+     */
     public boolean hasCurrentSession() {
-        return TransactionSynchronizationManager.hasResource(this);
+        return getSessionResolver().resolve() != null;

Review Comment:
   Agreed the mutation shouldn't live in a predicate. Added 
`SessionResolver.hasResolvedSession()` — implemented by iterating 
`SessionHolder.getSessions()` (the genuinely read-only 
`Collections.unmodifiableCollection` view) checking `isConnected()`, same 
accuracy as `resolve()` but zero mutation. 
`AbstractDatastore.hasCurrentSession()` now delegates to that instead of 
`resolve() != null`. Commit `3707a82f63`.
   
   Audited all 8 call sites first: the 2 `ActiveSessionDatastoreSelector` 
routing-scan sites needed the pure check; the other 6 all immediately call 
`getCurrentSession()` right after anyway, which independently re-validates via 
its own `getValidatedSession()`, so none of them were actually relying on the 
old eviction side effect. Added a regression spec proving a 
`hasCurrentSession()` probe on datastore A leaves datastore B's session binding 
completely untouched (present, not evicted).



##########
grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/FirstAndLastMethodSpec.groovy:
##########
@@ -163,7 +163,7 @@ class FirstAndLastMethodSpec extends GrailsDataTckSpec {
     }
 
     @PendingFeatureIf(
-            value = { System.getProperty('hibernate5.gorm.suite') },
+            value = { System.getProperty('hibernate5.gorm.suite') || 
System.getProperty('hibernate7.gorm.suite') },

Review Comment:
   Fixed the regression instead of widening the exclusion. Root cause: 
`GormStaticApi.first(Map)`/`last(Map)` (shared, not H7-specific — H5 lost the 
same override at merge-base too, both now route through the generic method) 
force-applies `max:1`/`order` at the DB level even when a composite-key entity 
has no derivable identity to sort by, so it returned an arbitrary row instead 
of falling back to natural/insertion order. Fixed generically: only force 
max/order when a sort key exists (user-supplied or derived from a simple 
identity); otherwise fetch normally and index `[0]`/`[-1]`, matching what the 
removed H7-specific override used to do. Reverted the `@PendingFeatureIf` back 
to its original H5-only condition — H7's composite-key case now passes 12/12; 
H5's own separate pre-existing skip is correctly untouched. Commit `da09401c4d`.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApi.groovy:
##########
@@ -20,76 +20,188 @@ package org.grails.datastore.gorm
 
 import java.lang.reflect.Method
 import java.lang.reflect.Modifier
+import java.util.concurrent.ConcurrentHashMap
 
 import groovy.transform.CompileDynamic
 import groovy.transform.CompileStatic
 
+import grails.gorm.MultiTenant
+import grails.gorm.multitenancy.CurrentTenantHolder
+import grails.gorm.multitenancy.Tenants
 import org.grails.datastore.gorm.utils.ReflectionUtils
 import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.DatastoreUtils
+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
 
 /**
- * Abstract GORM API provider.
+ * Abstract base class for GORM API objects
  *
  * @author Graeme Rocher
- * @param <D> the entity/domain class
  * @since 1.0
  */
 @CompileStatic
 abstract class AbstractGormApi<D> extends AbstractDatastoreApi {
 
-    static final List<String> EXCLUDES = [
-        'setProperty',
-        'getProperty',
-        'getMetaClass',
-        'setMetaClass',
-        'invokeMethod',
-        'getMethods',
-        'getExtendedMethods',
-        'wait',
-        'equals',
-        'toString',
-        'hashCode',
-        'getClass',
-        'notify',
-        'notifyAll',
-        'setTransactionManager'
+    protected static final List<String> EXCLUDES = [
+        'wait', 'notify', 'notifyAll', 'toString', 'hashCode', 'equals', 
'getClass',
+        'getMetaClass', 'setMetaClass', 'getProperty', 'setProperty', 
'invokeMethod'
     ]
 
+    private static final Map<Class, List<Method>> METHODS_CACHE = new 
ConcurrentHashMap<>()
+    private static final Map<Class, List<Method>> EXTENDED_METHODS_CACHE = new 
ConcurrentHashMap<>()
+
     protected Class<D> persistentClass
+    protected final GormRegistry registry
+    protected final String qualifier
+    protected MappingContext mappingContext
+
+    @Deprecated
     protected PersistentEntity persistentEntity
+
     private List<Method> methods
     private List<Method> extendedMethods
 
     AbstractGormApi(Class<D> persistentClass, Datastore datastore) {
+        this(persistentClass, datastore, (GormRegistry) null)
+    }
+
+    AbstractGormApi(Class<D> persistentClass, Datastore datastore, 
GormRegistry registry) {
         super(datastore)
         this.persistentClass = persistentClass
-        this.persistentEntity = 
datastore.getMappingContext().getPersistentEntity(persistentClass.name)
+        this.registry = registry ?: GormRegistry.instance
+        this.qualifier = ConnectionSource.DEFAULT
+        this.mappingContext = datastore?.mappingContext
+        this.persistentEntity = 
datastore?.mappingContext?.getPersistentEntity(persistentClass?.name)
     }
 
-    AbstractGormApi(Class<D> persistentClass, MappingContext mappingContext) {
-        super(null)
+    AbstractGormApi(Class<D> persistentClass, MappingContext mappingContext, 
DatastoreResolver datastoreResolver) {
+        this(persistentClass, mappingContext, datastoreResolver, (String) 
null, (GormRegistry) null)
+    }
+
+    AbstractGormApi(Class<D> persistentClass, MappingContext mappingContext, 
DatastoreResolver datastoreResolver, String qualifier, GormRegistry registry) {
+        super(datastoreResolver)
         this.persistentClass = persistentClass
-        this.persistentEntity = 
mappingContext.getPersistentEntity(persistentClass.name)
+        this.registry = registry ?: GormRegistry.instance
+        this.qualifier = qualifier ?: ConnectionSource.DEFAULT
+        this.mappingContext = mappingContext
+        this.persistentEntity = 
mappingContext?.getPersistentEntity(persistentClass?.name)
     }
 
-    @CompileDynamic
-    protected initializeMethods(clazz) {
-        while (clazz != Object) {
-            final methodsToAdd = clazz.declaredMethods.findAll { Method m ->
-                def mods = m.getModifiers()
-                !m.isSynthetic() && !Modifier.isStatic(mods) && 
Modifier.isPublic(mods) &&
-                        !AbstractGormApi.EXCLUDES.contains(m.name)
+    @Override
+    protected <T1> T1 execute(SessionCallback<T1> callback) {
+        Datastore ds = getDatastore()
+        if (ds == null) {
+            throw new IllegalStateException('Cannot execute session callback 
with null datastore')
+        }
+
+        String currentQualifier = getQualifier()
+        boolean isMultiTenantCapable = ds instanceof 
MultiTenantCapableDatastore
+        boolean isMultiTenantEntity = 
MultiTenant.isAssignableFrom(persistentClass)
+
+        // Check if we have a non-default qualifier
+        if (currentQualifier != null && 
!ConnectionSource.DEFAULT.equals(currentQualifier) && 
!ConnectionSource.OLD_DEFAULT.equalsIgnoreCase(currentQualifier)) {
+            if (isMultiTenantEntity && isMultiTenantCapable) {
+                // 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) {

Review Comment:
   Both fixed. Exception-as-control-flow: added 
`ConnectionSourceNameResolver.isConnectionSourceName(datastore, name)` — checks 
the datastore's declared connection-source names directly, no throwing — and 
swapped the try/catch for that non-throwing call. Commit `b07c5d7f95`, +5 new 
specs on `ConnectionSourceNameResolverSpec`.
   
   On the `tenantId.toString()` flattening: traced the actual data flow through 
`GormRegistry.resolveStaticApi` before deciding what 'fixed' should mean here. 
In the hot-path DISCRIMINATOR-mode case, that flattened string never resolves 
to a real registered api (both priority-1/2 lookups miss), falls through to the 
entity's own DEFAULT api, and `executeQualified`'s `qualifiedApi != this` check 
short-circuits before ever re-entering with the flattened string — so in that 
mode it's a wasted `String` allocation, not a live correctness bug. The `1L` vs 
`"1"` collision risk you named is real, but only manifests in DATABASE/SCHEMA 
mode, where it's a property of `GormRegistry`'s entire String-keyed per-tenant 
registration surface (`entityDatastores`, `staticApiRegistry`, 
`instanceApiRegistry`, `validationApiRegistry`), not something unique to this 
one call site — widening key types across that whole surface is a materially 
bigger change than this item's scope. Documented the constraint 
 in a comment at the call site instead. For what it's worth, your own fix 
branch left the identical line unchanged too, which is part of why I read this 
as genuinely out of scope for a targeted fix here rather than something I was 
skipping.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy:
##########
@@ -0,0 +1,992 @@
+/*
+ *  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.grails.datastore.gorm
+
+import java.util.concurrent.ConcurrentHashMap
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+
+import org.springframework.transaction.PlatformTransactionManager
+
+import grails.gorm.MultiTenant
+import grails.gorm.multitenancy.CurrentTenantHolder
+import org.grails.datastore.gorm.finders.FinderMethod
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.core.connections.ConnectionSourcesSupport
+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
+import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
+import 
org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
+import org.grails.datastore.mapping.reflect.NameUtils
+import org.grails.datastore.mapping.transactions.TransactionCapableDatastore
+
+/**
+ * A registry of GORM API objects. This registry is used to decouple the API
+ * objects from the static state in GormEnhancer.
+ *
+ * It implements an O(M+N) memory strategy where:
+ * M = Number of Entities
+ * N = Number of Connections (Tenants)
+ *
+ * @author Walter Duque de Estrada
+ * @since 8.0.0
+ */
+@Slf4j
+@CompileStatic
+class GormRegistry {
+
+    private static final GormRegistry instance = new GormRegistry()
+    private final GormApiFactory defaultApiFactory = new 
DefaultGormApiFactory()
+    final GormApiResolver apiResolver = new GormApiResolver(this)
+    final GormStaticApiRegistry staticApiRegistry = new 
GormStaticApiRegistry(this)
+    final GormInstanceApiRegistry instanceApiRegistry = new 
GormInstanceApiRegistry(this)
+    final GormValidationApiRegistry validationApiRegistry = new 
GormValidationApiRegistry(this)
+
+    final Map<String, Datastore> datastoresByQualifier = new 
ConcurrentHashMap<>()
+    private final Map<String, Map<String, Datastore>> entityDatastores = new 
ConcurrentHashMap<>()
+    private final Map<Class, String> normalizedEntityKeysByClass = new 
ConcurrentHashMap<>()
+    private final Map<String, String> normalizedEntityKeysByName = new 
ConcurrentHashMap<>()
+    private final Map<String, String> normalizedQualifiers = new 
ConcurrentHashMap<>()
+    final Map<Class, Datastore> datastoresByType = new ConcurrentHashMap<>()
+    private final Map<Class, GormApiFactory> apiFactoriesByDatastoreType = new 
ConcurrentHashMap<>()
+    final Set<Datastore> allDatastores = Collections.newSetFromMap(new 
ConcurrentHashMap<Datastore, Boolean>())
+
+    static GormRegistry getInstance() {
+        return instance
+    }
+
+    /**
+     * @return The default datastore
+     */
+    Datastore getDefaultDatastore() {
+        return datastoresByQualifier.get(ConnectionSource.DEFAULT)
+    }
+
+    /**
+     * Resets the registry.
+     * Nominally unused in core mapping runtime code, but heavily used by 
testing frameworks to reset state between spec executions.
+     */
+    static void reset() {
+        instance.resetInstance()
+    }
+
+    private void resetInstance() {
+        staticApiRegistry.clear()
+        instanceApiRegistry.clear()
+        validationApiRegistry.clear()
+        datastoresByQualifier.clear()
+        entityDatastores.clear()
+        normalizedEntityKeysByClass.clear()
+        normalizedEntityKeysByName.clear()
+        normalizedQualifiers.clear()
+        datastoresByType.clear()
+        apiFactoriesByDatastoreType.clear()
+        allDatastores.clear()
+        GormEnhancerRegistry.getInstance().clearPreferredDatastore()
+        GormEnhancerRegistry.getInstance().clearResolvingDatastoreDepth()
+    }
+
+    static <D> GormStaticApi<D> findStaticApi(Class<D> entity) {
+        instance.resolveStaticApi(entity, (String) null)
+    }
+
+    static <D> GormStaticApi<D> findStaticApi(Class<D> entity, String 
qualifier) {
+        instance.resolveStaticApi(entity, qualifier)
+    }
+
+    static <D> GormInstanceApi<D> findInstanceApi(Class<D> entity) {
+        instance.resolveInstanceApi(entity, (String) null)
+    }
+
+    static <D> GormInstanceApi<D> findInstanceApi(Class<D> entity, String 
qualifier) {
+        instance.resolveInstanceApi(entity, qualifier)
+    }
+
+    static <D> GormValidationApi<D> findValidationApi(Class<D> entity) {
+        instance.resolveValidationApi(entity, (String) null)
+    }
+
+    static <D> GormValidationApi<D> findValidationApi(Class<D> entity, String 
qualifier) {
+        instance.resolveValidationApi(entity, qualifier)
+    }
+
+    static Datastore findDatastore(Class entity) {
+        instance.apiResolver.findDatastore(entity, (String) null)
+    }
+
+    static Datastore findDatastore(Class entity, String qualifier) {
+        instance.apiResolver.findDatastore(entity, qualifier)
+    }
+
+    /**
+     * Registers a custom GormApiFactory for a specific datastore type.
+     * Nominally unused within the core mapping module, but invoked 
dynamically by external datastore implementations (e.g. Hibernate, MongoDB) to 
customize API generation.
+     */
+    void registerApiFactory(Class datastoreType, GormApiFactory factory) {
+        apiFactoriesByDatastoreType.put(datastoreType, factory)
+    }
+
+    GormApiFactory getApiFactory(Datastore datastore) {
+        GormApiFactory factory = 
apiFactoriesByDatastoreType.get(datastore.getClass())
+        if (factory == null) {
+            for (Map.Entry<Class, GormApiFactory> entry in 
apiFactoriesByDatastoreType.entrySet()) {
+                if (entry.key.isInstance(datastore)) {
+                    return entry.value
+                }
+            }
+            return defaultApiFactory
+        }
+        return factory
+    }
+
+    /**
+     * Finds a single transaction manager if only one datastore is registered.
+     * Nominally unused, but invoked at compile-time by transactional AST 
transformations.
+     */
+    PlatformTransactionManager findSingleTransactionManager() {
+        return findSingleTransactionManager(ConnectionSource.DEFAULT)
+    }
+
+    /**
+     * Finds a single transaction manager for a specific qualifier.
+     * Nominally unused, but invoked at compile-time by transactional AST 
transformations.
+     */
+    PlatformTransactionManager findSingleTransactionManager(String qualifier) {
+        Datastore ds = getDatastoreByString((String) null, qualifier)
+        if (ds == null) {
+            if (defaultDatastore == null) {
+                throw new IllegalStateException('No GORM implementations 
configured. Ensure GORM has been initialized correctly')
+            }
+            return null
+        }
+        if (ds instanceof TransactionCapableDatastore) {
+            return ((TransactionCapableDatastore) ds).transactionManager
+        }
+        return null
+    }
+
+    /**
+     * Finds a transaction manager for a specific entity class and qualifier.
+     * Nominally unused, but invoked at compile-time by transactional/service 
AST transformations.
+     */
+    PlatformTransactionManager findTransactionManager(Class entityClass, 
String qualifier) {
+        Datastore ds = getDatastore(entityClass, qualifier)
+        if (ds == null) {
+            // The qualifier may be a tenant ID rather than a registered 
datastore qualifier
+            // (e.g. DISCRIMINATOR / SCHEMA multi-tenancy). Fall back via the 
full resolver
+            // which understands the multi-tenancy mode and returns the 
correct datastore.
+            ds = apiResolver.findDatastore(entityClass, qualifier)
+        }
+        if (ds == null) {
+            if (defaultDatastore == null) {
+                throw new IllegalStateException('No GORM implementations 
configured. Ensure GORM has been initialized correctly')
+            }
+            return null
+        }
+        if (ds instanceof TransactionCapableDatastore) {
+            return ((TransactionCapableDatastore) ds).transactionManager
+        }
+        return null
+    }
+
+    /**
+     * Finds a transaction manager for a specific entity class.
+     * Nominally unused, but invoked at compile-time by transactional/service 
AST transformations.
+     */
+    PlatformTransactionManager findTransactionManager(Class entityClass) {
+        return findTransactionManager(entityClass, ConnectionSource.DEFAULT)
+    }
+
+    /**
+     * Finds a datastore for a specific qualifier (connection name).
+     */
+    Datastore getDatastore(String qualifier) {
+        return getDatastoreByString((String) null, qualifier)
+    }
+
+    /**
+     * Internal method to avoid redundant normalization.
+     */
+    Datastore getDatastoreDirect(String normalizedClassName, String 
normalizedQualifier) {
+        if (normalizedClassName != null) {
+            Map<String, Datastore> mappedDatastores = 
entityDatastores.get(normalizedClassName)
+            if (mappedDatastores != null) {
+                Datastore ds = mappedDatastores.get(normalizedQualifier)
+                if (ds != null) {
+                    return ds
+                }
+                if (ConnectionSource.DEFAULT.equals(normalizedQualifier) && 
!mappedDatastores.isEmpty()) {
+                    return mappedDatastores.values().iterator().next()

Review Comment:
   Traced this to the removal path specifically — registration already 
deterministically tracks the entity's first declared connection and assigns 
DEFAULT correctly; the arbitrary pick only happened after 
`removeDatastore`/`removeEntityDatastore` stripped an entry and left the map 
non-empty but DEFAULT-less. Reproduced first as a failing test (registered an 
entity on 3 declared connections, removed the one DEFAULT pointed at, asserted 
DEFAULT re-points at the next declared one — failed against unfixed code, 
landing on whichever entry `ConcurrentHashMap`'s hash order happened to hand 
back). Fixed with `entityConnectionOrder` (a declared-order list per entity) 
plus a `repairDefaultRouting` helper called from both removal paths. Commit 
`63c2c20021`.
   
   Also removed the `mappedDatastores.values().iterator().next()` fallback 
itself as defense-in-depth on top of the repair (same change your branch made) 
— any not-yet-found edge case now fails loud instead of silently routing to a 
wrong datastore. Commit `b81bda3f73`. One thing I checked: your branch's 
version of this fix doesn't actually repair the removal-path bug — deleting the 
fallback alone just turns it into DEFAULT resolving to `null` post-removal 
rather than a wrong-but-nonnull datastore, which is a harder failure, not a fix 
for the underlying issue. Kept both pieces (the repair mechanism plus the 
loud-failure fallback) rather than just the latter.



##########
grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/CurrentTenantHolder.groovy:
##########
@@ -0,0 +1,135 @@
+/*
+ *  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 grails.gorm.multitenancy
+
+import groovy.transform.CompileStatic
+
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+
+@CompileStatic
+class CurrentTenantHolder {
+
+    private static final ThreadLocal<Map<Object, Serializable>> 
currentTenantThreadLocal = new ThreadLocal<Map<Object, Serializable>>() {
+        @Override
+        protected Map<Object, Serializable> initialValue() {
+            return new HashMap<>()
+        }
+    }
+
+    /**
+     * @return Obtain the current tenant (fallback for any datastore)
+     */
+    static Serializable get() {
+        def map = currentTenantThreadLocal.get()
+        if (!map.isEmpty()) {
+            return map.values().iterator().next()

Review Comment:
   Made it total rather than dropping it — grepped every module 
(grails-datamapping-core/H5/H7/Mongo/Simple/Neo4j/datastore-core/rx/tck) for 
production callers of the no-arg `get()` and found none, only 2 test files, 
confirming in-tree callers can all pass a datastore already. `get()` now 
collects the distinct bound tenant values; throws `TenantException` (existing 
type, not new) naming the ambiguous tenants when more than one is bound, 
returns the single value otherwise, or `null` if nothing's bound — 
same-tenant-bound-to-multiple-datastores correctly does not throw. Commit 
`4909abfc67`. Reproduced the bug as a failing test first, before implementing. 
This matches your branch's fix for the same comment.
   
   Also added the `grails-doc` coverage you asked for — this whole class is now 
documented in the new multiTenancy.adoc page.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to