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]
