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


##########
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()
+                }
+                Datastore qualifierDs = 
datastoresByQualifier.get(normalizedQualifier)
+                if (qualifierDs != null && 
qualifierDs.getMappingContext()?.getPersistentEntity(normalizedClassName) != 
null) {
+                    return qualifierDs
+                }
+                return null
+            }
+        }
+
+        Datastore ds = datastoresByQualifier.get(normalizedQualifier)
+        if (ds == null && 
ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+            if (allDatastores.size() == 1) {
+                return allDatastores.iterator().next()
+            }
+        }
+        return ds
+    }
+
+    /**
+     * Internal method to avoid ambiguity.
+     */
+    Datastore getDatastoreByString(String className, String qualifier) {
+        return getDatastoreDirect(className != null ? 
normalizeEntityKey(className) : null, normalizeQualifier(qualifier))
+    }
+
+    /**
+     * Finds a datastore for a specific entity class.
+     * Part of the public API for external integrations and manual datastore 
lookup.
+     */
+    Datastore getDatastore(Class entityClass) {
+        return getDatastore(entityClass, ConnectionSource.DEFAULT)
+    }
+
+    /**
+     * Finds a datastore for a specific entity class and qualifier.
+     */
+    Datastore getDatastore(Class entityClass, String qualifier) {
+        return getDatastoreByString(entityClass != null ? 
normalizeEntityKey(entityClass) : (String) null, qualifier)
+    }
+
+    /**
+     * Finds a datastore for an entity class name and qualifier.
+     */
+    Datastore getDatastore(String className, String qualifier) {
+        return getDatastoreByString(className, qualifier)
+    }
+
+    /**
+     * Registers GORM APIs for an entity.
+     */
+    void registerApi(String className, GormStaticApi staticApi, 
GormInstanceApi instanceApi, GormValidationApi validationApi) {
+        String normalizedClassName = normalizeEntityKey(className)
+        staticApiRegistry.register(normalizedClassName, staticApi)
+        instanceApiRegistry.register(normalizedClassName, instanceApi)
+        validationApiRegistry.register(normalizedClassName, validationApi)
+    }
+
+    /**
+     * Registers a datastore for a qualifier. (O(N) part)
+     */
+    void registerDatastore(String qualifier, Datastore datastore) {
+        if (datastore == null) return
+        String normalizedQualifier = normalizeQualifier(qualifier)
+        datastoresByQualifier.put(normalizedQualifier, datastore)
+        allDatastores.add(datastore)
+    }
+
+    /**
+     * Initializes a datastore, registering its type and default qualifier.
+     */
+    void initializeDatastore(Datastore datastore) {
+        if (datastore == null) return
+        registerDatastore(ConnectionSource.DEFAULT, datastore)
+        datastoresByType.put(datastore.getClass(), datastore)
+    }
+
+    /**
+     * Registers a datastore.
+     */
+    void registerDatastore(Datastore datastore) {
+        initializeDatastore(datastore)
+    }
+
+    /**
+     * Registers a datastore by its type.
+     * Nominally unused in core mapping runtime code, but used by test suites 
and external integrations.
+     */
+    void registerDatastoreByType(Datastore datastore) {
+        if (datastore == null) return
+        datastoresByType.put(datastore.getClass(), datastore)
+        allDatastores.add(datastore)
+    }
+
+    /**
+     * Registers a datastore by qualifier only, without adding it to the 
global type-based discovery.
+     */
+    void registerDatastoreByQualifier(String qualifier, Datastore datastore) {
+        if (qualifier != null && datastore != null) {
+            datastoresByQualifier.put(normalizeQualifier(qualifier), datastore)
+        }
+    }
+
+    /**
+     * Removes a datastore from discovery by its class type.
+     * Nominally unused in core mapping runtime code, but used by testing 
frameworks to clean up dynamic datastores.
+     */
+    void removeDatastoreByType(Class datastoreType) {
+        if (datastoreType == null) return
+        datastoresByType.remove(datastoreType)
+    }
+
+    /**
+     * Removes a datastore from discovery by its instance type.
+     * Nominally unused in core mapping runtime code, but used by testing 
frameworks to clean up dynamic datastores.
+     */
+    void removeDatastoreByType(Datastore datastore) {
+        if (datastore == null) return
+        removeDatastoreByType(datastore.getClass())
+    }
+
+    /**
+     * Removes a datastore from global discovery (allDatastores and 
datastoresByType)
+     * but keeps it in datastoresByQualifier.
+     * Nominally unused in core mapping runtime code, but used by test suites 
to verify multi-datastore isolation.
+     */
+    void removeDatastoreFromDiscovery(Datastore datastore) {
+        if (datastore == null) return
+        allDatastores.remove(datastore)
+        datastoresByType.remove(datastore.getClass())
+    }
+
+    /**
+     * Completely removes a datastore from the registry.
+     */
+    void removeDatastore(Datastore datastore) {
+        if (datastore == null) return
+        allDatastores.remove(datastore)
+        datastoresByType.remove(datastore.getClass())
+
+        Iterator<Map.Entry<String, Datastore>> it = 
datastoresByQualifier.entrySet().iterator()
+        while (it.hasNext()) {
+            if (it.next().value == datastore) it.remove()
+        }
+
+        for (Map<String, Datastore> entityMap in entityDatastores.values()) {
+            Iterator<Map.Entry<String, Datastore>> eit = 
entityMap.entrySet().iterator()
+            while (eit.hasNext()) {
+                if (eit.next().value == datastore) eit.remove()
+            }
+        }
+
+        staticApiRegistry.removeDatastore(datastore)
+        instanceApiRegistry.removeDatastore(datastore)
+        validationApiRegistry.removeDatastore(datastore)
+
+        // When the last datastore goes away (application shutdown, or 
test/dev-reload cycles),
+        // drop the normalization caches too: normalizedEntityKeysByClass 
holds strong Class
+        // references (classloader retention across reloads) and 
normalizedQualifiers grows by one
+        // entry per tenant identifier ever seen.
+        if (allDatastores.isEmpty()) {
+            normalizedEntityKeysByClass.clear()
+            normalizedEntityKeysByName.clear()
+            normalizedQualifiers.clear()
+        }
+    }
+
+    /**
+     * Removes a datastore for a specific entity.
+     */
+    void removeEntityDatastore(String className, Datastore datastore) {
+        if (className != null && datastore != null) {
+            Map<String, Datastore> entityMap = entityDatastores.get(className)
+            if (entityMap != null) {
+                Iterator<Map.Entry<String, Datastore>> eit = 
entityMap.entrySet().iterator()
+                while (eit.hasNext()) {
+                    if (eit.next().value == datastore) eit.remove()
+                }
+            }
+        }
+    }
+
+    /**
+     * Checks if a specific datastore is explicitly registered for an entity.
+     */
+    boolean isDatastoreRegisteredForEntity(String className, Datastore 
datastore) {
+        if (className != null && datastore != null) {
+            Map<String, Datastore> entityMap = 
entityDatastores.get(normalizeEntityKey(className))
+            if (entityMap != null && entityMap.values().contains(datastore)) {
+                return true
+            }
+        }
+        return false
+    }
+
+    GormStaticApi getStaticApi(Class entityClass) {
+        return staticApiRegistry.get(normalizeEntityKey(entityClass))
+    }
+
+    GormInstanceApi getInstanceApi(Class entityClass) {
+        return instanceApiRegistry.get(normalizeEntityKey(entityClass))
+    }
+
+    GormValidationApi getValidationApi(Class entityClass) {
+        return validationApiRegistry.get(normalizeEntityKey(entityClass))
+    }
+
+    GormStaticApi getStaticApi(Class entityClass, String qualifier) {
+        return staticApiRegistry.get(normalizeEntityKey(entityClass), 
normalizeQualifier(qualifier))
+    }
+
+    GormInstanceApi getInstanceApi(Class entityClass, String qualifier) {
+        return instanceApiRegistry.get(normalizeEntityKey(entityClass), 
normalizeQualifier(qualifier))
+    }
+
+    GormValidationApi getValidationApi(Class entityClass, String qualifier) {
+        return validationApiRegistry.get(normalizeEntityKey(entityClass), 
normalizeQualifier(qualifier))
+    }
+
+    GormStaticApi resolveStaticApi(Class entityClass) {
+        return resolveStaticApi(entityClass, (String) null)
+    }
+
+    GormStaticApi resolveStaticApi(Class entityClass, String qualifier) {
+        String normalizedClassName = normalizeEntityKey(entityClass)
+        String normalizedQualifier = normalizeQualifier(qualifier)
+
+        if (MultiTenant.isAssignableFrom(entityClass)) {
+            // Priority 1: Explicit qualifier that doesn't match default is 
likely a tenant ID
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                GormStaticApi api = 
staticApiRegistry.getDirect(normalizedClassName, normalizedQualifier)
+                if (api != null) return api
+            }
+
+            // Priority 2: Check current bound tenant if using default 
qualifier
+            Datastore ds = getDatastoreDirect(normalizedClassName, 
normalizedQualifier)
+            if (ds instanceof MultiTenantCapableDatastore) {
+                MultiTenantCapableDatastore mtds = 
(MultiTenantCapableDatastore) ds
+                MultiTenancySettings.MultiTenancyMode mode = 
mtds.getMultiTenancyMode()
+                boolean strictMode = mode == 
MultiTenancySettings.MultiTenancyMode.DATABASE ||
+                        mode == MultiTenancySettings.MultiTenancyMode.SCHEMA
+                Serializable tenantId = CurrentTenantHolder.get(mtds)
+                if (tenantId == null && strictMode) {
+                    try {
+                        tenantId = 
mtds.tenantResolver.resolveTenantIdentifier()
+                    } catch (TenantNotFoundException e) {
+                        throw e
+                    }
+                }
+                if (tenantId != null && 
!ConnectionSource.DEFAULT.equals(tenantId.toString())) {
+                    GormStaticApi api = 
staticApiRegistry.getDirect(normalizedClassName, tenantId.toString())
+                    if (api != null) return api
+                }
+            }
+
+            // Priority 3: Fall back to default API instance if specialized 
one not found,
+            // but keep the qualifier so the API can handle tenant binding
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                GormStaticApi api = 
staticApiRegistry.getDirect(normalizedClassName, ConnectionSource.DEFAULT)
+                if (api != null) return api
+            }
+        }
+
+        return staticApiRegistry.getDirect(normalizedClassName, 
normalizedQualifier)
+    }
+
+    GormInstanceApi resolveInstanceApi(Class entityClass) {
+        return resolveInstanceApi(entityClass, (String) null)
+    }
+
+    GormInstanceApi resolveInstanceApi(Class entityClass, String qualifier) {
+        String normalizedClassName = normalizeEntityKey(entityClass)
+        String normalizedQualifier = normalizeQualifier(qualifier)
+
+        if (MultiTenant.isAssignableFrom(entityClass)) {
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                GormInstanceApi api = 
instanceApiRegistry.getDirect(normalizedClassName, normalizedQualifier)
+                if (api != null) return api
+            }
+
+            Datastore ds = getDatastoreDirect(normalizedClassName, 
normalizedQualifier)
+            if (ds instanceof MultiTenantCapableDatastore) {
+                MultiTenantCapableDatastore mtds = 
(MultiTenantCapableDatastore) ds
+                MultiTenancySettings.MultiTenancyMode mode = 
mtds.getMultiTenancyMode()
+                boolean strictMode = mode == 
MultiTenancySettings.MultiTenancyMode.DATABASE ||
+                        mode == MultiTenancySettings.MultiTenancyMode.SCHEMA
+                Serializable tenantId = CurrentTenantHolder.get(mtds)
+                if (tenantId == null && strictMode) {
+                    try {
+                        tenantId = 
mtds.tenantResolver.resolveTenantIdentifier()
+                    } catch (TenantNotFoundException e) {
+                        throw e
+                    }
+                }
+                if (tenantId != null && 
!ConnectionSource.DEFAULT.equals(tenantId.toString())) {
+                    GormInstanceApi api = 
instanceApiRegistry.getDirect(normalizedClassName, tenantId.toString())
+                    if (api != null) return api
+                }
+            }
+
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                GormInstanceApi api = 
instanceApiRegistry.getDirect(normalizedClassName, ConnectionSource.DEFAULT)
+                if (api != null) return api
+            }
+        }
+
+        return instanceApiRegistry.getDirect(normalizedClassName, 
normalizedQualifier)
+    }
+
+    /**
+     * Resolves the validation API for the given entity class.
+     * Nominally unused in core mapping runtime code, but invoked at 
compile-time by GORM's AST transformations.
+     */
+    GormValidationApi resolveValidationApi(Class entityClass) {
+        return resolveValidationApi(entityClass, (String) null)
+    }
+
+    /**
+     * Resolves the validation API for the given entity class and qualifier.
+     * Nominally unused in core mapping runtime code, but invoked at 
compile-time by GORM's AST transformations.
+     */
+    GormValidationApi resolveValidationApi(Class entityClass, String 
qualifier) {
+        String normalizedClassName = normalizeEntityKey(entityClass)
+        String normalizedQualifier = normalizeQualifier(qualifier)
+
+        if (MultiTenant.isAssignableFrom(entityClass)) {
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                GormValidationApi api = 
validationApiRegistry.getDirect(normalizedClassName, normalizedQualifier)
+                if (api != null) return api
+            }
+
+            Datastore ds = getDatastoreDirect(normalizedClassName, 
normalizedQualifier)
+            if (ds instanceof MultiTenantCapableDatastore) {
+                MultiTenantCapableDatastore mtds = 
(MultiTenantCapableDatastore) ds
+                MultiTenancySettings.MultiTenancyMode mode = 
mtds.getMultiTenancyMode()
+                boolean strictMode = mode == 
MultiTenancySettings.MultiTenancyMode.DATABASE ||
+                        mode == MultiTenancySettings.MultiTenancyMode.SCHEMA
+                Serializable tenantId = CurrentTenantHolder.get(mtds)
+                if (tenantId == null && strictMode) {
+                    try {
+                        tenantId = 
mtds.tenantResolver.resolveTenantIdentifier()
+                    } catch (TenantNotFoundException e) {
+                        throw e
+                    }
+                }
+                if (tenantId != null && 
!ConnectionSource.DEFAULT.equals(tenantId.toString())) {
+                    GormValidationApi api = 
validationApiRegistry.getDirect(normalizedClassName, tenantId.toString())
+                    if (api != null) return api
+                }
+            }
+
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                GormValidationApi api = 
validationApiRegistry.getDirect(normalizedClassName, ConnectionSource.DEFAULT)
+                if (api != null) return api
+            }
+        }
+
+        return validationApiRegistry.getDirect(normalizedClassName, 
normalizedQualifier)
+    }
+
+    GormStaticApi getStaticApi(String className) {
+        return staticApiRegistry.get(normalizeEntityKey(className))
+    }
+
+    GormStaticApi getStaticApi(String className, String qualifier) {
+        return staticApiRegistry.get(normalizeEntityKey(className), 
normalizeQualifier(qualifier))
+    }
+
+    GormInstanceApi getInstanceApi(String className) {
+        return instanceApiRegistry.get(normalizeEntityKey(className))
+    }
+
+    GormInstanceApi getInstanceApi(String className, String qualifier) {
+        return instanceApiRegistry.get(normalizeEntityKey(className), 
normalizeQualifier(qualifier))
+    }
+
+    GormValidationApi getValidationApi(String className) {
+        return validationApiRegistry.get(normalizeEntityKey(className))
+    }
+
+    GormValidationApi getValidationApi(String className, String qualifier) {
+        return validationApiRegistry.get(normalizeEntityKey(className), 
normalizeQualifier(qualifier))
+    }
+
+    private Map<String, Datastore> getInternalMap(Map<String, Map<String, 
Datastore>> rootMap, String key) {
+        Map<String, Datastore> map = rootMap.get(key)
+        if (map == null) {
+            map = new ConcurrentHashMap<String, Datastore>()
+            Map<String, Datastore> prior = rootMap.putIfAbsent(key, map)
+            if (prior != null) {
+                return prior
+            }
+        }
+        return map
+    }
+
+    String normalizeEntityKey(Object entityKey) {
+        if (entityKey == null) {
+            return null
+        }
+        if (entityKey instanceof Class) {
+            Class entityClass = (Class) entityKey
+            String existing = normalizedEntityKeysByClass.get(entityClass)
+            if (existing != null) {
+                return existing
+            }
+            String computed = NameUtils.getClassName(entityClass)
+            String normalized = normalizeEntityKey(computed)
+            if (normalized == null) {
+                return null
+            }
+            String prior = 
normalizedEntityKeysByClass.putIfAbsent(entityClass, normalized)
+            return prior != null ? prior : normalized
+        } else {
+            String className = entityKey.toString()
+            String existing = normalizedEntityKeysByName.get(className)
+            if (existing != null) {
+                return existing
+            }
+            String normalized = className.trim()
+            if (normalized.isEmpty()) {
+                return null
+            }
+            String prior = normalizedEntityKeysByName.putIfAbsent(className, 
normalized)
+            return prior != null ? prior : normalized
+        }
+    }
+
+    /**
+     * @deprecated Use {@code normalizeEntityKey(Class)}.
+     */
+    @Deprecated
+    String normalizeEntityKeyFromClass(Class entityClass) {
+        normalizeEntityKey(entityClass)
+    }
+
+    String normalizeQualifier(String qualifier) {
+        if (qualifier == null) {
+            return ConnectionSource.DEFAULT
+        }
+        String existing = normalizedQualifiers.get(qualifier)
+        if (existing != null) {
+            return existing
+        }
+        String normalized = qualifier.trim()
+        if (normalized.isEmpty() || 
ConnectionSource.OLD_DEFAULT.equalsIgnoreCase(normalized)) {
+            normalized = ConnectionSource.DEFAULT
+        }
+        String prior = normalizedQualifiers.putIfAbsent(qualifier, normalized)
+        return prior != null ? prior : normalized
+    }
+
+    /**
+     * @deprecated Use {@code normalizeQualifier(String)}.
+     */
+    @Deprecated
+    String normalizeQualifierByString(String qualifier) {
+        normalizeQualifier(qualifier)
+    }
+
+    /**
+     * Register API objects for a persistent entity.
+     * Creates and registers StaticApi, InstanceApi, and ValidationApi for the 
given entity.
+     * Part of the public API for external plugins and test environments.
+     *
+     * @param className The entity class name
+     * @param staticApi The static API implementation
+     * @param instanceApi The instance API implementation
+     * @param validationApi The validation API implementation
+     */
+    void registerEntityApis(String className, GormStaticApi staticApi, 
GormInstanceApi instanceApi, GormValidationApi validationApi) {
+        registerApi(className, staticApi, instanceApi, validationApi)
+    }
+
+    /**
+     * Register datastores for a persistent entity across multiple connection 
sources.
+     * Handles entity-specific datastore mappings for multi-tenant and 
multi-datasource scenarios.
+     *
+     * @param className The entity class name
+     * @param datastore The datastore to register
+     * @param connectionSourceNames The connection source names to register 
the datastore for
+     * @param entity The persistent entity (for entity-specific qualifier 
resolution)
+     */
+    void registerEntityDatastores(String className, Object datastore, 
List<String> connectionSourceNames, Object entity) {
+
+        if (datastore == null) return
+        String normalizedClassName = normalizeEntityKey(className)
+        if (normalizedClassName == null) {
+            return
+        }
+
+        Datastore defaultDatastore = (Datastore) datastore
+        List<String> qualifiers = connectionSourceNames ?: 
Collections.singletonList(ConnectionSource.DEFAULT)
+        boolean multiTenantEntity = entity instanceof PersistentEntity && 
((PersistentEntity) entity).isMultiTenant()
+
+        // Accumulate the entity's qualifier routing into a local map and 
publish it with a single
+        // put below: remove-then-repopulate would open a window where a 
concurrent lookup sees no
+        // routing for this entity at all and falls back to the wrong 
datastore.
+        Map<String, Datastore> newEntityDatastores = new ConcurrentHashMap<>()
+
+        Datastore primaryDatastore = defaultDatastore
+
+        // Register datastores for each connection source. For each qualifier, 
attempt to resolve a
+        // connection-specific child datastore. If the resolution falls back 
to the parent (meaning
+        // the qualifier is a runtime tenant ID, not a datasource connection 
name), skip registration
+        // for non-DEFAULT qualifiers in multi-tenant mode so that we do not 
overwrite the correctly
+        // registered child datastores (e.g. those added by 
addTenantForSchemaInternal).
+        for (String connectionSourceName in qualifiers) {
+            String normalizedQualifier = 
normalizeQualifier(connectionSourceName)
+            Datastore qualifierDatastore = defaultDatastore
+            if (defaultDatastore instanceof 
MultipleConnectionSourceCapableDatastore &&
+                    !ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                try {
+                    Datastore resolved = 
((MultipleConnectionSourceCapableDatastore) defaultDatastore)
+                            .getDatastoreForConnection(normalizedQualifier)
+                    if (resolved != null) {
+                        qualifierDatastore = resolved
+                    }
+                } catch (Throwable e) {
+                    // qualifier is not a datasource connection name; keep 
defaultDatastore
+                    log.debug('Ignoring failure resolving connection {} for 
entity {}: {}', normalizedQualifier, className, e.message)
+                }
+            }
+            // Skip non-DEFAULT qualifiers that resolve back to the parent for 
multi-tenant entities.
+            // Those qualifiers are runtime tenant IDs handled at the session 
level, not datasource names.
+            if (multiTenantEntity && 
!ConnectionSource.DEFAULT.equals(normalizedQualifier) &&
+                    qualifierDatastore == defaultDatastore) {
+                continue
+            }
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier) && 
primaryDatastore == defaultDatastore) {
+                primaryDatastore = qualifierDatastore
+            }
+            registerDatastoreByQualifier(normalizedQualifier, 
qualifierDatastore)
+            newEntityDatastores.put(normalizedQualifier, qualifierDatastore)
+        }
+
+        // If the entity does not explicitly include DEFAULT, route its 
unqualified (no-connection)
+        // operations. Real datastores manage a session per connection, so 
DEFAULT goes to the first
+        // explicit connection's datastore. A single-session mock (the 
in-memory datastore used by
+        // unit tests) keeps unqualified operations on the parent it manages — 
its connection
+        // children exist only for explicit access and have no harness-flushed 
session.
+        if (!qualifiers.collect { String it -> normalizeQualifier(it) 
}.contains(ConnectionSource.DEFAULT)) {
+            Datastore defaultConnectionDatastore = primaryDatastore
+            if (defaultDatastore instanceof 
MultipleConnectionSourceCapableDatastore &&
+                    !((MultipleConnectionSourceCapableDatastore) 
defaultDatastore).routesUnqualifiedToMappedConnection()) {
+                defaultConnectionDatastore = defaultDatastore
+            }
+            if (defaultConnectionDatastore != null) {
+                newEntityDatastores.put(ConnectionSource.DEFAULT, 
defaultConnectionDatastore)
+            }
+        }
+
+        if (newEntityDatastores.isEmpty()) {
+            entityDatastores.remove(normalizedClassName)
+        }
+        else {
+            entityDatastores.put(normalizedClassName, newEntityDatastores)
+        }
+    }
+
+    /**
+     * Registers an entity-specific datastore override.
+     */
+    void registerEntityDatastore(String className, String qualifier, Datastore 
datastore) {
+        if (datastore != null) {
+            String normalizedClassName = normalizeEntityKey(className)
+            if (normalizedClassName == null) {
+                return
+            }
+            String normalizedQualifier = normalizeQualifier(qualifier)
+            getInternalMap(entityDatastores, 
normalizedClassName).put(normalizedQualifier, datastore)
+        }
+    }
+
+    /**
+     * Creates dynamic finders for the default datastore
+     *
+     * @return List of finder methods
+     */
+    List<FinderMethod> createDynamicFinders(Datastore targetDatastore) {
+        createDynamicFinders(new DatastoreResolver() {
+            @Override
+            Datastore resolve() {
+                targetDatastore
+            }
+        }, targetDatastore.getMappingContext())
+    }
+
+    /**
+     * Creates dynamic finders using the given resolver and mapping context.
+     *
+     * @param resolver The datastore resolver
+     * @param mappingContext The mapping context
+     * @return List of finder methods
+     */
+    List<FinderMethod> createDynamicFinders(DatastoreResolver resolver, 
MappingContext mappingContext) {
+        Datastore ds = resolver.resolve()
+        if (ds != null) {
+            return getApiFactory(ds).createDynamicFinders(resolver, 
mappingContext)
+        }
+        return []
+    }
+
+    /**
+     * Create a DatastoreResolver for a class and optional qualifier.
+     */
+    DatastoreResolver createClassDatastoreResolver(Class cls, String qualifier 
= ConnectionSource.DEFAULT) {
+        String normalizedClassName = normalizeEntityKey(cls)
+        String normalizedQualifier = normalizeQualifier(qualifier)
+        return new DatastoreResolver() {
+            @Override
+            Datastore resolve() {
+                apiResolver.findDatastore(cls, normalizedQualifier)
+            }
+        }
+    }
+
+    /**
+     * Create a GormStaticApi instance.
+     * Uses a bound resolver (always returns the given datastore) at 
registration time so that
+     * tenant-resolving DatastoreResolvers are not invoked before any tenant 
context is active.
+     */
+    GormStaticApi createStaticApi(Class cls, Datastore datastore, 
DatastoreResolver resolver, String qualifier) {

Review Comment:
   Confirmed dead and removed — traced every call site first. 
`createStaticApi`/`createInstanceApi`/`createValidationApi` all discarded the 
passed-in `resolver` in favor of their own `boundResolver` closure; the 
`resolver` built in `registerEntity` and the `normalizedClassName` local inside 
`createClassDatastoreResolver` were both genuinely unused. 
`createClassDatastoreResolver` itself stays — it has real callers elsewhere 
(`GormStaticApiRegistry`, `GormInstanceApiRegistry`, `GormInstanceApi`, 
`GormValidationApiRegistry`) for actual tenant-resolving `qualify()` lookups. 
Commit `709d72a574`, matches your branch, no conflict. Pure dead-parameter 
removal with zero observable behavior change, so no new tests — existing 
`registerEntity` coverage across the whole suite already exercises the path.



##########
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()
+                }
+                Datastore qualifierDs = 
datastoresByQualifier.get(normalizedQualifier)
+                if (qualifierDs != null && 
qualifierDs.getMappingContext()?.getPersistentEntity(normalizedClassName) != 
null) {
+                    return qualifierDs
+                }
+                return null
+            }
+        }
+
+        Datastore ds = datastoresByQualifier.get(normalizedQualifier)
+        if (ds == null && 
ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+            if (allDatastores.size() == 1) {
+                return allDatastores.iterator().next()
+            }
+        }
+        return ds
+    }
+
+    /**
+     * Internal method to avoid ambiguity.
+     */
+    Datastore getDatastoreByString(String className, String qualifier) {
+        return getDatastoreDirect(className != null ? 
normalizeEntityKey(className) : null, normalizeQualifier(qualifier))
+    }
+
+    /**
+     * Finds a datastore for a specific entity class.
+     * Part of the public API for external integrations and manual datastore 
lookup.
+     */
+    Datastore getDatastore(Class entityClass) {
+        return getDatastore(entityClass, ConnectionSource.DEFAULT)
+    }
+
+    /**
+     * Finds a datastore for a specific entity class and qualifier.
+     */
+    Datastore getDatastore(Class entityClass, String qualifier) {
+        return getDatastoreByString(entityClass != null ? 
normalizeEntityKey(entityClass) : (String) null, qualifier)
+    }
+
+    /**
+     * Finds a datastore for an entity class name and qualifier.
+     */
+    Datastore getDatastore(String className, String qualifier) {
+        return getDatastoreByString(className, qualifier)
+    }
+
+    /**
+     * Registers GORM APIs for an entity.
+     */
+    void registerApi(String className, GormStaticApi staticApi, 
GormInstanceApi instanceApi, GormValidationApi validationApi) {
+        String normalizedClassName = normalizeEntityKey(className)
+        staticApiRegistry.register(normalizedClassName, staticApi)
+        instanceApiRegistry.register(normalizedClassName, instanceApi)
+        validationApiRegistry.register(normalizedClassName, validationApi)
+    }
+
+    /**
+     * Registers a datastore for a qualifier. (O(N) part)
+     */
+    void registerDatastore(String qualifier, Datastore datastore) {
+        if (datastore == null) return
+        String normalizedQualifier = normalizeQualifier(qualifier)
+        datastoresByQualifier.put(normalizedQualifier, datastore)
+        allDatastores.add(datastore)
+    }
+
+    /**
+     * Initializes a datastore, registering its type and default qualifier.
+     */
+    void initializeDatastore(Datastore datastore) {
+        if (datastore == null) return
+        registerDatastore(ConnectionSource.DEFAULT, datastore)
+        datastoresByType.put(datastore.getClass(), datastore)
+    }
+
+    /**
+     * Registers a datastore.
+     */
+    void registerDatastore(Datastore datastore) {
+        initializeDatastore(datastore)
+    }
+
+    /**
+     * Registers a datastore by its type.
+     * Nominally unused in core mapping runtime code, but used by test suites 
and external integrations.
+     */
+    void registerDatastoreByType(Datastore datastore) {
+        if (datastore == null) return
+        datastoresByType.put(datastore.getClass(), datastore)
+        allDatastores.add(datastore)
+    }
+
+    /**
+     * Registers a datastore by qualifier only, without adding it to the 
global type-based discovery.
+     */
+    void registerDatastoreByQualifier(String qualifier, Datastore datastore) {
+        if (qualifier != null && datastore != null) {
+            datastoresByQualifier.put(normalizeQualifier(qualifier), datastore)
+        }
+    }
+
+    /**
+     * Removes a datastore from discovery by its class type.
+     * Nominally unused in core mapping runtime code, but used by testing 
frameworks to clean up dynamic datastores.
+     */
+    void removeDatastoreByType(Class datastoreType) {
+        if (datastoreType == null) return
+        datastoresByType.remove(datastoreType)
+    }
+
+    /**
+     * Removes a datastore from discovery by its instance type.
+     * Nominally unused in core mapping runtime code, but used by testing 
frameworks to clean up dynamic datastores.
+     */
+    void removeDatastoreByType(Datastore datastore) {
+        if (datastore == null) return
+        removeDatastoreByType(datastore.getClass())
+    }
+
+    /**
+     * Removes a datastore from global discovery (allDatastores and 
datastoresByType)
+     * but keeps it in datastoresByQualifier.
+     * Nominally unused in core mapping runtime code, but used by test suites 
to verify multi-datastore isolation.
+     */
+    void removeDatastoreFromDiscovery(Datastore datastore) {
+        if (datastore == null) return
+        allDatastores.remove(datastore)
+        datastoresByType.remove(datastore.getClass())
+    }
+
+    /**
+     * Completely removes a datastore from the registry.
+     */
+    void removeDatastore(Datastore datastore) {
+        if (datastore == null) return
+        allDatastores.remove(datastore)
+        datastoresByType.remove(datastore.getClass())
+
+        Iterator<Map.Entry<String, Datastore>> it = 
datastoresByQualifier.entrySet().iterator()
+        while (it.hasNext()) {
+            if (it.next().value == datastore) it.remove()
+        }
+
+        for (Map<String, Datastore> entityMap in entityDatastores.values()) {
+            Iterator<Map.Entry<String, Datastore>> eit = 
entityMap.entrySet().iterator()
+            while (eit.hasNext()) {
+                if (eit.next().value == datastore) eit.remove()
+            }
+        }
+
+        staticApiRegistry.removeDatastore(datastore)
+        instanceApiRegistry.removeDatastore(datastore)
+        validationApiRegistry.removeDatastore(datastore)
+
+        // When the last datastore goes away (application shutdown, or 
test/dev-reload cycles),
+        // drop the normalization caches too: normalizedEntityKeysByClass 
holds strong Class
+        // references (classloader retention across reloads) and 
normalizedQualifiers grows by one
+        // entry per tenant identifier ever seen.
+        if (allDatastores.isEmpty()) {
+            normalizedEntityKeysByClass.clear()
+            normalizedEntityKeysByName.clear()
+            normalizedQualifiers.clear()
+        }
+    }
+
+    /**
+     * Removes a datastore for a specific entity.
+     */
+    void removeEntityDatastore(String className, Datastore datastore) {
+        if (className != null && datastore != null) {
+            Map<String, Datastore> entityMap = entityDatastores.get(className)
+            if (entityMap != null) {
+                Iterator<Map.Entry<String, Datastore>> eit = 
entityMap.entrySet().iterator()
+                while (eit.hasNext()) {
+                    if (eit.next().value == datastore) eit.remove()
+                }
+            }
+        }
+    }
+
+    /**
+     * Checks if a specific datastore is explicitly registered for an entity.
+     */
+    boolean isDatastoreRegisteredForEntity(String className, Datastore 
datastore) {
+        if (className != null && datastore != null) {
+            Map<String, Datastore> entityMap = 
entityDatastores.get(normalizeEntityKey(className))
+            if (entityMap != null && entityMap.values().contains(datastore)) {
+                return true
+            }
+        }
+        return false
+    }
+
+    GormStaticApi getStaticApi(Class entityClass) {
+        return staticApiRegistry.get(normalizeEntityKey(entityClass))
+    }
+
+    GormInstanceApi getInstanceApi(Class entityClass) {
+        return instanceApiRegistry.get(normalizeEntityKey(entityClass))
+    }
+
+    GormValidationApi getValidationApi(Class entityClass) {
+        return validationApiRegistry.get(normalizeEntityKey(entityClass))
+    }
+
+    GormStaticApi getStaticApi(Class entityClass, String qualifier) {
+        return staticApiRegistry.get(normalizeEntityKey(entityClass), 
normalizeQualifier(qualifier))
+    }
+
+    GormInstanceApi getInstanceApi(Class entityClass, String qualifier) {
+        return instanceApiRegistry.get(normalizeEntityKey(entityClass), 
normalizeQualifier(qualifier))
+    }
+
+    GormValidationApi getValidationApi(Class entityClass, String qualifier) {
+        return validationApiRegistry.get(normalizeEntityKey(entityClass), 
normalizeQualifier(qualifier))
+    }
+
+    GormStaticApi resolveStaticApi(Class entityClass) {
+        return resolveStaticApi(entityClass, (String) null)
+    }
+
+    GormStaticApi resolveStaticApi(Class entityClass, String qualifier) {
+        String normalizedClassName = normalizeEntityKey(entityClass)
+        String normalizedQualifier = normalizeQualifier(qualifier)
+
+        if (MultiTenant.isAssignableFrom(entityClass)) {
+            // Priority 1: Explicit qualifier that doesn't match default is 
likely a tenant ID
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                GormStaticApi api = 
staticApiRegistry.getDirect(normalizedClassName, normalizedQualifier)
+                if (api != null) return api
+            }
+
+            // Priority 2: Check current bound tenant if using default 
qualifier
+            Datastore ds = getDatastoreDirect(normalizedClassName, 
normalizedQualifier)
+            if (ds instanceof MultiTenantCapableDatastore) {
+                MultiTenantCapableDatastore mtds = 
(MultiTenantCapableDatastore) ds
+                MultiTenancySettings.MultiTenancyMode mode = 
mtds.getMultiTenancyMode()
+                boolean strictMode = mode == 
MultiTenancySettings.MultiTenancyMode.DATABASE ||
+                        mode == MultiTenancySettings.MultiTenancyMode.SCHEMA
+                Serializable tenantId = CurrentTenantHolder.get(mtds)
+                if (tenantId == null && strictMode) {
+                    try {
+                        tenantId = 
mtds.tenantResolver.resolveTenantIdentifier()
+                    } catch (TenantNotFoundException e) {
+                        throw e
+                    }
+                }
+                if (tenantId != null && 
!ConnectionSource.DEFAULT.equals(tenantId.toString())) {
+                    GormStaticApi api = 
staticApiRegistry.getDirect(normalizedClassName, tenantId.toString())
+                    if (api != null) return api
+                }
+            }
+
+            // Priority 3: Fall back to default API instance if specialized 
one not found,
+            // but keep the qualifier so the API can handle tenant binding
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                GormStaticApi api = 
staticApiRegistry.getDirect(normalizedClassName, ConnectionSource.DEFAULT)
+                if (api != null) return api
+            }
+        }
+
+        return staticApiRegistry.getDirect(normalizedClassName, 
normalizedQualifier)
+    }
+
+    GormInstanceApi resolveInstanceApi(Class entityClass) {
+        return resolveInstanceApi(entityClass, (String) null)
+    }
+
+    GormInstanceApi resolveInstanceApi(Class entityClass, String qualifier) {
+        String normalizedClassName = normalizeEntityKey(entityClass)
+        String normalizedQualifier = normalizeQualifier(qualifier)
+
+        if (MultiTenant.isAssignableFrom(entityClass)) {
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                GormInstanceApi api = 
instanceApiRegistry.getDirect(normalizedClassName, normalizedQualifier)
+                if (api != null) return api
+            }
+
+            Datastore ds = getDatastoreDirect(normalizedClassName, 
normalizedQualifier)
+            if (ds instanceof MultiTenantCapableDatastore) {
+                MultiTenantCapableDatastore mtds = 
(MultiTenantCapableDatastore) ds
+                MultiTenancySettings.MultiTenancyMode mode = 
mtds.getMultiTenancyMode()
+                boolean strictMode = mode == 
MultiTenancySettings.MultiTenancyMode.DATABASE ||
+                        mode == MultiTenancySettings.MultiTenancyMode.SCHEMA
+                Serializable tenantId = CurrentTenantHolder.get(mtds)
+                if (tenantId == null && strictMode) {
+                    try {
+                        tenantId = 
mtds.tenantResolver.resolveTenantIdentifier()
+                    } catch (TenantNotFoundException e) {
+                        throw e
+                    }
+                }
+                if (tenantId != null && 
!ConnectionSource.DEFAULT.equals(tenantId.toString())) {
+                    GormInstanceApi api = 
instanceApiRegistry.getDirect(normalizedClassName, tenantId.toString())
+                    if (api != null) return api
+                }
+            }
+
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                GormInstanceApi api = 
instanceApiRegistry.getDirect(normalizedClassName, ConnectionSource.DEFAULT)
+                if (api != null) return api
+            }
+        }
+
+        return instanceApiRegistry.getDirect(normalizedClassName, 
normalizedQualifier)
+    }
+
+    /**
+     * Resolves the validation API for the given entity class.
+     * Nominally unused in core mapping runtime code, but invoked at 
compile-time by GORM's AST transformations.
+     */
+    GormValidationApi resolveValidationApi(Class entityClass) {
+        return resolveValidationApi(entityClass, (String) null)
+    }
+
+    /**
+     * Resolves the validation API for the given entity class and qualifier.
+     * Nominally unused in core mapping runtime code, but invoked at 
compile-time by GORM's AST transformations.
+     */
+    GormValidationApi resolveValidationApi(Class entityClass, String 
qualifier) {
+        String normalizedClassName = normalizeEntityKey(entityClass)
+        String normalizedQualifier = normalizeQualifier(qualifier)
+
+        if (MultiTenant.isAssignableFrom(entityClass)) {
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                GormValidationApi api = 
validationApiRegistry.getDirect(normalizedClassName, normalizedQualifier)
+                if (api != null) return api
+            }
+
+            Datastore ds = getDatastoreDirect(normalizedClassName, 
normalizedQualifier)
+            if (ds instanceof MultiTenantCapableDatastore) {
+                MultiTenantCapableDatastore mtds = 
(MultiTenantCapableDatastore) ds
+                MultiTenancySettings.MultiTenancyMode mode = 
mtds.getMultiTenancyMode()
+                boolean strictMode = mode == 
MultiTenancySettings.MultiTenancyMode.DATABASE ||
+                        mode == MultiTenancySettings.MultiTenancyMode.SCHEMA
+                Serializable tenantId = CurrentTenantHolder.get(mtds)
+                if (tenantId == null && strictMode) {
+                    try {
+                        tenantId = 
mtds.tenantResolver.resolveTenantIdentifier()
+                    } catch (TenantNotFoundException e) {
+                        throw e
+                    }
+                }
+                if (tenantId != null && 
!ConnectionSource.DEFAULT.equals(tenantId.toString())) {
+                    GormValidationApi api = 
validationApiRegistry.getDirect(normalizedClassName, tenantId.toString())
+                    if (api != null) return api
+                }
+            }
+
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                GormValidationApi api = 
validationApiRegistry.getDirect(normalizedClassName, ConnectionSource.DEFAULT)
+                if (api != null) return api
+            }
+        }
+
+        return validationApiRegistry.getDirect(normalizedClassName, 
normalizedQualifier)
+    }
+
+    GormStaticApi getStaticApi(String className) {
+        return staticApiRegistry.get(normalizeEntityKey(className))
+    }
+
+    GormStaticApi getStaticApi(String className, String qualifier) {
+        return staticApiRegistry.get(normalizeEntityKey(className), 
normalizeQualifier(qualifier))
+    }
+
+    GormInstanceApi getInstanceApi(String className) {
+        return instanceApiRegistry.get(normalizeEntityKey(className))
+    }
+
+    GormInstanceApi getInstanceApi(String className, String qualifier) {
+        return instanceApiRegistry.get(normalizeEntityKey(className), 
normalizeQualifier(qualifier))
+    }
+
+    GormValidationApi getValidationApi(String className) {
+        return validationApiRegistry.get(normalizeEntityKey(className))
+    }
+
+    GormValidationApi getValidationApi(String className, String qualifier) {
+        return validationApiRegistry.get(normalizeEntityKey(className), 
normalizeQualifier(qualifier))
+    }
+
+    private Map<String, Datastore> getInternalMap(Map<String, Map<String, 
Datastore>> rootMap, String key) {
+        Map<String, Datastore> map = rootMap.get(key)
+        if (map == null) {
+            map = new ConcurrentHashMap<String, Datastore>()
+            Map<String, Datastore> prior = rootMap.putIfAbsent(key, map)
+            if (prior != null) {
+                return prior
+            }
+        }
+        return map
+    }
+
+    String normalizeEntityKey(Object entityKey) {
+        if (entityKey == null) {
+            return null
+        }
+        if (entityKey instanceof Class) {
+            Class entityClass = (Class) entityKey
+            String existing = normalizedEntityKeysByClass.get(entityClass)
+            if (existing != null) {
+                return existing
+            }
+            String computed = NameUtils.getClassName(entityClass)
+            String normalized = normalizeEntityKey(computed)
+            if (normalized == null) {
+                return null
+            }
+            String prior = 
normalizedEntityKeysByClass.putIfAbsent(entityClass, normalized)
+            return prior != null ? prior : normalized
+        } else {
+            String className = entityKey.toString()
+            String existing = normalizedEntityKeysByName.get(className)
+            if (existing != null) {
+                return existing
+            }
+            String normalized = className.trim()
+            if (normalized.isEmpty()) {
+                return null
+            }
+            String prior = normalizedEntityKeysByName.putIfAbsent(className, 
normalized)
+            return prior != null ? prior : normalized
+        }
+    }
+
+    /**
+     * @deprecated Use {@code normalizeEntityKey(Class)}.
+     */
+    @Deprecated
+    String normalizeEntityKeyFromClass(Class entityClass) {
+        normalizeEntityKey(entityClass)
+    }
+
+    String normalizeQualifier(String qualifier) {
+        if (qualifier == null) {
+            return ConnectionSource.DEFAULT
+        }
+        String existing = normalizedQualifiers.get(qualifier)
+        if (existing != null) {
+            return existing
+        }
+        String normalized = qualifier.trim()
+        if (normalized.isEmpty() || 
ConnectionSource.OLD_DEFAULT.equalsIgnoreCase(normalized)) {
+            normalized = ConnectionSource.DEFAULT
+        }
+        String prior = normalizedQualifiers.putIfAbsent(qualifier, normalized)
+        return prior != null ? prior : normalized
+    }
+
+    /**
+     * @deprecated Use {@code normalizeQualifier(String)}.
+     */
+    @Deprecated
+    String normalizeQualifierByString(String qualifier) {
+        normalizeQualifier(qualifier)
+    }
+
+    /**
+     * Register API objects for a persistent entity.
+     * Creates and registers StaticApi, InstanceApi, and ValidationApi for the 
given entity.
+     * Part of the public API for external plugins and test environments.
+     *
+     * @param className The entity class name
+     * @param staticApi The static API implementation
+     * @param instanceApi The instance API implementation
+     * @param validationApi The validation API implementation
+     */
+    void registerEntityApis(String className, GormStaticApi staticApi, 
GormInstanceApi instanceApi, GormValidationApi validationApi) {
+        registerApi(className, staticApi, instanceApi, validationApi)
+    }
+
+    /**
+     * Register datastores for a persistent entity across multiple connection 
sources.
+     * Handles entity-specific datastore mappings for multi-tenant and 
multi-datasource scenarios.
+     *
+     * @param className The entity class name
+     * @param datastore The datastore to register
+     * @param connectionSourceNames The connection source names to register 
the datastore for
+     * @param entity The persistent entity (for entity-specific qualifier 
resolution)
+     */
+    void registerEntityDatastores(String className, Object datastore, 
List<String> connectionSourceNames, Object entity) {
+
+        if (datastore == null) return
+        String normalizedClassName = normalizeEntityKey(className)
+        if (normalizedClassName == null) {
+            return
+        }
+
+        Datastore defaultDatastore = (Datastore) datastore
+        List<String> qualifiers = connectionSourceNames ?: 
Collections.singletonList(ConnectionSource.DEFAULT)
+        boolean multiTenantEntity = entity instanceof PersistentEntity && 
((PersistentEntity) entity).isMultiTenant()
+
+        // Accumulate the entity's qualifier routing into a local map and 
publish it with a single
+        // put below: remove-then-repopulate would open a window where a 
concurrent lookup sees no
+        // routing for this entity at all and falls back to the wrong 
datastore.
+        Map<String, Datastore> newEntityDatastores = new ConcurrentHashMap<>()
+
+        Datastore primaryDatastore = defaultDatastore
+
+        // Register datastores for each connection source. For each qualifier, 
attempt to resolve a
+        // connection-specific child datastore. If the resolution falls back 
to the parent (meaning
+        // the qualifier is a runtime tenant ID, not a datasource connection 
name), skip registration
+        // for non-DEFAULT qualifiers in multi-tenant mode so that we do not 
overwrite the correctly
+        // registered child datastores (e.g. those added by 
addTenantForSchemaInternal).
+        for (String connectionSourceName in qualifiers) {
+            String normalizedQualifier = 
normalizeQualifier(connectionSourceName)
+            Datastore qualifierDatastore = defaultDatastore
+            if (defaultDatastore instanceof 
MultipleConnectionSourceCapableDatastore &&
+                    !ConnectionSource.DEFAULT.equals(normalizedQualifier)) {
+                try {
+                    Datastore resolved = 
((MultipleConnectionSourceCapableDatastore) defaultDatastore)
+                            .getDatastoreForConnection(normalizedQualifier)
+                    if (resolved != null) {
+                        qualifierDatastore = resolved
+                    }
+                } catch (Throwable e) {
+                    // qualifier is not a datasource connection name; keep 
defaultDatastore
+                    log.debug('Ignoring failure resolving connection {} for 
entity {}: {}', normalizedQualifier, className, e.message)
+                }
+            }
+            // Skip non-DEFAULT qualifiers that resolve back to the parent for 
multi-tenant entities.
+            // Those qualifiers are runtime tenant IDs handled at the session 
level, not datasource names.
+            if (multiTenantEntity && 
!ConnectionSource.DEFAULT.equals(normalizedQualifier) &&
+                    qualifierDatastore == defaultDatastore) {
+                continue
+            }
+            if (!ConnectionSource.DEFAULT.equals(normalizedQualifier) && 
primaryDatastore == defaultDatastore) {
+                primaryDatastore = qualifierDatastore
+            }
+            registerDatastoreByQualifier(normalizedQualifier, 
qualifierDatastore)
+            newEntityDatastores.put(normalizedQualifier, qualifierDatastore)
+        }
+
+        // If the entity does not explicitly include DEFAULT, route its 
unqualified (no-connection)
+        // operations. Real datastores manage a session per connection, so 
DEFAULT goes to the first
+        // explicit connection's datastore. A single-session mock (the 
in-memory datastore used by
+        // unit tests) keeps unqualified operations on the parent it manages — 
its connection
+        // children exist only for explicit access and have no harness-flushed 
session.
+        if (!qualifiers.collect { String it -> normalizeQualifier(it) 
}.contains(ConnectionSource.DEFAULT)) {
+            Datastore defaultConnectionDatastore = primaryDatastore
+            if (defaultDatastore instanceof 
MultipleConnectionSourceCapableDatastore &&
+                    !((MultipleConnectionSourceCapableDatastore) 
defaultDatastore).routesUnqualifiedToMappedConnection()) {
+                defaultConnectionDatastore = defaultDatastore
+            }
+            if (defaultConnectionDatastore != null) {
+                newEntityDatastores.put(ConnectionSource.DEFAULT, 
defaultConnectionDatastore)
+            }
+        }
+
+        if (newEntityDatastores.isEmpty()) {
+            entityDatastores.remove(normalizedClassName)
+        }
+        else {
+            entityDatastores.put(normalizedClassName, newEntityDatastores)
+        }
+    }
+
+    /**
+     * Registers an entity-specific datastore override.
+     */
+    void registerEntityDatastore(String className, String qualifier, Datastore 
datastore) {
+        if (datastore != null) {
+            String normalizedClassName = normalizeEntityKey(className)
+            if (normalizedClassName == null) {
+                return
+            }
+            String normalizedQualifier = normalizeQualifier(qualifier)
+            getInternalMap(entityDatastores, 
normalizedClassName).put(normalizedQualifier, datastore)
+        }
+    }
+
+    /**
+     * Creates dynamic finders for the default datastore
+     *
+     * @return List of finder methods
+     */
+    List<FinderMethod> createDynamicFinders(Datastore targetDatastore) {
+        createDynamicFinders(new DatastoreResolver() {
+            @Override
+            Datastore resolve() {
+                targetDatastore
+            }
+        }, targetDatastore.getMappingContext())
+    }
+
+    /**
+     * Creates dynamic finders using the given resolver and mapping context.
+     *
+     * @param resolver The datastore resolver
+     * @param mappingContext The mapping context
+     * @return List of finder methods
+     */
+    List<FinderMethod> createDynamicFinders(DatastoreResolver resolver, 
MappingContext mappingContext) {
+        Datastore ds = resolver.resolve()
+        if (ds != null) {
+            return getApiFactory(ds).createDynamicFinders(resolver, 
mappingContext)
+        }
+        return []
+    }
+
+    /**
+     * Create a DatastoreResolver for a class and optional qualifier.
+     */
+    DatastoreResolver createClassDatastoreResolver(Class cls, String qualifier 
= ConnectionSource.DEFAULT) {
+        String normalizedClassName = normalizeEntityKey(cls)
+        String normalizedQualifier = normalizeQualifier(qualifier)
+        return new DatastoreResolver() {
+            @Override
+            Datastore resolve() {
+                apiResolver.findDatastore(cls, normalizedQualifier)
+            }
+        }
+    }
+
+    /**
+     * Create a GormStaticApi instance.
+     * Uses a bound resolver (always returns the given datastore) at 
registration time so that
+     * tenant-resolving DatastoreResolvers are not invoked before any tenant 
context is active.
+     */
+    GormStaticApi createStaticApi(Class cls, Datastore datastore, 
DatastoreResolver resolver, String qualifier) {
+        DatastoreResolver boundResolver = { datastore } as DatastoreResolver
+        return getApiFactory(datastore).createStaticApi(cls, 
datastore.mappingContext, boundResolver, qualifier, this)
+    }
+
+    /**
+     * Create a GormInstanceApi instance.
+     * Uses a bound resolver (always returns the given datastore) at 
registration time so that
+     * tenant-resolving DatastoreResolvers are not invoked before any tenant 
context is active.
+     */
+    GormInstanceApi createInstanceApi(Class cls, Datastore datastore, 
DatastoreResolver resolver, boolean failOnError, boolean markDirty) {
+        DatastoreResolver boundResolver = { datastore } as DatastoreResolver
+        return getApiFactory(datastore).createInstanceApi(cls, 
datastore.mappingContext, boundResolver, this, failOnError, markDirty)
+    }
+
+    /**
+     * Create a GormValidationApi instance.
+     * Uses a bound resolver (always returns the given datastore) at 
registration time so that
+     * tenant-resolving DatastoreResolvers are not invoked before any tenant 
context is active.
+     */
+    GormValidationApi createValidationApi(Class cls, Datastore datastore, 
DatastoreResolver resolver) {
+        DatastoreResolver boundResolver = { datastore } as DatastoreResolver
+        return getApiFactory(datastore).createValidationApi(cls, 
datastore.mappingContext, boundResolver, this)
+    }
+
+    /**
+     * Register API objects for a persistent entity.
+     * Part of the public API for external plugins and test environments.
+     */
+    void registerEntityApis(Class cls, GormStaticApi staticApi, 
GormInstanceApi instanceApi, GormValidationApi validationApi) {
+        registerEntityApis(cls.name, staticApi, instanceApi, validationApi)
+    }
+
+    /**
+     * Register constraints for all entities in a datastore.
+     * Delegates to the ConstraintsEvaluator if available in the mapping 
context.
+     *
+     * @param datastore The datastore containing the entities
+     */
+    @CompileDynamic
+    void registerConstraints(Object datastore) {

Review Comment:
   Both problems fixed, traced against the real 8.0.x baseline rather than 
assumed. `GormRegistry.registerConstraints(Object)` (the duck-typed 
`ConstraintsEvaluator` path) has no 8.0.x basis at all — `GormRegistry` doesn't 
exist there — and duplicates work the real `ConstraintRegistrar` path 
(`GormEnhancer.registerConstraints(Datastore)`) already does. Deleted entirely, 
including the dead `if (cls != null)` guard on `Class.forName` you flagged.
   
   Also found `GormEnhancer.removeConstraints()` hadn't actually been restored 
to 8.0.x's real implementation 
(`ConstrainedProperty.removeConstraint('unique')`) in an earlier pass of this 
work — it was still the PR-invented rewrite, verified byte-for-byte against 
8.0.x. Restored the real logic, but moved it onto 
`GormRegistry.removeConstraints()` (called from `removeDatastore()`) rather 
than back onto `GormEnhancer` — `removeDatastore()` already owns every other 
piece of datastore teardown (API deregistration, datastore mapping cleanup), so 
this fits there. Register-time `registerConstraints` stays on `GormEnhancer`, 
matching both 8.0.x and your branch; only the remove side moved.
   
   On the API-factory/constraints-hook coupling: agreed and adopted your fix — 
added a dedicated `registerApiFactories()` hook on `GormEnhancer`, called from 
the constructor before `registerConstraints`; H5/H7/Mongo now register their 
factories there instead of piggybacking on the constraints override. Commit 
`d49ee5992d`.



##########
grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/Tenants.groovy:
##########
@@ -254,39 +286,76 @@ class Tenants {
      * @return The result of the closure
      */
     static <T> T withId(MultiTenantCapableDatastore 
multiTenantCapableDatastore, Serializable tenantId, Closure<T> callable) {
-        return CurrentTenant.withTenant(tenantId) {
+        log.debug('Tenants.withId called for datastore {} with tenantId {}', 
multiTenantCapableDatastore, tenantId)
+        org.grails.datastore.mapping.core.Datastore childDatastore = null
+        try {
+            childDatastore = 
multiTenantCapableDatastore.getDatastoreForTenantId(tenantId)
+        } catch (Throwable e) {
+            log.debug('Ignoring failure resolving datastore for tenant {}: 
{}', tenantId, e.message)
+        }
+        // Only reuse an already-bound per-tenant session for 
non-shared-connection modes
+        // (e.g. DATABASE), where getDatastoreForTenantId yields a distinct 
child datastore.
+        // Shared-connection modes (DISCRIMINATOR, SCHEMA) must run through 
the shared-connection
+        // path below so the closure receives the datastore's own session 
(which adapters may
+        // expose as a native session), rather than the resolved child 
datastore's session.
+        if 
(!multiTenantCapableDatastore.getMultiTenancyMode().isSharedConnection() &&
+                childDatastore != null && childDatastore.hasCurrentSession()) {
+            return CurrentTenantHolder.withTenant(multiTenantCapableDatastore, 
tenantId) {
+                def i = callable.parameterTypes.length
+                switch (i) {
+                    case 0:
+                        return callable.call()
+                    case 1:
+                        return callable.call(tenantId)
+                    case 2:
+                        return callable.call(tenantId, 
childDatastore.getCurrentSession())
+                    default:
+                        throw new IllegalArgumentException('Provided closure 
accepts too many arguments')
+                }
+            }
+        }
+        return CurrentTenantHolder.withTenant(multiTenantCapableDatastore, 
tenantId) {
             if 
(multiTenantCapableDatastore.getMultiTenancyMode().isSharedConnection()) {
                 def i = callable.parameterTypes.length
                 if (i == 2) {
                     return multiTenantCapableDatastore.withSession { session ->
-                        return callable.call(tenantId, session)
+                        def result = callable.call(tenantId, session)
+                        log.debug('Result from shared connection with 2 args: 
{}', result)

Review Comment:
   Stripped it back — `Tenants.withId` down to plain `return 
callable.call(...)` per switch arm, keeping one informative entry log at the 
top of the method; `GrailsTransactionTemplate.executeAndRollback` loses the 
duplicate-message `isDebugEnabled()` blocks, kept a single parameterized 
`log.debug('Rolling back after the action threw', e)`. Commit `508cce361e`, 
matches your branch's shape here.
   
   Narrowed the broad `catch (Throwable e)` around `getDatastoreForTenantId` to 
`catch (ConfigurationException | TenantException e)`, bumped to warn — checked 
the real adapter code first: H5/H7/Mongo's `getDatastoreForConnection` throws 
exactly `ConfigurationException` for an unknown name, and 
`TenantNotFoundException extends TenantException`, so the narrowed catch covers 
the real failure modes rather than a guess. This did break an existing test 
that used a generic `IllegalStateException` as its simulated failure (only 
worked under the old unconditional catch) — replaced with 3 tests using the 
real exception types, plus one proving a genuinely unrelated exception now 
correctly propagates instead of being silently swallowed.



##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy:
##########
@@ -63,8 +64,15 @@ import 
org.grails.datastore.mapping.multitenancy.MultiTenancySettings
 @CompileStatic
 class MongoStaticApi<D> extends GormStaticApi<D> implements 
MongoAllOperations<D> {
 
+    protected final PersistentEntity persistentEntity

Review Comment:
   Took your option 1 — kept `GormStaticApi.multiTenancyMode` as the field 
rather than removing it, so no upgrade note or re-declaration is needed at all. 
Dropped `MongoStaticApi`'s shadowing `persistentEntity`/`multiTenancyMode` 
fields entirely; `wrapFilterWithMultiTenancy`/`preparePipeline` now use 
`getGormPersistentEntity()` and the inherited field.
   
   One thing worth flagging on your own branch while I was here: your 
replacement `getMultiTenancyMode()` is commented 'an API is no longer bound to 
a single datastore for its lifetime.' I traced `GormRegistry.createStaticApi`'s 
`boundResolver = { datastore } as DatastoreResolver` closure on both branches 
(yours included — the binding mechanism itself is unchanged there, you only 
dropped the now-unused `resolver` param) and every `GormStaticApi` is built 
through that one path on both. An instance genuinely is pinned to one datastore 
for its whole lifetime; when a datastore changes, the registry replaces the API 
instance wholesale rather than mutating one in place. The rationale in that 
comment doesn't hold against your own diff — worth a look, might be worth just 
keeping the field on your branch too.
   
   Also found and fixed a real, separate bug while restoring this: 
`MongoStaticApi`'s only constructor routed through `GormStaticApi`'s deprecated 
4-arg super, which eagerly resolved the datastore and discarded whatever 
qualifier the registry actually asked for — every Mongo entity's API silently 
hardcoded `ConnectionSource.DEFAULT` regardless of its real qualifier. Added a 
primary constructor mirroring `GormStaticApi`'s non-deprecated signature; 
`MongoGormApiFactory` now calls that one directly. 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 = [

Review Comment:
   Fixed, just now — you're right on all counts. Restored 
`getMethods`/`getExtendedMethods`/`setTransactionManager` (verified 
byte-for-byte against real 8.0.x), added `getTransactionManager` since that 
abstract getter is new on this branch and needed the same treatment as the 
setter, and reverted the field back to `static final` with no explicit 
visibility modifier so Groovy re-generates the public static accessor — 
`protected` had silently dropped it, exactly as you called out. Fixed 
`AbstractGormApiSpec`'s circular assertion too, replaced with a check against 
the literal excluded names, plus a dedicated new test asserting `EXCLUDES`' 
exact contents and the restored accessor. Commit `c9c57e6a21`.



##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/MultipleConnectionSourceCapableDatastore.java:
##########
@@ -0,0 +1,56 @@
+/*
+ *  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.mapping.core.connections;
+
+import org.grails.datastore.mapping.core.Datastore;
+
+/**
+ * A {@link Datastore} capable of configuring multiple {@link Datastore} with 
individually named {@link ConnectionSource} instances
+ *
+ * @author Graeme Rocher
+ * @since 6.1
+ */
+public interface MultipleConnectionSourceCapableDatastore extends Datastore {
+
+    /**
+     * Lookup a {@link Datastore} by {@link ConnectionSource} name
+     *
+     * @param connectionName The connection name
+     * @return The {@link Datastore}
+     */
+    Datastore getDatastoreForConnection(String connectionName);
+
+    /**
+     * Whether an entity mapped only to non-default connection sources should 
route its unqualified
+     * (no explicit connection) operations to its first mapped connection's 
datastore.
+     *
+     * Real datastores manage an independent session per connection, so such 
an entity's default
+     * operations belong to that mapped connection's datastore. The in-memory 
mock used for unit
+     * testing manages a single session on this (parent) datastore and only 
fabricates isolated
+     * children for explicit connection access, so it overrides this to keep 
unqualified operations
+     * on the parent — otherwise they would target a child whose session the 
test harness never
+     * flushes.
+     *
+     * @return {@code true} to route unqualified operations to the mapped 
connection datastore
+     */
+    default boolean routesUnqualifiedToMappedConnection() {

Review Comment:
   Removed it from the public SPI entirely rather than fixing 
`SimpleMapDatastore`'s session model — agreed it shouldn't be third-party 
datastores' problem to reason about. Added a narrow 
`SingleSessionCapableDatastore` marker interface in `grails-datastore-core` 
(javadoc explicitly says it's not part of the public multi-connection contract, 
don't implement it for production datastores); `SimpleMapDatastore` implements 
it instead of overriding a method on the shared interface. `GormRegistry`'s one 
call site collapsed from a double-negative instanceof+method check to a single 
`instanceof SingleSessionCapableDatastore`. Commit `da09401c4d`.



##########
grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java:
##########
@@ -389,30 +392,71 @@ public List retrieveAll(final Class type, final Iterable 
keys) {
         final String entityName = persistentEntity.getName();
         final String idName = persistentEntity.getIdentity().getName();
         final String hql = "from " + entityName + " as e where e." + idName + 
" in (:keys)";
+        final Class idType = persistentEntity.getIdentity().getType();
+        final ConversionService conversionService = 
getMappingContext().getConversionService();
+
+        // Convert each requested id to the entity's identifier type, 
preserving order and
+        // duplicates. getAll() must return entities in the supplied id order 
with a null slot
+        // for any id that does not resolve to a row, so order is driven by 
the request rather
+        // than the database.
+        final List<Serializable> requestedIds = new ArrayList<>();

Review Comment:
   Kept these rather than splitting them out — traced each one against real git 
blame/TCK history before deciding, rather than against just the PR 
description's framing:
   
   - `getAll()` order/null-slot semantics: the shared TCK's `GormEnhancerSpec` 
(zero diff from 8.0.x) already asserts 'Test getAll preserves the supplied id 
order' and 'returns a null slot for a missing id' — the old H5/H7 code was 
structurally incapable of passing either. This is a pre-existing 8.0.x contract 
violation the registry work surfaced and fixed, not new undocumented behavior — 
the coverage already existed, it just hadn't been cross-referenced against an 
already-large diff. I did fix the specific gap you named, though: 
`entitiesById` is now consistently keyed/looked-up by `String.valueOf(id)` on 
both sides, so a raw `requestedId` that `convertToIdentifierType` couldn't 
convert (but Hibernate itself would have coerced) now resolves instead of 
silently yielding a null slot. Applied to both H5 and H7.
   - `HibernateQuery`'s `disjunction()`/`conjunction()`/`negation()`: small, 
self-contained, a genuine correctness bug — core's base implementations wrote 
to an unused field, silently dropping `countByXOrY`'s OR. Reverting would 
reintroduce silently-wrong query results. It didn't have a dedicated test 
before this; happy to add one if that's what would unblock keeping it here 
instead of splitting it out.
   - `CriteriaBuilder`'s `ensureQueryIsInitialized()` guards ship with their 
own dedicated commit and regression test (a real NPE from a bare 
`createCriteria()` call); `getPersistentEntity()` is load-bearing for 
`DynamicFinder`'s own machinery in this same refactor, not optional; 
`list(Closure)`/`call(Closure)` formalize dispatch that already worked via 
`invokeMethod` pre-PR and is exercised today by pre-existing, unchanged tests.
   - `removeConstraints()`: this is the undo-counterpart of the 
double-constraint-registration bug flagged separately on the 
`GormRegistry.groovy` thread — same fix, not a separate concern.
   
   Genuinely open to splitting any one of these into its own PR if the trace 
above doesn't change your read — wanted to give you the actual evidence rather 
than just assert they're fine and ask you to trust it.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApiRegistry.groovy:
##########
@@ -0,0 +1,179 @@
+/*
+ *  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 groovy.transform.CompileStatic
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import 
org.grails.datastore.mapping.core.connections.MultipleConnectionSourceCapableDatastore
+import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
+import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
+
+import java.util.concurrent.ConcurrentHashMap
+
+@CompileStatic
+abstract class AbstractGormApiRegistry<T extends AbstractDatastoreApi> {
+
+    private final Map<String, T> apis = new ConcurrentHashMap<>()
+    private final Map<String, Map<String, T>> qualifiedApis = new 
ConcurrentHashMap<>()
+    protected final GormRegistry registry
+
+    AbstractGormApiRegistry(GormRegistry registry) {
+        this.registry = registry
+    }
+
+    void register(String className, T api) {
+        String normalizedClassName = registry.normalizeEntityKey(className)
+        if (normalizedClassName != null && api != null) {
+            apis.put(normalizedClassName, api)
+            qualifiedApis.remove(normalizedClassName)
+        }
+    }
+
+    T get(String className) {
+        return apis.get(registry.normalizeEntityKey(className))
+    }
+
+    T get(String className, String qualifier) {
+        return getDirect(registry.normalizeEntityKey(className), 
registry.normalizeQualifier(qualifier))
+    }
+
+    T getDirect(String normalizedClassName, String normalizedQualifier) {
+        T defaultApi = apis.get(normalizedClassName)
+        if (defaultApi == null) {
+            return null
+        }
+
+        Datastore ds = registry.getDatastoreDirect(normalizedClassName, 
normalizedQualifier)
+        if (ds == null && defaultApi.getDatastore() instanceof 
MultipleConnectionSourceCapableDatastore) {
+            Datastore defaultDatastore = defaultApi.getDatastore()
+            boolean canResolveConnection = true
+            if (defaultDatastore instanceof MultiTenantCapableDatastore) {
+                MultiTenancySettings.MultiTenancyMode mode = 
((MultiTenantCapableDatastore) defaultDatastore).getMultiTenancyMode()
+                if (mode == 
MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR ||
+                        mode == MultiTenancySettings.MultiTenancyMode.SCHEMA) {
+                    canResolveConnection = false
+                }
+            }
+            if (canResolveConnection) {
+                ds = ((MultipleConnectionSourceCapableDatastore) 
defaultDatastore).getDatastoreForConnection(normalizedQualifier)
+            } else {
+                ds = defaultDatastore
+            }
+        }
+
+        if (ds != null && ds != defaultApi.getDatastore()) {
+            Map<String, T> classQualifiedApis = 
qualifiedApis.computeIfAbsent(normalizedClassName, { new 
ConcurrentHashMap<String, T>() })
+            T api = classQualifiedApis.get(normalizedQualifier)
+            if (api == null) {
+                api = qualify(defaultApi, normalizedQualifier)
+                if (api != null) {
+                    classQualifiedApis.put(normalizedQualifier, api)
+                    // register(className, newApi) does apis.put(new) then 
qualifiedApis.remove(..).
+                    // If that remove ran between our read of defaultApi and 
the put above, the
+                    // cached entry would be derived from a stale default API 
and survive
+                    // indefinitely. Re-validate after publishing and retract 
if superseded.
+                    if (!defaultApi.is(apis.get(normalizedClassName))) {
+                        classQualifiedApis.remove(normalizedQualifier, api)
+                    }
+                }
+            }
+            return api
+        }
+
+        return defaultApi
+    }
+
+    boolean containsKey(String className) {
+        return apis.containsKey(registry.normalizeEntityKey(className))
+    }
+
+    /**
+     * Whether an API is currently materialized for the given entity and 
qualifier WITHOUT triggering
+     * lazy creation. The default-qualifier API is allocated eagerly at 
registration; non-default
+     * (connection / tenant) APIs are allocated lazily on first access. 
Supports verifying the
+     * O(M+N) lazy-allocation strategy.
+     */
+    boolean isAllocated(String className, String qualifier) {
+        String normalizedClassName = registry.normalizeEntityKey(className)
+        if (normalizedClassName == null) {
+            return false
+        }
+        String normalizedQualifier = registry.normalizeQualifier(qualifier)
+        if (ConnectionSource.DEFAULT == normalizedQualifier) {
+            return apis.containsKey(normalizedClassName)
+        }
+        Map<String, T> classQualifiedApis = 
qualifiedApis.get(normalizedClassName)
+        return classQualifiedApis != null && 
classQualifiedApis.containsKey(normalizedQualifier)
+    }
+
+    int size() {
+        return apis.size()
+    }
+
+    Set<String> keySet() {
+        return apis.keySet()
+    }
+
+    void clear() {
+        apis.clear()
+        qualifiedApis.clear()
+    }
+
+    void removeDatastore(Datastore datastore) {
+        if (datastore == null) return
+        Iterator<Map.Entry<String, T>> it = apis.entrySet().iterator()
+        while (it.hasNext()) {
+            try {
+                if (it.next().value.getDatastore() == datastore) {
+                    it.remove()
+                }
+            } catch (Exception e) {

Review Comment:
   Confirmed and fixed exactly as described. `removeDatastore()`'s `catch 
(Exception e) { it.remove() }` (both the default and qualified maps) could 
evict an unrelated entity's API registration just because reading its datastore 
happened to throw at that moment — reachable from an ordinary unresolved-tenant 
condition, since `getDatastore()` routes through a `DatastoreResolver` that can 
throw `TenantNotFoundException` when no tenant is bound. Reproduced first as a 
failing test (temporarily reverted the fix, confirmed both new specs failed as 
expected against the original catch-and-remove code). Extracted a 
`belongsTo(api, datastore, className, qualifier)` helper — only a positive 
identity match removes; on exception, logs at warn with the exception and which 
entity/qualifier was affected, returns `false` (kept, not evicted). Commit 
`efe62b0ca2`, matches your branch's fix. 3 new specs covering cross-entity 
eviction stays scoped correctly and both throwing-API survival cases.



-- 
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