This is an automated email from the ASF dual-hosted git repository. borinquenkid pushed a commit to branch 8.0.x-hibernate7.gorm-scaling-clean in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 949fdaaeee9a94351c4f3bf8a020c4f4ecdec009 Author: Walter Duque de Estrada <[email protected]> AuthorDate: Thu May 21 18:20:53 2026 -0500 Add grails-datastore-core optimization changes Includes SessionResolver and ThreadLocalSessionResolver (new interfaces/classes introduced by the O(M+N) scaling refactor), plus updates to AbstractDatastore, AbstractMappingContext, and related core classes that the datastore modules (SimpleMap, Hibernate 5/7) depend on at compile time. Missed from initial clean rebuild commit. Agent collaboration note: Claude Sonnet 4.6 assisted; borinquenkid is the primary author and remains responsible for the final changes. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --- .../datastore/mapping/core/AbstractDatastore.java | 80 +++++++++++++++++- .../grails/datastore/mapping/core/Datastore.java | 5 ++ .../datastore/mapping/core/DatastoreUtils.java | 64 +++++++++++++-- .../Service.groovy => core/SessionResolver.groovy} | 36 ++++---- .../mapping/core/ThreadLocalSessionResolver.groovy | 66 +++++++++++++++ .../AbstractConnectionSourceFactory.java | 11 +++ .../ConnectionSourceSettingsBuilder.groovy | 4 + .../dirty/checking/DirtyCheckingSupport.groovy | 4 + .../document/config/DocumentMappingContext.java | 2 +- .../mapping/config/KeyValueMappingContext.java | 6 +- .../mapping/model/AbstractMappingContext.java | 6 +- .../mapping/model/AbstractPersistentEntity.java | 10 ++- .../datastore/mapping/model/MappingContext.java | 14 ++++ .../org/grails/datastore/mapping/query/Query.java | 2 +- .../datastore/mapping/reflect/AstUtils.groovy | 6 +- .../datastore/mapping/reflect/ClassUtils.java | 27 ++++++ .../datastore/mapping/services/Service.groovy | 17 ++-- .../CustomizableRollbackTransactionAttribute.java | 43 ++++++---- .../mapping/core/AbstractDatastoreSpec.groovy | 96 ++++++++++++++++++++++ .../core/SessionResolverIntegrationSpec.groovy | 58 +++++++++++++ .../core/ThreadLocalSessionResolverSpec.groovy | 65 +++++++++++++++ .../services/DefaultServiceRegistrySpec.groovy | 4 +- 22 files changed, 563 insertions(+), 63 deletions(-) diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java index 7ee641a2bf..630126a214 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java @@ -14,6 +14,9 @@ */ package org.grails.datastore.mapping.core; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import groovy.lang.Closure; @@ -26,8 +29,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationListener; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.PayloadApplicationEvent; import org.springframework.core.convert.converter.ConverterRegistry; import org.springframework.core.env.PropertyResolver; import org.springframework.transaction.support.TransactionSynchronizationManager; @@ -55,12 +61,48 @@ import org.grails.datastore.mapping.transactions.SessionHolder; @SuppressWarnings({"rawtypes", "unchecked"}) public abstract class AbstractDatastore implements Datastore, StatelessDatastore, ServiceRegistry { protected static final Logger LOG = LoggerFactory.getLogger(AbstractDatastore.class); + + private static final class DefaultApplicationEventPublisher implements ApplicationEventPublisher { + private final List<ApplicationListener> listeners = new ArrayList<>(); + + @Override + public void publishEvent(ApplicationEvent event) { + publishEvent((Object) event); + } + + @Override + public void publishEvent(Object event) { + for (ApplicationListener listener : new ArrayList<>(listeners)) { + if (event instanceof ApplicationEvent) { + listener.onApplicationEvent((ApplicationEvent) event); + } else { + listener.onApplicationEvent(new PayloadApplicationEvent(this, event)); + } + } + } + + public void addApplicationListener(ApplicationListener<?> listener) { + listeners.add(listener); + } + } + private ApplicationContext applicationContext; + protected ApplicationEventPublisher applicationEventPublisher = new DefaultApplicationEventPublisher(); protected final MappingContext mappingContext; protected final ServiceRegistry serviceRegistry; protected final PropertyResolver connectionDetails; protected final TPCacheAdapterRepository cacheAdapterRepository; + protected SessionResolver sessionResolver; + + @Override + public SessionResolver getSessionResolver() { + return sessionResolver; + } + + public void setSessionResolver(SessionResolver sessionResolver) { + this.sessionResolver = sessionResolver; + } public AbstractDatastore(MappingContext mappingContext) { this(mappingContext, (PropertyResolver) null, null); @@ -80,8 +122,10 @@ public abstract class AbstractDatastore implements Datastore, StatelessDatastore ConfigurableApplicationContext ctx, TPCacheAdapterRepository cacheAdapterRepository) { this.mappingContext = mappingContext; this.connectionDetails = connectionDetails; - setApplicationContext(ctx); this.cacheAdapterRepository = cacheAdapterRepository; + this.applicationEventPublisher = ctx != null ? ctx : new DefaultApplicationEventPublisher(); + this.sessionResolver = new ThreadLocalSessionResolver<>(); + setApplicationContext(ctx); DefaultServiceRegistry defaultServiceRegistry = new DefaultServiceRegistry(this); this.serviceRegistry = defaultServiceRegistry; defaultServiceRegistry.initialize(); @@ -122,6 +166,36 @@ public abstract class AbstractDatastore implements Datastore, StatelessDatastore public void setApplicationContext(ApplicationContext ctx) { applicationContext = ctx; + if (ctx instanceof ApplicationEventPublisher) { + this.applicationEventPublisher = (ApplicationEventPublisher) ctx; + } + else if (ctx == null && !(this.applicationEventPublisher instanceof DefaultApplicationEventPublisher)) { + this.applicationEventPublisher = new DefaultApplicationEventPublisher(); + } + } + + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.applicationEventPublisher = applicationEventPublisher; + } + + /** + * Adds an application listener to the datastore + * @param listener The listener + */ + public void addApplicationListener(ApplicationListener<?> listener) { + if (applicationEventPublisher instanceof ConfigurableApplicationContext) { + ((ConfigurableApplicationContext) applicationEventPublisher).addApplicationListener(listener); + } else if (applicationEventPublisher instanceof DefaultApplicationEventPublisher) { + ((DefaultApplicationEventPublisher) applicationEventPublisher).addApplicationListener(listener); + } + else { + try { + Method method = applicationEventPublisher.getClass().getMethod("addApplicationListener", ApplicationListener.class); + method.invoke(applicationEventPublisher, listener); + } catch (Exception e) { + // ignore + } + } } public Session connect() { @@ -171,7 +245,7 @@ public abstract class AbstractDatastore implements Datastore, StatelessDatastore } public boolean hasCurrentSession() { - return TransactionSynchronizationManager.hasResource(this); + return sessionResolver.resolve() != null || TransactionSynchronizationManager.hasResource(this); } /** @@ -223,7 +297,7 @@ public abstract class AbstractDatastore implements Datastore, StatelessDatastore } public ApplicationEventPublisher getApplicationEventPublisher() { - return getApplicationContext(); + return applicationEventPublisher; } protected void initializeConverters(MappingContext mappingContext) { diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/Datastore.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/Datastore.java index 57206ea2e7..300381acd9 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/Datastore.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/Datastore.java @@ -39,6 +39,11 @@ import org.grails.datastore.mapping.services.ServiceRegistry; */ public interface Datastore extends ServiceRegistry { + /** + * @return The session resolver for this datastore + */ + SessionResolver getSessionResolver(); + /** * Connects to the datastore with the default connection details, normally provided via the datastore implementations constructor * diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/DatastoreUtils.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/DatastoreUtils.java index 7e7e868325..449422e19f 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/DatastoreUtils.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/DatastoreUtils.java @@ -116,11 +116,15 @@ public abstract class DatastoreUtils { Assert.notNull(datastore, "No Datastore specified"); + Session session = datastore.getSessionResolver().resolve(); + if (session != null) { + return session; + } + SessionHolder sessionHolder = (SessionHolder) TransactionSynchronizationManager.getResource(datastore); if (sessionHolder != null && !sessionHolder.isEmpty()) { // pre-bound Datastore Session - Session session; if (TransactionSynchronizationManager.isSynchronizationActive() && sessionHolder.doesNotHoldNonDefaultSession()) { // Spring transaction management is active -> @@ -150,7 +154,7 @@ public abstract class DatastoreUtils { if (logger.isDebugEnabled()) { logger.debug("Opening Datastore Session"); } - Session session = datastore.connect(); + session = datastore.connect(); // Use same Session for further Datastore actions within the transaction. // Thread object will get removed by synchronization at transaction completion. @@ -361,13 +365,61 @@ public abstract class DatastoreUtils { } } + /** + * Execute the given callback with a new session, regardless of whether an existing session is present + * @param datastore The datastore + * @param callback The callback + * @param <T> The return type + * @return The result of the callback + */ + public static <T> T executeWithNewSession(Datastore datastore, SessionCallback<T> callback) { + Session session = bindNewSession(datastore.connect()); + try { + return callback.doInSession(session); + } + finally { + SessionHolder sessionHolder = (SessionHolder) TransactionSynchronizationManager.getResource(datastore); + if (sessionHolder != null) { + sessionHolder.removeSession(session); + if (sessionHolder.isEmpty()) { + TransactionSynchronizationManager.unbindResource(datastore); + } + } + closeSessionOrRegisterDeferredClose(session, datastore); + } + } + + /** + * Execute the given callback with a new session, regardless of whether an existing session is present + * @param datastore The datastore + * @param callback The callback + */ + public static void executeWithNewSession(Datastore datastore, VoidSessionCallback callback) { + Session session = bindNewSession(datastore.connect()); + try { + callback.doInSession(session); + } + finally { + SessionHolder sessionHolder = (SessionHolder) TransactionSynchronizationManager.getResource(datastore); + if (sessionHolder != null) { + sessionHolder.removeSession(session); + if (sessionHolder.isEmpty()) { + TransactionSynchronizationManager.unbindResource(datastore); + } + } + closeSessionOrRegisterDeferredClose(session, datastore); + } + } + /** * Bind the session to the thread with a SessionHolder keyed by its Datastore. * @param session the session * @return the session (for method chaining) */ public static Session bindSession(final Session session) { - TransactionSynchronizationManager.bindResource(session.getDatastore(), new SessionHolder(session)); + if (!TransactionSynchronizationManager.hasResource(session.getDatastore())) { + TransactionSynchronizationManager.bindResource(session.getDatastore(), new SessionHolder(session)); + } return session; } @@ -377,7 +429,9 @@ public abstract class DatastoreUtils { * @return the session (for method chaining) */ public static Session bindSession(final Session session, Object creator) { - TransactionSynchronizationManager.bindResource(session.getDatastore(), new SessionHolder(session, creator)); + if (!TransactionSynchronizationManager.hasResource(session.getDatastore())) { + TransactionSynchronizationManager.bindResource(session.getDatastore(), new SessionHolder(session, creator)); + } return session; } @@ -489,7 +543,7 @@ public abstract class DatastoreUtils { } else { - Map<String, Object>[] configurations = new Map[1]; + Map<String, Object>[] configurations = (Map<String, Object>[]) new Map[1]; configurations[0] = configuration; return createPropertyResolvers(configurations); } diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/services/Service.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy similarity index 58% copy from grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/services/Service.groovy copy to grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy index 337718115b..52447599eb 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/services/Service.groovy +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy @@ -16,34 +16,28 @@ * specific language governing permissions and limitations * under the License. */ -package org.grails.datastore.mapping.services -import groovy.transform.Generated +package org.grails.datastore.mapping.core; -import org.grails.datastore.mapping.core.Datastore +import groovy.transform.CompileStatic; /** - * Represents a service available exposed by the GORM {@link Datastore} + * Resolver for sessions in the current context (thread, tenant, etc) * - * @author Graeme Rocher - * @since 6.1 - * - * <T> The domain class type this service operates with + * @author borinquenkid + * @since 8.0 */ -trait Service<T> { +@CompileStatic +public interface SessionResolver<S extends Session> { + /** Resolves the current session based on current context (thread, tenant, etc) */ + S resolve(); - /** - * The datastore that this service is related to - */ - private Datastore datastore + /** Resolves a session for a specific qualifier/tenant */ + S resolve(String qualifier); - @Generated - Datastore getDatastore() { - return datastore - } + /** Binds a session to the current context */ + void bind(S session); - @Generated - void setDatastore(Datastore datastore) { - this.datastore = datastore - } + /** Unbinds the current session */ + void unbind(); } diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/ThreadLocalSessionResolver.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/ThreadLocalSessionResolver.groovy new file mode 100644 index 0000000000..80bb972d36 --- /dev/null +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/ThreadLocalSessionResolver.groovy @@ -0,0 +1,66 @@ +/* + * 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; + +import groovy.transform.CompileStatic; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A default thread-bound SessionResolver + * + * @author borinquenkid + * @since 8.0 + */ +@CompileStatic +public class ThreadLocalSessionResolver<S extends Session> implements SessionResolver<S> { + + private final ThreadLocal<S> currentSession = new ThreadLocal<>(); + private final Map<String, S> qualifiedSessions = new ConcurrentHashMap<>(); + + @Override + public S resolve() { + return currentSession.get(); + } + + @Override + public S resolve(String qualifier) { + return qualifiedSessions.get(qualifier); + } + + @Override + public void bind(S session) { + currentSession.set(session); + // Note: In a production scenario, we'd need to link the session's datastore qualifier here. + } + + public void bind(String qualifier, S session) { + qualifiedSessions.put(qualifier, session); + } + + @Override + public void unbind() { + currentSession.remove(); + } + + public void unbind(String qualifier) { + qualifiedSessions.remove(qualifier); + } +} diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/AbstractConnectionSourceFactory.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/AbstractConnectionSourceFactory.java index e4ab461300..c56b537120 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/AbstractConnectionSourceFactory.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/AbstractConnectionSourceFactory.java @@ -88,6 +88,17 @@ public abstract class AbstractConnectionSourceFactory<T, S extends ConnectionSou S settings = buildRuntimeSettings(name, configuration, fallbackSettings); return create(name, settings); } + + /** + * Creates the settings for the given configuration + * @param configuration The configuration + * @return The settings + */ + public S createSettings(PropertyResolver configuration) { + ConnectionSourceSettingsBuilder builder = new ConnectionSourceSettingsBuilder(configuration); + ConnectionSourceSettings fallbackSettings = builder.build(); + return (S) buildSettings(ConnectionSource.DEFAULT, configuration, fallbackSettings, true); + } public <F extends ConnectionSourceSettings> S buildRuntimeSettings(String name, PropertyResolver configuration, F fallbackSettings) { return buildSettings(name, configuration, fallbackSettings, false); diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/ConnectionSourceSettingsBuilder.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/ConnectionSourceSettingsBuilder.groovy index 9eecb9f5d9..e23cacc1ca 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/ConnectionSourceSettingsBuilder.groovy +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/ConnectionSourceSettingsBuilder.groovy @@ -39,6 +39,10 @@ class ConnectionSourceSettingsBuilder extends ConfigurationBuilder<ConnectionSou super(propertyResolver, configurationPrefix) } + ConnectionSourceSettingsBuilder(PropertyResolver propertyResolver, String configurationPrefix, Object fallBackConfiguration) { + super(propertyResolver, configurationPrefix, fallBackConfiguration) + } + @Override protected ConnectionSourceSettings createBuilder() { return new ConnectionSourceSettings() diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/dirty/checking/DirtyCheckingSupport.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/dirty/checking/DirtyCheckingSupport.groovy index 7fb541e908..de799c8261 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/dirty/checking/DirtyCheckingSupport.groovy +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/dirty/checking/DirtyCheckingSupport.groovy @@ -80,6 +80,10 @@ class DirtyCheckingSupport { if (coll.isDirty()) return true } } + else if (value instanceof DirtyCheckableCollection) { + DirtyCheckableCollection coll = (DirtyCheckableCollection) value + if (coll.hasChanged()) return true + } } } diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/document/config/DocumentMappingContext.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/document/config/DocumentMappingContext.java index abfc980892..70b6210bda 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/document/config/DocumentMappingContext.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/document/config/DocumentMappingContext.java @@ -69,7 +69,7 @@ public class DocumentMappingContext extends AbstractMappingContext { } @Override - protected void initialize(ConnectionSourceSettings settings) { + public void initialize(ConnectionSourceSettings settings) { this.defaultMapping = settings.getDefault().getMapping(); AbstractGormMappingFactory documentMappingFactory = (AbstractGormMappingFactory) createDocumentMappingFactory(defaultMapping); diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/keyvalue/mapping/config/KeyValueMappingContext.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/keyvalue/mapping/config/KeyValueMappingContext.java index 15842f2a86..6b4983e8f8 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/keyvalue/mapping/config/KeyValueMappingContext.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/keyvalue/mapping/config/KeyValueMappingContext.java @@ -25,7 +25,7 @@ import org.grails.datastore.mapping.model.AbstractMappingContext; import org.grails.datastore.mapping.model.MappingConfigurationStrategy; import org.grails.datastore.mapping.model.MappingFactory; import org.grails.datastore.mapping.model.PersistentEntity; -import org.grails.datastore.mapping.model.config.JpaMappingConfigurationStrategy; +import org.grails.datastore.mapping.model.config.GormMappingConfigurationStrategy; /** * A MappingContext used to map objects to a Key/Value store @@ -54,7 +54,7 @@ public class KeyValueMappingContext extends AbstractMappingContext { Assert.notNull(keyspace, "Argument [keyspace] cannot be null"); this.keyspace = keyspace; initializeDefaultMappingFactory(keyspace); - syntaxStrategy = new JpaMappingConfigurationStrategy(mappingFactory); + syntaxStrategy = new GormMappingConfigurationStrategy(mappingFactory); super.initialize(new ConnectionSourceSettings()); } @@ -67,7 +67,7 @@ public class KeyValueMappingContext extends AbstractMappingContext { Assert.notNull(keyspace, "Argument [keyspace] cannot be null"); this.keyspace = keyspace; initializeDefaultMappingFactory(keyspace); - syntaxStrategy = new JpaMappingConfigurationStrategy(this.mappingFactory); + syntaxStrategy = new GormMappingConfigurationStrategy(this.mappingFactory); super.initialize(settings); } diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AbstractMappingContext.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AbstractMappingContext.java index 8afab3ed45..6415cdf414 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AbstractMappingContext.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AbstractMappingContext.java @@ -91,7 +91,11 @@ public abstract class AbstractMappingContext implements MappingContext, Initiali return this.multiTenancyMode; } - protected void initialize(ConnectionSourceSettings settings) { + public void setMultiTenancyMode(MultiTenancySettings.MultiTenancyMode multiTenancyMode) { + this.multiTenancyMode = multiTenancyMode; + } + + public void initialize(ConnectionSourceSettings settings) { FieldEntityAccess.clearReflectors(); this.multiTenancyMode = settings.getMultiTenancy().getMode(); diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AbstractPersistentEntity.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AbstractPersistentEntity.java index f4f56dc091..ecab43c54b 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AbstractPersistentEntity.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AbstractPersistentEntity.java @@ -97,6 +97,14 @@ public abstract class AbstractPersistentEntity<T extends Entity> implements Pers } public TenantId getTenantId() { + if (this.tenantId == null && isMultiTenant()) { + for (PersistentProperty prop : persistentProperties) { + if (prop instanceof TenantId) { + this.tenantId = (TenantId) prop; + break; + } + } + } return tenantId; } @@ -152,7 +160,7 @@ public abstract class AbstractPersistentEntity<T extends Entity> implements Pers associations = new ArrayList(); embedded = new ArrayList(); - boolean multiTenancyEnabled = isMultiTenant && context.getMultiTenancyMode() == MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR; + boolean multiTenancyEnabled = isMultiTenant() && context.getMultiTenancyMode() == MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR; for (PersistentProperty persistentProperty : persistentProperties) { if (multiTenancyEnabled && persistentProperty instanceof TenantId) { this.tenantId = (TenantId) persistentProperty; diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/MappingContext.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/MappingContext.java index 9300985ec7..ee4b5d0a20 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/MappingContext.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/MappingContext.java @@ -27,6 +27,7 @@ import org.springframework.validation.Validator; import org.grails.datastore.mapping.engine.EntityAccess; import org.grails.datastore.mapping.multitenancy.MultiTenancySettings; +import org.grails.datastore.mapping.core.connections.ConnectionSourceSettings; import org.grails.datastore.mapping.proxy.ProxyFactory; import org.grails.datastore.mapping.proxy.ProxyHandler; import org.grails.datastore.mapping.reflect.EntityReflector; @@ -51,11 +52,24 @@ import org.grails.datastore.mapping.validation.ValidatorRegistry; @SuppressWarnings("rawtypes") public interface MappingContext { + /** + * Initialize the mapping context with the given settings + * @param settings The settings + */ + void initialize(ConnectionSourceSettings settings); + /** * @return The multi tenancy mode */ MultiTenancySettings.MultiTenancyMode getMultiTenancyMode(); + /** + * Set the multi tenancy mode + * + * @param multiTenancyMode The multi tenancy mode + */ + void setMultiTenancyMode(MultiTenancySettings.MultiTenancyMode multiTenancyMode); + /** * Obtains a list of PersistentEntity instances * diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/query/Query.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/query/Query.java index faaf1cbace..d7c9207e54 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/query/Query.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/query/Query.java @@ -647,7 +647,7 @@ public abstract class Query implements Cloneable, Serializable { ApplicationEventPublisher publisher = session.getDatastore().getApplicationEventPublisher(); if (publisher != null) { - publisher.publishEvent(new PreQueryEvent(this)); + publisher.publishEvent(new PreQueryEvent(session.getDatastore(), this)); } List results = executeQuery(entity, criteria); diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/AstUtils.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/AstUtils.groovy index edcb67296e..dba5a9f744 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/AstUtils.groovy +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/AstUtils.groovy @@ -358,8 +358,10 @@ class AstUtils { String annotationClassName = node.getClassNode().getName() if ((excluded == null || !excluded.contains(annotationClassName)) && (included == null || included.contains(annotationClassName))) { - final AnnotationNode copyOfAnnotationNode = cloneAnnotation(node) - to.addAnnotation(copyOfAnnotationNode) + if (to.getAnnotations(node.getClassNode()).isEmpty()) { + final AnnotationNode copyOfAnnotationNode = cloneAnnotation(node) + to.addAnnotation(copyOfAnnotationNode) + } } } } diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/ClassUtils.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/ClassUtils.java index c55fe8d5b7..1f4b77095c 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/ClassUtils.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/ClassUtils.java @@ -119,6 +119,33 @@ public class ClassUtils { } } + /** + * Retrieves an integer value from a Map for the given key. + * + * @param key The key that references the integer value + * @param map The map to look in + * @return An Integer value, or {@code null} if the map is null, does not contain the key, or the value is null + */ + public static Integer getIntegerFromMap(String key, Map<?, ?> map) { + if (map == null) return null; + if (map.containsKey(key)) { + Object o = map.get(key); + if (o == null) return null; + if (o instanceof Integer) { + return (Integer) o; + } + if (o instanceof Number) { + return ((Number) o).intValue(); + } + try { + return Integer.valueOf(o.toString()); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + /** * Retrieves a boolean value from a Map for the given key * diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/services/Service.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/services/Service.groovy index 337718115b..1561b90a6b 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/services/Service.groovy +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/services/Service.groovy @@ -33,17 +33,16 @@ import org.grails.datastore.mapping.core.Datastore trait Service<T> { /** - * The datastore that this service is related to + * @return The datastore that this service is related to */ - private Datastore datastore - @Generated - Datastore getDatastore() { - return datastore - } + abstract Datastore getDatastore() + /** + * Sets the datastore + * @param datastore The datastore + */ @Generated - void setDatastore(Datastore datastore) { - this.datastore = datastore - } + abstract void setDatastore(Datastore datastore) + } diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/transactions/CustomizableRollbackTransactionAttribute.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/transactions/CustomizableRollbackTransactionAttribute.java index 3f55de0a66..f78a8678bb 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/transactions/CustomizableRollbackTransactionAttribute.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/transactions/CustomizableRollbackTransactionAttribute.java @@ -28,6 +28,7 @@ import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.interceptor.NoRollbackRuleAttribute; import org.springframework.transaction.interceptor.RollbackRuleAttribute; import org.springframework.transaction.interceptor.RuleBasedTransactionAttribute; +import org.springframework.transaction.interceptor.TransactionAttribute; /** * Extended version of {@link RuleBasedTransactionAttribute} that ensures all exception types are rolled back and allows inheritance of setRollbackOnly @@ -51,38 +52,48 @@ public class CustomizableRollbackTransactionAttribute extends RuleBasedTransacti super(propagationBehavior, rollbackRules); } - public CustomizableRollbackTransactionAttribute(org.springframework.transaction.interceptor.TransactionAttribute other) { + public CustomizableRollbackTransactionAttribute(TransactionAttribute other) { super(); - setPropagationBehavior(other.getPropagationBehavior()); - setIsolationLevel(other.getIsolationLevel()); - setTimeout(other.getTimeout()); - setReadOnly(other.isReadOnly()); - setName(other.getName()); + copyFrom(other); } public CustomizableRollbackTransactionAttribute(TransactionDefinition other) { super(); - setPropagationBehavior(other.getPropagationBehavior()); - setIsolationLevel(other.getIsolationLevel()); - setTimeout(other.getTimeout()); - setReadOnly(other.isReadOnly()); - setName(other.getName()); + copyFrom(other); } public CustomizableRollbackTransactionAttribute(CustomizableRollbackTransactionAttribute other) { - this((RuleBasedTransactionAttribute) other); + super(); + copyFrom(other); } public CustomizableRollbackTransactionAttribute(RuleBasedTransactionAttribute other) { + super(); + copyFrom(other); + } + + protected void copyFrom(TransactionDefinition other) { + setPropagationBehavior(other.getPropagationBehavior()); + setIsolationLevel(other.getIsolationLevel()); + setTimeout(other.getTimeout()); + setReadOnly(other.isReadOnly()); + setName(other.getName()); + if (other instanceof TransactionAttribute) { + setQualifier(((TransactionAttribute) other).getQualifier()); + } + if (other instanceof RuleBasedTransactionAttribute) { + setRollbackRules(((RuleBasedTransactionAttribute) other).getRollbackRules()); + } if (other instanceof CustomizableRollbackTransactionAttribute) { this.inheritRollbackOnly = ((CustomizableRollbackTransactionAttribute) other).inheritRollbackOnly; + this.connection = ((CustomizableRollbackTransactionAttribute) other).connection; } } @Override public boolean rollbackOn(Throwable ex) { if (log.isTraceEnabled()) { - log.trace("Applying rules to determine whether transaction should rollback on $ex"); + log.trace("Applying rules to determine whether transaction should rollback on " + ex); } RollbackRuleAttribute winner = null; @@ -100,12 +111,14 @@ public class CustomizableRollbackTransactionAttribute extends RuleBasedTransacti } if (log.isTraceEnabled()) { - log.trace("Winning rollback rule is: $winner"); + log.trace("Winning rollback rule is: " + winner); } // User superclass behavior (rollback on unchecked) if no rule matches. if (winner == null) { - log.trace("No relevant rollback rule found: applying default rules"); + if (log.isTraceEnabled()) { + log.trace("No relevant rollback rule found: applying default rules"); + } // always rollback regardless if it is a checked or unchecked exception since Groovy doesn't differentiate those return true; diff --git a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/AbstractDatastoreSpec.groovy b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/AbstractDatastoreSpec.groovy new file mode 100644 index 0000000000..cbe753482a --- /dev/null +++ b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/AbstractDatastoreSpec.groovy @@ -0,0 +1,96 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed 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 + +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.context.support.GenericApplicationContext +import org.springframework.core.env.PropertyResolver +import spock.lang.Specification + +class AbstractDatastoreSpec extends Specification { + + void "test that getApplicationEventPublisher returns the application context if set"() { + given: + def mappingContext = Mock(MappingContext) + def ctx = new GenericApplicationContext() + ctx.refresh() + def datastore = new TestDatastore(mappingContext, (PropertyResolver)null, ctx) + + expect: + datastore.applicationEventPublisher == ctx + datastore.applicationContext == ctx + } + + void "test that SessionCreationEvent is published when connect is called"() { + given: + def mappingContext = Mock(MappingContext) + def events = [] + def publisher = [ + publishEvent: { event -> events << event } + ] as ApplicationEventPublisher + + // Note: GenericApplicationContext implements ApplicationEventPublisher + def ctx = new GenericApplicationContext() + ctx.addApplicationListener({ event -> events << event }) + ctx.refresh() + + def datastore = new TestDatastore(mappingContext, (PropertyResolver)null, ctx) + def mockSession = Mock(Session) + mockSession.getDatastore() >> datastore + datastore.sessionCreator = { mockSession } + + when: + def session = datastore.connect() + + then: + session == mockSession + events.any { it instanceof SessionCreationEvent } + ((SessionCreationEvent)events.find { it instanceof SessionCreationEvent }).session == session + } + + void "test that getApplicationEventPublisher returns the standalone publisher if set"() { + given: + def mappingContext = Mock(MappingContext) + def events = [] + def publisher = [ + publishEvent: { event -> events << event } + ] as ApplicationEventPublisher + + def datastore = new TestDatastore(mappingContext, (PropertyResolver)null, null) + datastore.setApplicationEventPublisher(publisher) + + expect: + datastore.getApplicationEventPublisher() == publisher + datastore.applicationContext == null + } + + static class TestDatastore extends AbstractDatastore { + Closure<Session> sessionCreator = { null } + + TestDatastore(MappingContext mappingContext, PropertyResolver connectionDetails, ConfigurableApplicationContext ctx) { + super(mappingContext, (PropertyResolver)connectionDetails, ctx) + } + + @Override + protected Session createSession(PropertyResolver connectionDetails) { + return sessionCreator.call(connectionDetails) + } + } +} diff --git a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/SessionResolverIntegrationSpec.groovy b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/SessionResolverIntegrationSpec.groovy new file mode 100644 index 0000000000..bc41e9f1e1 --- /dev/null +++ b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/SessionResolverIntegrationSpec.groovy @@ -0,0 +1,58 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed 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 + +import org.grails.datastore.mapping.model.MappingContext +import org.springframework.core.env.PropertyResolver +import spock.lang.Specification + +class SessionResolverIntegrationSpec extends Specification { + + void "test session resolution through datastore"() { + given: + def datastore = new TestDatastore(Mock(MappingContext)) + def session = Mock(Session) + + // Ensure resolver is available + def resolver = datastore.getSessionResolver() + + when: + resolver.bind(session) + + then: + resolver.resolve() == session + + when: + resolver.unbind() + + then: + resolver.resolve() == null + } + + static class TestDatastore extends AbstractDatastore { + TestDatastore(MappingContext mappingContext) { + super(mappingContext) + // Manually inject the resolver since we are testing the integration + this.sessionResolver = new ThreadLocalSessionResolver<Session>() + } + + @Override + protected Session createSession(PropertyResolver connectionDetails) { + return null + } + } +} diff --git a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/ThreadLocalSessionResolverSpec.groovy b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/ThreadLocalSessionResolverSpec.groovy new file mode 100644 index 0000000000..b879e8746e --- /dev/null +++ b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/ThreadLocalSessionResolverSpec.groovy @@ -0,0 +1,65 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed 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 + +import spock.lang.Specification + +class ThreadLocalSessionResolverSpec extends Specification { + + ThreadLocalSessionResolver<Session> resolver = new ThreadLocalSessionResolver<>() + + def "should bind and resolve session"() { + given: + Session session = Mock(Session) + + when: + resolver.bind(session) + + then: + resolver.resolve() == session + + cleanup: + resolver.unbind() + } + + def "should bind and resolve qualified session"() { + given: + Session session = Mock(Session) + String qualifier = "secondary" + + when: + resolver.bind(qualifier, session) + + then: + resolver.resolve(qualifier) == session + + cleanup: + resolver.unbind(qualifier) + } + + def "should unbind session"() { + given: + Session session = Mock(Session) + resolver.bind(session) + + when: + resolver.unbind() + + then: + resolver.resolve() == null + } +} diff --git a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/services/DefaultServiceRegistrySpec.groovy b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/services/DefaultServiceRegistrySpec.groovy index 16f6864bc3..4bc63ff5de 100644 --- a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/services/DefaultServiceRegistrySpec.groovy +++ b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/services/DefaultServiceRegistrySpec.groovy @@ -53,6 +53,8 @@ class DefaultServiceRegistrySpec extends Specification { } } -class TestService implements Service, ITestService {} +class TestService implements Service, ITestService { + Datastore datastore +} interface ITestService {}
