You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by jd...@apache.org on 2026/08/05 12:43:13 UTC
(grails-core) 01/01: fix: address review feedback on the GORM registry consolidation
This is an automated email from the ASF dual-hosted git repository.
jdaugherty pushed a commit to branch fix/gorm-registry-review-feedback
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 4d2b83262830b714d0c23a6cd4c5eec1945ccc19
Author: James Daugherty <jd...@jdresources.net>
AuthorDate: Mon Aug 3 15:15:16 2026 -0400
fix: address review feedback on the GORM registry consolidation
Public API
- Restore Tenants.withId(Class<? extends Datastore>, ..) and give the domain-class
variants distinct names (withIdForDomain / withTenantForDomain); both erased to the
same signature, so ~40 in-tree callers passing a datastore class silently changed
meaning.
- CurrentTenantHolder.get() now throws when different datastores hold different
tenants instead of returning an arbitrary one.
- Restore GormStaticApi.multiTenancyMode as a derived accessor; MongoStaticApi no
longer shadows it or persistentEntity.
- Restore AbstractGormApi.EXCLUDES to public and re-add getMethods,
getExtendedMethods and setTransactionManager, plus the newly added
getTransactionManager, getQualifier and getGormPersistentEntity.
- Remove routesUnqualifiedToMappedConnection() from
MultipleConnectionSourceCapableDatastore; the SPI existed only to accommodate the
in-memory test datastore.
- Add a non-mutating SessionResolver.hasSession(); hasCurrentSession() uses it, so a
routing probe no longer unbinds another datastore's holder.
Correctness
- deleteAll(Map) honours flush instead of discarding params.
- Deterministic unqualified routing: an entity mapped only to non-default connections
routes to its first declared connection, decided at registration rather than by map
iteration order.
- AbstractGormApiRegistry.removeDatastore only evicts on a positive identity match and
logs at warn when a datastore cannot be read.
- Hibernate 5/7 getAll matches an entity back to a requested id that the
ConversionService could not coerce, instead of yielding a null slot.
- first()/last() no longer order by a non-existent single identity for composite-key
entities, so the composite-key TCK test passes on Hibernate 7 rather than being
excluded.
- TenantDelegatingGormOperations binds through the datastore instance it holds rather
than looking one up by class.
Cost and clarity
- Replace the getDatastoreForConnection exception probe with a non-throwing
connection-source name check on the per-operation path.
- Only install metaclass missing-method dispatch for entities that are not GormEntity,
and never over a handler someone else installed.
- Remove the duplicate constraint-registration pass and give API-factory registration
its own registerApiFactories() hook.
- Scope the AstUtils.copyAnnotations dedup guard to the ServiceTransformation call
sites so repeatable annotations survive elsewhere.
- Strip the debug scaffolding from Tenants and GrailsTransactionTemplate; narrow the
catch around getDatastoreForTenantId and log at warn.
- Drop the DatastoreResolver parameters GormRegistry's create*Api methods ignored.
Docs
- Guide: new GORM multi-tenancy section covering Tenants and CurrentTenantHolder;
deleteAll documented under basic CRUD.
- Grails 8 upgrade notes for the removed GormEnhancer members, the new
registerApiFactories() hook, and the Tenants/CurrentTenantHolder changes.
- Developer guide no longer documents the removed enhance() call.
---
.../main/docs/understandingApi/gormEnhancer.adoc | 33 +++-
.../orm/hibernate/HibernateGormEnhancer.groovy | 2 +-
.../org/grails/orm/hibernate/HibernateSession.java | 24 ++-
.../HibernateSessionRetrieveAllSpec.groovy | 76 +++++++
.../orm/hibernate/HibernateGormEnhancer.groovy | 2 +-
.../org/grails/orm/hibernate/HibernateSession.java | 24 ++-
.../orm/hibernate/HibernateSessionSpec.groovy | 40 ++++
.../datastore/gorm/mongo/MongoGormEnhancer.groovy | 4 +-
.../datastore/gorm/mongo/api/MongoStaticApi.groovy | 17 +-
.../gorm/mongo/api/MongoStaticApiSpec.groovy | 27 ++-
.../mapping/simple/SimpleMapDatastore.java | 9 -
.../mapping/simple/SimpleMapDatastoreSpec.groovy | 13 +-
.../grails/gorm/api/GormStaticOperations.groovy | 16 +-
.../gorm/multitenancy/CurrentTenantHolder.groovy | 32 ++-
.../groovy/grails/gorm/multitenancy/Tenants.groovy | 82 +++++---
.../transactions/GrailsTransactionTemplate.groovy | 19 +-
.../grails/datastore/gorm/AbstractGormApi.groovy | 64 +++---
.../datastore/gorm/AbstractGormApiRegistry.groovy | 39 ++--
.../gorm/ConnectionSourceNameResolver.groovy | 36 ++++
.../org/grails/datastore/gorm/GormEnhancer.groovy | 74 +++++--
.../org/grails/datastore/gorm/GormEntity.groovy | 15 +-
.../org/grails/datastore/gorm/GormRegistry.groovy | 105 ++++------
.../org/grails/datastore/gorm/GormStaticApi.groovy | 73 ++++++-
.../TenantDelegatingGormOperations.groovy | 219 +++++++++++----------
.../transform/ServiceTransformation.groovy | 6 +-
.../multitenancy/CurrentTenantHolderSpec.groovy | 52 +++++
.../grails/gorm/multitenancy/TenantsSpec.groovy | 73 ++++++-
.../gorm/AbstractGormApiRegistrySpec.groovy | 65 +++++-
.../datastore/gorm/AbstractGormApiSpec.groovy | 27 ++-
.../gorm/ActiveSessionDatastoreSelectorSpec.groovy | 35 +++-
.../gorm/ConnectionSourceNameResolverSpec.groovy | 51 +++++
.../datastore/gorm/GormEnhancerCoverageSpec.groovy | 48 +++++
.../datastore/gorm/GormRegistryCoverageSpec.groovy | 71 ++++++-
.../grails/datastore/gorm/GormStaticApiSpec.groovy | 29 ++-
.../tck/tests/FirstAndLastMethodSpec.groovy | 2 +-
.../data/testing/tck/tests/GetAllSpec.groovy | 89 +++++++++
.../datastore/mapping/core/AbstractDatastore.java | 6 +-
.../datastore/mapping/core/SessionResolver.groovy | 12 ++
...ransactionSynchronizationSessionResolver.groovy | 17 ++
.../MultipleConnectionSourceCapableDatastore.java | 17 --
.../datastore/mapping/reflect/AstUtils.groovy | 25 ++-
...actionSynchronizationSessionResolverSpec.groovy | 60 ++++++
.../datastore/mapping/reflect/AstUtilsSpec.groovy | 34 +++-
grails-doc/src/en/guide/GORM/multiTenancy.adoc | 135 +++++++++++++
.../en/guide/GORM/quickStartGuide/basicCRUD.adoc | 38 ++++
grails-doc/src/en/guide/toc.yml | 1 +
.../src/en/guide/upgrading/upgrading80x.adoc | 84 ++++++++
47 files changed, 1640 insertions(+), 382 deletions(-)
diff --git a/grails-data-docs/guide-developer/src/main/docs/understandingApi/gormEnhancer.adoc b/grails-data-docs/guide-developer/src/main/docs/understandingApi/gormEnhancer.adoc
index 156a2169a7..d5212989ef 100644
--- a/grails-data-docs/guide-developer/src/main/docs/understandingApi/gormEnhancer.adoc
+++ b/grails-data-docs/guide-developer/src/main/docs/understandingApi/gormEnhancer.adoc
@@ -43,8 +43,7 @@ def mongoDatastore = new MongoDatastore(context)
mongoDatastore.afterPropertiesSet()
// enhance
-def enhancer = new MongoGormEnhancer(mongoDatastore, new DatastoreTransactionManager(datastore: mongoDatastore))
-enhancer.enhance()
+new MongoGormEnhancer(mongoDatastore, new DatastoreTransactionManager(datastore: mongoDatastore))
// use GORM!
def books = Book.list()
@@ -54,6 +53,30 @@ The key element for enabling the use of all GORM methods (`list()`, dynamic find
[source,groovy]
----
-def enhancer = new GormEnhancer(mongoDatastore, new DatastoreTransactionManager(datastore: mongoDatastore))
-enhancer.enhance()
-----
\ No newline at end of file
+new GormEnhancer(mongoDatastore, new DatastoreTransactionManager(datastore: mongoDatastore))
+----
+
+Constructing the enhancer is all that is required. It registers every entity in the datastore's mapping context
+as it is constructed, and `registerEntity(PersistentEntity)` registers one that is added later. There is no
+separate `enhance()` step.
+
+=== Supplying Your Own API Objects
+
+API objects are built on demand by a `GormApiFactory`, looked up per `(entity, qualifier)`. A datastore that
+needs specialised API objects — for instance a static API exposing driver-specific query methods — registers a
+factory for its datastore type by overriding `registerApiFactories()`, which the enhancer calls once before any
+entity is registered:
+
+[source,groovy]
+----
+class MongoGormEnhancer extends GormEnhancer {
+
+ @Override
+ protected void registerApiFactories() {
+ GormRegistry.instance.registerApiFactory(MongoDatastore, new MongoGormApiFactory())
+ }
+}
+----
+
+Registering the factory before entity registration is what lets code cast the result of a lookup to the
+specialised type, for example `(MongoStaticApi) findStaticApi(..)`.
\ No newline at end of file
diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy
index 7433f4ca9d..4527412b0a 100644
--- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy
+++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy
@@ -75,7 +75,7 @@ class HibernateGormEnhancer extends GormEnhancer {
}
@Override
- protected void registerConstraints(Datastore datastore) {
+ protected void registerApiFactories() {
GormRegistry.instance.registerApiFactory(HibernateDatastore, new HibernateGormApiFactory(Thread.currentThread().contextClassLoader))
}
}
diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java
index b1a4d57e12..ce17489a4b 100644
--- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java
+++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java
@@ -190,6 +190,7 @@ public class HibernateSession extends AbstractHibernateSession {
}
final Map<Object, Object> entitiesById = new HashMap<>();
+ final Map<String, Object> entitiesByIdString = new HashMap<>();
if (!distinctIds.isEmpty()) {
final CriteriaBuilder criteriaBuilder = session.getCriteriaBuilder();
CriteriaQuery criteriaQuery = criteriaBuilder.createQuery(type);
@@ -204,19 +205,38 @@ public class HibernateSession extends AbstractHibernateSession {
final List results = new HibernateHqlQuery(this, persistentEntity, jpaQuery).list();
for (Object entity : results) {
- entitiesById.put(session.getIdentifier(entity), entity);
+ Object identifier = session.getIdentifier(entity);
+ entitiesById.put(identifier, entity);
+ entitiesByIdString.put(String.valueOf(identifier), entity);
}
}
// Reassemble in the requested order, leaving a null slot for missing ids.
final List ordered = new ArrayList<>(requestedIds.size());
for (Serializable requestedId : requestedIds) {
- ordered.add(requestedId == null ? null : entitiesById.get(requestedId));
+ ordered.add(requestedId == null ? null : resolveEntity(requestedId, entitiesById, entitiesByIdString));
}
return ordered;
});
}
+ /**
+ * Looks up the entity returned for a requested id.
+ *
+ * The direct lookup covers ids that reached the query already in the identifier's type. When
+ * {@link #convertToIdentifierType} could not coerce a key, that key was passed through raw and
+ * Hibernate coerced it instead, so the returned entity's identifier does not equal the requested
+ * key and the direct lookup misses. The string index catches exactly that case: identifiers are
+ * unique within an entity type, so their string forms are too.
+ */
+ private Object resolveEntity(Serializable requestedId, Map<Object, Object> entitiesById, Map<String, Object> entitiesByIdString) {
+ Object entity = entitiesById.get(requestedId);
+ if (entity == null) {
+ entity = entitiesByIdString.get(String.valueOf(requestedId));
+ }
+ return entity;
+ }
+
private Serializable convertToIdentifierType(Object key, Class idType, ConversionService conversionService) {
if (key == null || idType.isInstance(key)) {
return (Serializable) key;
diff --git a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionRetrieveAllSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionRetrieveAllSpec.groovy
new file mode 100644
index 0000000000..9829bdab93
--- /dev/null
+++ b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionRetrieveAllSpec.groovy
@@ -0,0 +1,76 @@
+/*
+ * 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.orm.hibernate
+
+import grails.gorm.annotation.Entity
+import grails.gorm.tests.HibernateGormDatastoreSpec
+
+/**
+ * The shared TCK {@code GetAllSpec} covers the portable contract (order, duplicates, null slots).
+ * This spec covers the Hibernate-specific part: a requested id that the {@code ConversionService}
+ * cannot coerce is passed to the query raw and coerced by Hibernate instead, so the entity that
+ * comes back does not carry the requested key as its identifier and has to be matched back to it.
+ */
+class HibernateSessionRetrieveAllSpec extends HibernateGormDatastoreSpec {
+
+ void setupSpec() {
+ manager.registerDomainClasses(HSRABook)
+ }
+
+ void "retrieveAll resolves an id supplied as a String, which Hibernate coerces for the query"() {
+ given:
+ def session = manager.session
+ def id1 = session.persist(new HSRABook(title: 'Coerce1'))
+ def id2 = session.persist(new HSRABook(title: 'Coerce2'))
+ session.flush()
+ session.clear()
+
+ when: "ids arrive as strings, as they do straight off a request parameter"
+ def results = session.retrieveAll(HSRABook, id1.toString(), id2.toString())
+
+ then: "the entity's own identifier is matched back to the requested key, not left as a null slot"
+ results.size() == 2
+ results*.title == ['Coerce1', 'Coerce2']
+ }
+
+ void "retrieveAll preserves the requested order, duplicates and a null slot for a missing id"() {
+ given:
+ def session = manager.session
+ def id1 = session.persist(new HSRABook(title: 'Order1'))
+ def id2 = session.persist(new HSRABook(title: 'Order2'))
+ session.flush()
+ session.clear()
+ def missingId = id2 + 100000L
+
+ when:
+ def results = session.retrieveAll(HSRABook, id2, id1, id2, missingId)
+
+ then:
+ results.size() == 4
+ results[0].title == 'Order2'
+ results[1].title == 'Order1'
+ results[2].title == 'Order2'
+ results[3] == null
+ }
+}
+
+@Entity
+class HSRABook {
+ String title
+}
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy
index 225bcb9f4a..3e77c2127d 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormEnhancer.groovy
@@ -90,7 +90,7 @@ class HibernateGormEnhancer extends GormEnhancer {
}
@Override
- protected void registerConstraints(Datastore datastore) {
+ protected void registerApiFactories() {
GormRegistry.instance.registerApiFactory(HibernateDatastore, new HibernateGormApiFactory(Thread.currentThread().contextClassLoader))
}
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java
index da5b381273..ec4e56953d 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateSession.java
@@ -414,6 +414,7 @@ public class HibernateSession extends AbstractAttributeStoringSession implements
}
final Map<Object, Object> entitiesById = new HashMap<>();
+ final Map<String, Object> entitiesByIdString = new HashMap<>();
if (!distinctIds.isEmpty()) {
HqlQueryContext queryContext = HqlQueryContext.prepare(
persistentEntity,
@@ -434,19 +435,38 @@ public class HibernateSession extends AbstractAttributeStoringSession implements
queryContext
).list();
for (Object entity : results) {
- entitiesById.put(session.getIdentifier(entity), entity);
+ Object identifier = session.getIdentifier(entity);
+ entitiesById.put(identifier, entity);
+ entitiesByIdString.put(String.valueOf(identifier), entity);
}
}
// Reassemble in the requested order, leaving a null slot for missing ids.
final List ordered = new ArrayList<>(requestedIds.size());
for (Serializable requestedId : requestedIds) {
- ordered.add(requestedId == null ? null : entitiesById.get(requestedId));
+ ordered.add(requestedId == null ? null : resolveEntity(requestedId, entitiesById, entitiesByIdString));
}
return ordered;
});
}
+ /**
+ * Looks up the entity returned for a requested id.
+ *
+ * The direct lookup covers ids that reached the query already in the identifier's type. When
+ * {@link #convertToIdentifierType} could not coerce a key, that key was passed through raw and
+ * Hibernate coerced it instead, so the returned entity's identifier does not equal the requested
+ * key and the direct lookup misses. The string index catches exactly that case: identifiers are
+ * unique within an entity type, so their string forms are too.
+ */
+ private Object resolveEntity(Serializable requestedId, Map<Object, Object> entitiesById, Map<String, Object> entitiesByIdString) {
+ Object entity = entitiesById.get(requestedId);
+ if (entity == null) {
+ entity = entitiesByIdString.get(String.valueOf(requestedId));
+ }
+ return entity;
+ }
+
private Serializable convertToIdentifierType(Object key, Class idType, ConversionService conversionService) {
if (key == null || idType.isInstance(key)) {
return (Serializable) key;
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionSpec.groovy
index 9f696c5d30..5dca57facb 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/HibernateSessionSpec.groovy
@@ -368,6 +368,46 @@ class HibernateSessionSpec extends HibernateGormDatastoreSpec {
results.size() == 2
}
+ void "retrieveAll resolves an id supplied as a String, which Hibernate coerces for the query"() {
+ given:
+ def session = getSession()
+ def b1 = new HSBook(title: "Coerce1")
+ def b2 = new HSBook(title: "Coerce2")
+ def id1 = session.persist(b1)
+ def id2 = session.persist(b2)
+ session.flush()
+ session.clear()
+
+ when: "ids arrive as strings, as they do straight off a request parameter"
+ def results = session.retrieveAll(HSBook, id1.toString(), id2.toString())
+
+ then: "the entity's own identifier is matched back to the requested key, not left as a null slot"
+ results.size() == 2
+ results*.title == ['Coerce1', 'Coerce2']
+ }
+
+ void "retrieveAll preserves the requested order, duplicates and a null slot for a missing id"() {
+ given:
+ def session = getSession()
+ def b1 = new HSBook(title: "Order1")
+ def b2 = new HSBook(title: "Order2")
+ def id1 = session.persist(b1)
+ def id2 = session.persist(b2)
+ session.flush()
+ session.clear()
+ def missingId = id2 + 100000L
+
+ when:
+ def results = session.retrieveAll(HSBook, id2, id1, id2, missingId)
+
+ then:
+ results.size() == 4
+ results[0].title == 'Order2'
+ results[1].title == 'Order1'
+ results[2].title == 'Order2'
+ results[3] == null
+ }
+
// -------------------------------------------------------------------------
// Bulk criteria operations
// -------------------------------------------------------------------------
diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/MongoGormEnhancer.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/MongoGormEnhancer.groovy
index 0a86670be8..fd8220878b 100644
--- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/MongoGormEnhancer.groovy
+++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/MongoGormEnhancer.groovy
@@ -25,7 +25,6 @@ import org.springframework.transaction.PlatformTransactionManager
import org.grails.datastore.gorm.GormEnhancer
import org.grails.datastore.gorm.GormRegistry
import org.grails.datastore.gorm.finders.DynamicFinder
-import org.grails.datastore.mapping.core.Datastore
import org.grails.datastore.mapping.mongo.MongoDatastore
import org.grails.datastore.mapping.mongo.connections.MongoConnectionSourceSettings
@@ -62,8 +61,7 @@ class MongoGormEnhancer extends GormEnhancer {
}
@Override
- protected void registerConstraints(Datastore datastore) {
- super.registerConstraints(datastore)
+ protected void registerApiFactories() {
GormRegistry.instance.registerApiFactory(MongoDatastore, new MongoGormApiFactory())
}
diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy
index 54f979c3bb..16e3fc7013 100644
--- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy
+++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApi.groovy
@@ -64,15 +64,8 @@ import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
@CompileStatic
class MongoStaticApi<D> extends GormStaticApi<D> implements MongoAllOperations<D> {
- protected final PersistentEntity persistentEntity
- protected final MultiTenancySettings.MultiTenancyMode multiTenancyMode
-
MongoStaticApi(Class<D> persistentClass, Datastore datastore, List<FinderMethod> finders, PlatformTransactionManager transactionManager) {
super(persistentClass, datastore, finders, transactionManager)
- this.persistentEntity = datastore.mappingContext.getPersistentEntity(persistentClass.name)
- this.multiTenancyMode = (datastore instanceof MongoDatastore)
- ? ((MongoDatastore) datastore).multiTenancyMode
- : MultiTenancySettings.MultiTenancyMode.NONE
}
FindIterable<D> find(Bson filter) {
@@ -292,9 +285,10 @@ class MongoStaticApi<D> extends GormStaticApi<D> implements MongoAllOperations<D
}
protected Bson wrapFilterWithMultiTenancy(Bson filter) {
- if (multiTenancyMode == MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR && persistentEntity.isMultiTenant()) {
+ PersistentEntity entity = getGormPersistentEntity()
+ if (multiTenancyMode == MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR && entity.isMultiTenant()) {
filter = Filters.and(
- Filters.eq(MappingUtils.getTargetKey(persistentEntity.tenantId), Tenants.currentId((Class<Datastore>) datastore.getClass())),
+ Filters.eq(MappingUtils.getTargetKey(entity.tenantId), Tenants.currentId((Class<Datastore>) datastore.getClass())),
filter
)
}
@@ -303,9 +297,10 @@ class MongoStaticApi<D> extends GormStaticApi<D> implements MongoAllOperations<D
private List<Bson> preparePipeline(List pipeline) {
List<Bson> newPipeline = new ArrayList<Bson>()
- if (multiTenancyMode == MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR && persistentEntity.isMultiTenant()) {
+ PersistentEntity entity = getGormPersistentEntity()
+ if (multiTenancyMode == MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR && entity.isMultiTenant()) {
newPipeline.add(
- Aggregates.match(Filters.eq(MappingUtils.getTargetKey(persistentEntity.tenantId), Tenants.currentId((Class<Datastore>) datastore.getClass())))
+ Aggregates.match(Filters.eq(MappingUtils.getTargetKey(entity.tenantId), Tenants.currentId((Class<Datastore>) datastore.getClass())))
)
}
for (o in pipeline) {
diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApiSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApiSpec.groovy
index 71e6176dc5..45fab6d8b6 100644
--- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApiSpec.groovy
+++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/api/MongoStaticApiSpec.groovy
@@ -25,27 +25,34 @@ import spock.lang.AutoCleanup
import spock.lang.Specification
/**
- * The diff added two new constructor-time field assignments: {@code persistentEntity} (resolved
- * eagerly from the datastore's mapping context) and {@code multiTenancyMode} (read from the
- * datastore when it's a real {@code MongoDatastore}, else defaulting to {@code NONE}). The
- * existing {@code MongoStaticApiMultiTenancySpec} (a real, Docker-backed
- * {@code AutoStartedMongoSpec}) already covers the "real MongoDatastore" branch; this spec covers
- * the "not a MongoDatastore" fallback cheaply with a {@link SimpleMapDatastore}, avoiding the need
- * to spin up a real MongoDB instance just to prove a defaulting branch.
+ * {@code MongoStaticApi} inherits {@code multiTenancyMode} and the persistent-entity lookup from
+ * {@code GormStaticApi} rather than re-declaring them, so there is exactly one of each per API object.
+ * The existing {@code MongoStaticApiMultiTenancySpec} (a real, Docker-backed
+ * {@code AutoStartedMongoSpec}) covers the "real MongoDatastore" branch; this spec covers the
+ * "not multi-tenant" fallback cheaply with a {@link SimpleMapDatastore}, avoiding the need to spin up a
+ * real MongoDB instance just to prove a defaulting branch.
*/
class MongoStaticApiSpec extends Specification {
@AutoCleanup
SimpleMapDatastore datastore = new SimpleMapDatastore(MongoStaticApiSpecThing)
- void "constructing MongoStaticApi with a non-MongoDatastore defaults multiTenancyMode to NONE"() {
+ void "MongoStaticApi over a datastore with no multi-tenancy reports mode NONE"() {
when:
def api = new MongoStaticApi<MongoStaticApiSpecThing>(MongoStaticApiSpecThing, datastore, [], datastore.transactionManager)
then:
api.multiTenancyMode == MultiTenancySettings.MultiTenancyMode.NONE
- api.persistentEntity != null
- api.persistentEntity.javaClass == MongoStaticApiSpecThing
+ }
+
+ void "MongoStaticApi resolves its persistent entity through the inherited accessor"() {
+ when:
+ def api = new MongoStaticApi<MongoStaticApiSpecThing>(MongoStaticApiSpecThing, datastore, [], datastore.transactionManager)
+
+ then: "there is no shadowing field — base-class code and MongoStaticApi see the same entity"
+ api.gormPersistentEntity != null
+ api.gormPersistentEntity.javaClass == MongoStaticApiSpecThing
+ !MongoStaticApi.declaredFields.any { it.name in ['persistentEntity', 'multiTenancyMode'] }
}
}
diff --git a/grails-data-simple/src/main/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastore.java b/grails-data-simple/src/main/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastore.java
index b25d56a6a4..68d4b651ee 100644
--- a/grails-data-simple/src/main/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastore.java
+++ b/grails-data-simple/src/main/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastore.java
@@ -373,15 +373,6 @@ public class SimpleMapDatastore extends AbstractDatastore implements Closeable,
throw new ConfigurationException("No datastore found for connection named [" + connectionName + "]");
}
- @Override
- public boolean routesUnqualifiedToMappedConnection() {
- // This in-memory mock manages a single session on the parent datastore; its per-connection
- // children exist only for explicit connection access. An entity mapped to a non-default
- // datasource must therefore keep its unqualified operations on the parent, not on a child
- // whose session the unit-test harness never flushes.
- return false;
- }
-
@Override
public void close() throws IOException {
try {
diff --git a/grails-data-simple/src/test/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastoreSpec.groovy b/grails-data-simple/src/test/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastoreSpec.groovy
index 1e96e7923a..e7e1b5baeb 100644
--- a/grails-data-simple/src/test/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastoreSpec.groovy
+++ b/grails-data-simple/src/test/groovy/org/grails/datastore/mapping/simple/SimpleMapDatastoreSpec.groovy
@@ -29,8 +29,7 @@ import spock.lang.Specification
* enable it). The diff here reworked {@code getDatastoreForConnection} to be idempotent for a leaf
* (single-connection, no-children) datastore resolving its own connection name back to itself
* (needed so an API already bound to a tenant's own datastore can still be wrapped in
- * {@code Tenants.withId(tenantId)} without failing), and added
- * {@code routesUnqualifiedToMappedConnection()} returning {@code false}.
+ * {@code Tenants.withId(tenantId)} without failing).
*/
class SimpleMapDatastoreSpec extends Specification {
@@ -59,8 +58,12 @@ class SimpleMapDatastoreSpec extends Specification {
thrown(ConfigurationException)
}
- void "routesUnqualifiedToMappedConnection always returns false"() {
- expect:
- !multiDatastore.routesUnqualifiedToMappedConnection()
+ void "each connection source gets its own child datastore with its own store"() {
+ given:
+ Datastore secondary = multiDatastore.getDatastoreForConnection('secondary')
+
+ expect: "a per-connection child is a real, independent datastore, not a view onto the parent"
+ !secondary.is(multiDatastore)
+ secondary.getMappingContext().is(multiDatastore.getMappingContext())
}
}
diff --git a/grails-datamapping-core/src/main/groovy/grails/gorm/api/GormStaticOperations.groovy b/grails-datamapping-core/src/main/groovy/grails/gorm/api/GormStaticOperations.groovy
index ada4b10fa7..1b31ee5fb6 100644
--- a/grails-datamapping-core/src/main/groovy/grails/gorm/api/GormStaticOperations.groovy
+++ b/grails-datamapping-core/src/main/groovy/grails/gorm/api/GormStaticOperations.groovy
@@ -106,14 +106,24 @@ interface GormStaticOperations<D> {
List<Serializable> saveAll(Iterable<?> objectsToSave)
/**
- * Deletes all objects
+ * Deletes every persisted instance of this class.
+ *
+ * To delete a subset, build a query and delete through it — for example
+ * {@code Book.where { author == 'X' }.deleteAll()}.
+ *
* @return The number of objects deleted
*/
Number deleteAll()
/**
- * Deletes all objects for the given arguments
- * @param params The arguments
+ * Deletes every persisted instance of this class.
+ *
+ * {@code params} controls how the delete is executed; it does not narrow what is deleted. The
+ * supported argument is {@code flush}, which flushes the session once the delete has been issued.
+ * To delete a subset, build a query and delete through it — for example
+ * {@code Book.where { author == 'X' }.deleteAll()}.
+ *
+ * @param params The arguments, e.g. {@code [flush: true]}
* @return The number of objects deleted
*/
Number deleteAll(Map params)
diff --git a/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/CurrentTenantHolder.groovy b/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/CurrentTenantHolder.groovy
index a3b9e27c88..336843cafd 100644
--- a/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/CurrentTenantHolder.groovy
+++ b/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/CurrentTenantHolder.groovy
@@ -22,7 +22,17 @@ import groovy.transform.CompileStatic
import org.grails.datastore.mapping.core.Datastore
import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.multitenancy.exceptions.TenantException
+/**
+ * Holds the tenant bound to the current thread, per datastore.
+ *
+ * A thread may have a different tenant bound for each datastore in play, so lookups are scoped by
+ * datastore. Bindings are made either against a datastore instance or against a datastore type; an
+ * instance binding wins over a type binding for the same datastore.
+ *
+ * @since 8.0
+ */
@CompileStatic
class CurrentTenantHolder {
@@ -34,14 +44,26 @@ class CurrentTenantHolder {
}
/**
- * @return Obtain the current tenant (fallback for any datastore)
+ * Obtain the tenant bound to the current thread, irrespective of datastore.
+ *
+ * Prefer {@link #get(Datastore)}. A thread can hold a different tenant for each datastore, and only the
+ * datastore-scoped lookup can say which of them applies to a given operation.
+ *
+ * @return The bound tenant id, or {@code null} if no tenant is bound
+ * @throws TenantException if different tenants are bound for different datastores. There is no correct
+ * answer to return in that case, and returning an arbitrary one is how cross-tenant reads and
+ * writes happen.
*/
static Serializable get() {
- def map = currentTenantThreadLocal.get()
- if (!map.isEmpty()) {
- return map.values().iterator().next()
+ Map<Object, Serializable> map = currentTenantThreadLocal.get()
+ if (map.isEmpty()) {
+ return null
+ }
+ Set<Serializable> boundTenants = new LinkedHashSet<>(map.values())
+ if (boundTenants.size() > 1) {
+ throw new TenantException("Cannot resolve a single current tenant: ${boundTenants} are bound on this thread for different datastores. Use get(Datastore) to select the tenant of the datastore being operated on.")
}
- return null
+ return boundTenants.iterator().next()
}
/**
diff --git a/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/Tenants.groovy b/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/Tenants.groovy
index bc098fd0fd..85f8f305b9 100644
--- a/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/Tenants.groovy
+++ b/grails-datamapping-core/src/main/groovy/grails/gorm/multitenancy/Tenants.groovy
@@ -26,10 +26,12 @@ import org.grails.datastore.gorm.GormRegistry
import org.grails.datastore.mapping.core.Datastore
import org.grails.datastore.mapping.core.connections.ConnectionSource
import org.grails.datastore.mapping.core.connections.ConnectionSources
+import org.grails.datastore.mapping.core.exceptions.ConfigurationException
import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
import org.grails.datastore.mapping.multitenancy.TenantResolver
+import org.grails.datastore.mapping.multitenancy.exceptions.TenantException
/**
* Helper methods for working with multi tenancy
@@ -59,7 +61,13 @@ class Tenants {
}
/**
- * Execute the given closure with the given tenant id.
+ * Binds the given tenant id to the current thread for the duration of the closure.
+ *
+ * This binds the tenant only. Unlike {@link #withId(Serializable, Closure)} it opens no session and
+ * manages no connection life cycle: the closure runs in whatever session the caller has already
+ * established, and the caller remains responsible for opening and closing it. Reach for {@code withId}
+ * when the tenant's work needs a session of its own — in DATABASE mode that is also what selects the
+ * tenant's connection.
*
* @param tenantId The tenant id
* @param callable The closure
@@ -213,12 +221,36 @@ class Tenants {
}
/**
- * Execute the given closure with given tenant id
+ * Execute the given closure with given tenant id against the datastore of the given type
+ *
+ * @param datastoreClass The datastore type
+ * @param tenantId The tenant id
+ * @param callable The closure
+ * @return The result of the closure
+ */
+ static <T> T withId(Class<? extends Datastore> datastoreClass, Serializable tenantId, Closure<T> callable) {
+ Datastore datastore = datastoreLocator.getDatastore(datastoreClass)
+ if (datastore instanceof MultiTenantCapableDatastore) {
+ MultiTenantCapableDatastore multiTenantCapableDatastore = (MultiTenantCapableDatastore) datastore
+ return withId(multiTenantCapableDatastore, tenantId, callable)
+ }
+ else {
+ throw new UnsupportedOperationException('Datastore implementation does not support multi-tenancy')
+ }
+ }
+
+ /**
+ * Execute the given closure with given tenant id against the datastore that maps the given domain class.
+ *
+ * Distinct from {@link #withId(Class, Serializable, Closure)}, which takes a <i>datastore</i> type: both
+ * erase to the same signature, so the domain-class form needs its own name to stay unambiguous.
+ *
+ * @param domainClass The domain class whose datastore should be used
* @param tenantId The tenant id
* @param callable The closure
* @return The result of the closure
*/
- static <T> T withId(Class domainClass, Serializable tenantId, Closure<T> callable) {
+ static <T> T withIdForDomain(Class domainClass, Serializable tenantId, Closure<T> callable) {
Datastore datastore = datastoreLocator.getDatastoreForDomain(domainClass)
if (datastore instanceof MultiTenantCapableDatastore) {
MultiTenantCapableDatastore multiTenantCapableDatastore = (MultiTenantCapableDatastore) datastore
@@ -230,12 +262,18 @@ class Tenants {
}
/**
- * Execute the given closure with given tenant id for the given datastore. This method will create a new datastore session for the scope of the call and hence is designed to be used to manage the connection life cycle
+ * Binds the given tenant id to the current thread for the datastore that maps the given domain class, for
+ * the duration of the closure.
+ *
+ * This binds the tenant only. Unlike {@link #withIdForDomain(Class, Serializable, Closure)} it opens no
+ * session and manages no connection: the caller owns the session the closure runs in.
+ *
+ * @param domainClass The domain class whose datastore the tenant should be bound for
* @param tenantId The tenant id
* @param callable The closure
* @return The result of the closure
*/
- static <T> T withTenant(Class domainClass, Serializable tenantId, Closure<T> callable) {
+ static <T> T withTenantForDomain(Class domainClass, Serializable tenantId, Closure<T> callable) {
Datastore datastore = datastoreLocator.getDatastoreForDomain(domainClass)
return CurrentTenantHolder.withTenant(datastore.getClass(), tenantId) {
return CurrentTenantHolder.withTenant(datastore, tenantId, callable)
@@ -287,11 +325,14 @@ class Tenants {
*/
static <T> T withId(MultiTenantCapableDatastore multiTenantCapableDatastore, Serializable tenantId, Closure<T> callable) {
log.debug('Tenants.withId called for datastore {} with tenantId {}', multiTenantCapableDatastore, tenantId)
- org.grails.datastore.mapping.core.Datastore childDatastore = null
+ Datastore childDatastore = null
try {
childDatastore = multiTenantCapableDatastore.getDatastoreForTenantId(tenantId)
- } catch (Throwable e) {
- log.debug('Ignoring failure resolving datastore for tenant {}: {}', tenantId, e.message)
+ }
+ catch (ConfigurationException | TenantException e) {
+ // An unknown tenant or missing connection: fall through to the session-creating path below,
+ // which resolves the tenant again and reports the failure to the caller rather than hiding it.
+ log.warn('Could not resolve a 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.
@@ -319,21 +360,15 @@ class Tenants {
def i = callable.parameterTypes.length
if (i == 2) {
return multiTenantCapableDatastore.withSession { session ->
- def result = callable.call(tenantId, session)
- log.debug('Result from shared connection with 2 args: {}', result)
- return result
+ return callable.call(tenantId, session)
}
}
else {
switch (i) {
case 0:
- def result = callable.call()
- log.debug('Result from shared connection with 0 args: {}', result)
- return result
+ return callable.call()
case 1:
- def result = callable.call(tenantId)
- log.debug('Result from shared connection with 1 arg: {}', result)
- return result
+ return callable.call(tenantId)
default:
throw new IllegalArgumentException('Provided closure accepts too many arguments')
}
@@ -341,21 +376,14 @@ class Tenants {
}
else {
return multiTenantCapableDatastore.withNewSession(tenantId) { session ->
- log.debug('Inside withNewSession for tenantId {}', tenantId)
def i = callable.parameterTypes.length
switch (i) {
case 0:
- def result = callable.call()
- log.debug('Result from new session with 0 args: {}', result)
- return result
+ return callable.call()
case 1:
- def result = callable.call(tenantId)
- log.debug('Result from new session with 1 arg: {}', result)
- return result
+ return callable.call(tenantId)
case 2:
- def result = callable.call(tenantId, session)
- log.debug('Result from new session with 2 args: {}', result)
- return result
+ return callable.call(tenantId, session)
default:
throw new IllegalArgumentException('Provided closure accepts too many arguments')
}
diff --git a/grails-datamapping-core/src/main/groovy/grails/gorm/transactions/GrailsTransactionTemplate.groovy b/grails-datamapping-core/src/main/groovy/grails/gorm/transactions/GrailsTransactionTemplate.groovy
index 424aefd923..4b8c559d53 100644
--- a/grails-datamapping-core/src/main/groovy/grails/gorm/transactions/GrailsTransactionTemplate.groovy
+++ b/grails-datamapping-core/src/main/groovy/grails/gorm/transactions/GrailsTransactionTemplate.groovy
@@ -73,28 +73,13 @@ class GrailsTransactionTemplate {
Object result = transactionTemplate.execute(new TransactionCallback() {
Object doInTransaction(TransactionStatus status) {
try {
- if (log.isDebugEnabled()) {
- log.debug('executeAndRollback doInTransaction - starting on transaction manager: ' + transactionTemplate.getTransactionManager())
- }
- Object val = action.call(status)
- if (log.isDebugEnabled()) {
- log.debug('executeAndRollback doInTransaction - finished action')
- log.debug('executeAndRollback action finished. status=' + status)
- }
- return val
+ return action.call(status)
}
catch (Throwable e) {
- if (log.isDebugEnabled()) {
- log.debug('executeAndRollback doInTransaction - caught exception: ' + e)
- log.debug('executeAndRollback action caught: ' + e)
- }
+ log.debug('Rolling back after the action threw', e)
return new ThrowableHolder(e)
} finally {
status.setRollbackOnly()
- if (log.isDebugEnabled()) {
- log.debug('executeAndRollback doInTransaction - setRollbackOnly called. isRollbackOnly=' + status.isRollbackOnly())
- log.debug('executeAndRollback setRollbackOnly called. status.isRollbackOnly=' + status.isRollbackOnly())
- }
}
}
})
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApi.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApi.groovy
index 139310b883..5565ff7889 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApi.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApi.groovy
@@ -35,7 +35,6 @@ 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
@@ -49,10 +48,30 @@ import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
@CompileStatic
abstract class AbstractGormApi<D> extends AbstractDatastoreApi {
- protected static final List<String> EXCLUDES = [
- 'wait', 'notify', 'notifyAll', 'toString', 'hashCode', 'equals', 'getClass',
- 'getMetaClass', 'setMetaClass', 'getProperty', 'setProperty', 'invokeMethod'
- ]
+ /**
+ * The names of methods that are part of the API object's own plumbing and must never be reported by
+ * {@link #getMethods()} / {@link #getExtendedMethods()} as part of the GORM surface of an entity.
+ */
+ static final List<String> EXCLUDES = [
+ 'setProperty',
+ 'getProperty',
+ 'getMetaClass',
+ 'setMetaClass',
+ 'invokeMethod',
+ 'getMethods',
+ 'getExtendedMethods',
+ 'wait',
+ 'equals',
+ 'toString',
+ 'hashCode',
+ 'getClass',
+ 'notify',
+ 'notifyAll',
+ 'setTransactionManager',
+ 'getTransactionManager',
+ 'getQualifier',
+ 'getGormPersistentEntity'
+ ].asImmutable()
private static final Map<Class, List<Method>> METHODS_CACHE = new ConcurrentHashMap<>()
private static final Map<Class, List<Method>> EXTENDED_METHODS_CACHE = new ConcurrentHashMap<>()
@@ -109,23 +128,17 @@ abstract class AbstractGormApi<D> extends AbstractDatastoreApi {
if (currentQualifier != null && !ConnectionSource.DEFAULT.equals(currentQualifier) && !ConnectionSource.OLD_DEFAULT.equalsIgnoreCase(currentQualifier)) {
if (isMultiTenantEntity && isMultiTenantCapable) {
// Determine whether the qualifier names a datasource connection or is a tenant ID.
- // A datasource connection qualifier resolves via getDatastoreForConnection(); a tenant ID
- // (e.g. from withTenant("t1")) does not. When it IS a connection qualifier we must not
- // bind it as the tenant ID — doing so overwrites the tenant context set by the
- // TenantResolver (e.g. SystemPropertyTenantResolver) and causes discriminator filters to
- // match the connection name instead of the real tenant.
- boolean isConnectionQualifier = false
- if (ds instanceof MultipleConnectionSourceCapableDatastore) {
- try {
- Datastore resolved = ((MultipleConnectionSourceCapableDatastore) ds)
- .getDatastoreForConnection(currentQualifier)
- if (resolved != null) {
- isConnectionQualifier = true
- }
- } catch (Exception ignored) {
- // qualifier is not a known datasource name; treat it as a tenant ID below
- }
- }
+ // A datasource connection qualifier is one of the datastore's configured connection
+ // sources; a tenant ID (e.g. from withTenant("t1")) is not. When it IS a connection
+ // qualifier we must not bind it as the tenant ID — doing so overwrites the tenant context
+ // set by the TenantResolver (e.g. SystemPropertyTenantResolver) and causes discriminator
+ // filters to match the connection name instead of the real tenant.
+ //
+ // This is checked against the declared connection source names rather than by probing
+ // getDatastoreForConnection(), which throws ConfigurationException for an unknown name:
+ // in DISCRIMINATOR mode the qualifier is always a tenant ID, so probing would fill in a
+ // stack trace on every single operation.
+ boolean isConnectionQualifier = ConnectionSourceNameResolver.isConnectionSourceName(ds, currentQualifier)
if (!isConnectionQualifier) {
// Qualifier is a tenant ID — bind it so the session and any discriminator filter
// both see the correct tenant for this operation.
@@ -150,6 +163,13 @@ abstract class AbstractGormApi<D> extends AbstractDatastoreApi {
}
/**
+ * The qualifier this API instance is bound to: either a connection source name, or a tenant id.
+ *
+ * Tenant ids are represented here — and throughout GORM's connection resolution, including
+ * {@code MultiTenantCapableDatastore.getDatastoreForTenantId} — by their {@code toString()}. A tenant id
+ * must therefore be uniquely representable as a string: two ids whose {@code toString()} collide (for
+ * example {@code 1L} and {@code "1"}) are the same qualifier and resolve to the same cached API.
+ *
* @return The qualifier for this API instance
*/
String getQualifier() {
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApiRegistry.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApiRegistry.groovy
index 60dd5150c5..28175d56d6 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApiRegistry.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/AbstractGormApiRegistry.groovy
@@ -19,6 +19,7 @@
package org.grails.datastore.gorm
import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
import org.grails.datastore.mapping.core.Datastore
import org.grails.datastore.mapping.core.connections.ConnectionSource
import org.grails.datastore.mapping.core.connections.MultipleConnectionSourceCapableDatastore
@@ -27,6 +28,7 @@ import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
import java.util.concurrent.ConcurrentHashMap
+@Slf4j
@CompileStatic
abstract class AbstractGormApiRegistry<T extends AbstractDatastoreApi> {
@@ -140,24 +142,19 @@ abstract class AbstractGormApiRegistry<T extends AbstractDatastoreApi> {
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) {
+ Map.Entry<String, T> entry = it.next()
+ if (belongsTo(entry.value, datastore, entry.key, null)) {
it.remove()
}
}
Iterator<Map.Entry<String, Map<String, T>>> qit = qualifiedApis.entrySet().iterator()
while (qit.hasNext()) {
- Map<String, T> classQualifiedApis = qit.next().value
+ Map.Entry<String, Map<String, T>> classEntry = qit.next()
+ Map<String, T> classQualifiedApis = classEntry.value
Iterator<Map.Entry<String, T>> eit = classQualifiedApis.entrySet().iterator()
while (eit.hasNext()) {
- try {
- if (eit.next().value.getDatastore() == datastore) {
- eit.remove()
- }
- } catch (Exception e) {
+ Map.Entry<String, T> entry = eit.next()
+ if (belongsTo(entry.value, datastore, classEntry.key, entry.key)) {
eit.remove()
}
}
@@ -167,6 +164,26 @@ abstract class AbstractGormApiRegistry<T extends AbstractDatastoreApi> {
}
}
+ /**
+ * Whether the given API is backed by the datastore being removed.
+ *
+ * An API whose datastore cannot be read is deliberately kept: {@code getDatastore()} routes through a
+ * {@link DatastoreResolver}, and a tenant-aware one throws whenever no tenant is bound on the calling
+ * thread. That is an ordinary transient condition, not evidence the API belongs to this datastore, so
+ * only a positive identity match evicts. Evicting on failure would permanently lose the entity's API
+ * registration and leave subsequent lookups either failing or falling back to a different datastore.
+ */
+ private boolean belongsTo(T api, Datastore datastore, String className, String qualifier) {
+ try {
+ return api.getDatastore() == datastore
+ }
+ catch (Exception e) {
+ log.warn('Could not resolve the datastore of the GORM API registered for [{}]; leaving it registered',
+ qualifier == null ? className : "${className} (qualifier [${qualifier}])", e)
+ return false
+ }
+ }
+
protected String className(Class entity) {
return registry.normalizeEntityKey(entity)
}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolver.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolver.groovy
index bad0e2eda0..c55cfbb2e3 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolver.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolver.groovy
@@ -67,4 +67,40 @@ class ConnectionSourceNameResolver {
}
return ConnectionSource.DEFAULT
}
+
+ /**
+ * Whether the given name is one of the datastore's configured connection sources.
+ *
+ * This answers "is this qualifier a datasource name or a tenant id?" without probing
+ * {@code getDatastoreForConnection}, which throws for an unknown name and would put stack-trace
+ * construction on the per-operation path of every discriminator-mode query.
+ *
+ * @param datastore The datastore to inspect
+ * @param name The candidate connection source name
+ * @return {@code true} if the datastore declares a connection source with that name
+ */
+ static boolean isConnectionSourceName(Object datastore, String name) {
+ if (name == null || !(datastore instanceof ConnectionSourcesProvider)) {
+ return false
+ }
+ ConnectionSources connectionSources = ((ConnectionSourcesProvider) datastore).connectionSources
+ if (connectionSources == null) {
+ return false
+ }
+ // Check the default first: it is the common case, needs no iteration, and a child datastore
+ // built for a single connection resolves its own name through it.
+ if (name == connectionSources.defaultConnectionSource?.name) {
+ return true
+ }
+ Iterable<ConnectionSource> allConnections = connectionSources.allConnectionSources
+ if (allConnections == null) {
+ return false
+ }
+ for (ConnectionSource connectionSource in allConnections) {
+ if (name == connectionSource.name) {
+ return true
+ }
+ }
+ return false
+ }
}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy
index d000ffbfb4..828be20cd7 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy
@@ -105,6 +105,7 @@ class GormEnhancer implements Closeable {
String qualifier = ConnectionSourceNameResolver.resolveDefaultConnectionSourceName(datastore)
registry.initializeDatastore(datastore, qualifier)
+ registerApiFactories()
registerConstraints(datastore)
for (entity in datastore.mappingContext.persistentEntities) {
@@ -112,6 +113,20 @@ class GormEnhancer implements Closeable {
}
}
+ /**
+ * Registers the {@link GormApiFactory} implementations this enhancer's datastore needs, before any
+ * entity is registered.
+ *
+ * Adapters that build specialised API objects (for example Mongo's {@code MongoStaticApi}, which
+ * {@code MongoEntity} casts to) override this. It is deliberately separate from
+ * {@link #registerConstraints(Datastore)}: factory registration has nothing to do with constraints,
+ * and hanging it off the constraints hook would break the moment a subclass reordered or skipped that
+ * step.
+ */
+ protected void registerApiFactories() {
+ // no-op by default: the registry falls back to DefaultGormApiFactory
+ }
+
/**
* Registers a new entity with the GORM enhancer
*
@@ -121,13 +136,32 @@ class GormEnhancer implements Closeable {
if (!entity.isExternal()) {
// Delegate entity registration orchestration to the registry
registry.registerEntity(entity, this)
-
- // Add dynamic methods to the class
- addStaticMethods(entity)
- addInstanceMethods(entity)
+
+ if (requiresMetaClassDispatch(entity)) {
+ addStaticMethods(entity)
+ addInstanceMethods(entity)
+ }
}
}
+ /**
+ * Whether the entity needs GORM's missing-method/missing-property dispatch installed on its
+ * {@link ExpandoMetaClass}.
+ *
+ * A {@link GormEntity} does not: the trait supplies {@code methodMissing}/{@code propertyMissing}
+ * and {@code GormEntityTransformation} compiles in {@code $static_methodMissing} /
+ * {@code $static_propertyMissing}, so dispatch already resolves without touching the metaclass.
+ * Installing closures over the top would shadow those compiled implementations — letting the two
+ * dispatch paths diverge — force an {@code ExpandoMetaClass} for every persistent entity at
+ * startup, and move dynamic-finder resolution onto a per-call metaclass miss.
+ *
+ * Classes that are not {@code GormEntity} — a Java entity, for instance — have no such dispatch of
+ * their own, so the metaclass is the only route available to them.
+ */
+ protected boolean requiresMetaClassDispatch(PersistentEntity entity) {
+ return !GormEntity.isAssignableFrom(entity.javaClass)
+ }
+
/**
* Obtain all of the qualifiers (typically the connection names) for the datastore and entity
*
@@ -223,20 +257,13 @@ class GormEnhancer implements Closeable {
@CompileDynamic
protected void removeConstraints() {
try {
- def cls = Class.forName('org.grails.datastore.gorm.validation.constraints.eval.ConstraintsEvaluator', false, GormEnhancer.classLoader)
- if (cls != null) {
- def factory = datastore.mappingContext.mappingFactory
- if (factory.hasProperty('entityContext')) {
- def constraintsEvaluator = factory.entityContext.getBean(cls)
- if (constraintsEvaluator != null) {
- for (entity in datastore.mappingContext.persistentEntities) {
- constraintsEvaluator.removeConstraints(entity.javaClass)
- }
- }
- }
+ String className = 'org.apache.groovy.grails.validation.ConstrainedProperty'
+ ClassLoader classLoader = getClass().getClassLoader()
+ if (ClassUtils.isPresent(className, classLoader)) {
+ classLoader.loadClass(className).removeConstraint('unique')
}
} catch (Throwable e) {
- log.debug("Not running in Grails environment, cannot de-register constraints. ${e.message}")
+ log.debug("Not running in Grails 2 environment, cannot de-register constraints. This exception can be safely ignored if you are not using Grails 2. ${e.message}", e)
}
}
@@ -257,7 +284,14 @@ class GormEnhancer implements Closeable {
protected void addStaticMethods(PersistentEntity e) {
def cls = e.javaClass
ExpandoMetaClass mc = MetaClassUtils.getExpandoMetaClass(cls)
-
+
+ if (mc.getStaticMetaMethod('methodMissing', [String, Object] as Class[]) != null) {
+ // An application or another plugin owns this class's static dispatch. Overwriting it here
+ // would silently break whatever it handles, so leave it in place.
+ log.debug('Not installing GORM static missing-method dispatch on {}: a handler is already installed', cls.name)
+ return
+ }
+
mc.static.methodMissing = { String name, args ->
def api = registry.findStaticApi(cls, null)
try {
@@ -286,6 +320,12 @@ class GormEnhancer implements Closeable {
Class cls = e.javaClass
ExpandoMetaClass mc = MetaClassUtils.getExpandoMetaClass(cls)
+ if (mc.getMetaMethod('methodMissing', [String, Object] as Class[]) != null) {
+ // See addStaticMethods: defer to a handler someone else installed rather than clobbering it.
+ log.debug('Not installing GORM instance missing-method dispatch on {}: a handler is already installed', cls.name)
+ return
+ }
+
mc.methodMissing = { String name, args ->
def api = registry.findInstanceApi(cls, null)
try {
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy
index b07d7da97e..a9f6792221 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy
@@ -545,7 +545,11 @@ trait GormEntity<D> implements GormValidateable, DirtyCheckable, GormEntityApi<D
}
/**
- * Deletes all objects
+ * Deletes every persisted instance of this class.
+ *
+ * To delete a subset, build a query and delete through it — for example
+ * {@code Book.where { author == 'X' }.deleteAll()}.
+ *
* @return The number of objects deleted
*/
@Generated
@@ -554,8 +558,13 @@ trait GormEntity<D> implements GormValidateable, DirtyCheckable, GormEntityApi<D
}
/**
- * Deletes all objects for the given arguments
- * @param params The arguments
+ * Deletes every persisted instance of this class.
+ *
+ * {@code params} controls how the delete is executed; it does not narrow what is deleted. The
+ * supported argument is {@code flush}. To delete a subset, build a query and delete through it —
+ * for example {@code Book.where { author == 'X' }.deleteAll()}.
+ *
+ * @param params The arguments, e.g. {@code [flush: true]}
* @return The number of objects deleted
*/
@Generated
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy
index 9edef53bc6..00d85d5fa3 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy
@@ -20,7 +20,6 @@ package org.grails.datastore.gorm
import java.util.concurrent.ConcurrentHashMap
-import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
@@ -236,9 +235,10 @@ class GormRegistry {
if (ds != null) {
return ds
}
- if (ConnectionSource.DEFAULT.equals(normalizedQualifier) && !mappedDatastores.isEmpty()) {
- return mappedDatastores.values().iterator().next()
- }
+ // No fallback to "whichever datastore this entity happens to be mapped to". The entity's
+ // unqualified routing is decided once, in declaration order, at registration time and stored
+ // under the DEFAULT key; picking an arbitrary map entry here would make an entity mapped only
+ // to non-default connections read and write a different database between runs.
Datastore qualifierDs = datastoresByQualifier.get(normalizedQualifier)
if (qualifierDs != null && qualifierDs.getMappingContext()?.getPersistentEntity(normalizedClassName) != null) {
return qualifierDs
@@ -774,18 +774,13 @@ class GormRegistry {
}
// 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.
+ // operations to the datastore of its FIRST declared connection. A datastore manages a session
+ // per connection, so an entity mapped only to non-default connections belongs to one of them,
+ // and which one has to be decided here — deterministically, in declaration order — rather than
+ // left to an arbitrary map entry at lookup time.
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 (primaryDatastore != null) {
+ newEntityDatastores.put(ConnectionSource.DEFAULT, primaryDatastore)
}
}
@@ -799,6 +794,10 @@ class GormRegistry {
/**
* Registers an entity-specific datastore override.
+ *
+ * If the entity has no unqualified routing yet, the first connection registered for it also becomes
+ * its unqualified route, so that an entity mapped only to non-default connections resolves to a
+ * datastore chosen at registration time rather than to whichever entry a map iterator yields.
*/
void registerEntityDatastore(String className, String qualifier, Datastore datastore) {
if (datastore != null) {
@@ -807,7 +806,9 @@ class GormRegistry {
return
}
String normalizedQualifier = normalizeQualifier(qualifier)
- getInternalMap(entityDatastores, normalizedClassName).put(normalizedQualifier, datastore)
+ Map<String, Datastore> mappedDatastores = getInternalMap(entityDatastores, normalizedClassName)
+ mappedDatastores.put(normalizedQualifier, datastore)
+ mappedDatastores.putIfAbsent(ConnectionSource.DEFAULT, datastore)
}
}
@@ -844,7 +845,6 @@ class GormRegistry {
* 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
@@ -855,31 +855,32 @@ class GormRegistry {
}
/**
- * 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.
+ * Create a GormStaticApi bound to the given datastore.
+ *
+ * The API is bound to the concrete datastore rather than to a resolving {@link DatastoreResolver}, so
+ * that tenant-resolving lookups are not invoked at registration time, before any tenant context exists.
*/
- GormStaticApi createStaticApi(Class cls, Datastore datastore, DatastoreResolver resolver, String qualifier) {
+ GormStaticApi createStaticApi(Class cls, Datastore datastore, 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.
+ * Create a GormInstanceApi bound to the given datastore.
+ *
+ * @see #createStaticApi(Class, Datastore, String) for why the binding is to the datastore itself
*/
- GormInstanceApi createInstanceApi(Class cls, Datastore datastore, DatastoreResolver resolver, boolean failOnError, boolean markDirty) {
+ GormInstanceApi createInstanceApi(Class cls, Datastore datastore, 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.
+ * Create a GormValidationApi bound to the given datastore.
+ *
+ * @see #createStaticApi(Class, Datastore, String) for why the binding is to the datastore itself
*/
- GormValidationApi createValidationApi(Class cls, Datastore datastore, DatastoreResolver resolver) {
+ GormValidationApi createValidationApi(Class cls, Datastore datastore) {
DatastoreResolver boundResolver = { datastore } as DatastoreResolver
return getApiFactory(datastore).createValidationApi(cls, datastore.mappingContext, boundResolver, this)
}
@@ -893,46 +894,19 @@ class GormRegistry {
}
/**
- * Register constraints for all entities in a datastore.
- * Delegates to the ConstraintsEvaluator if available in the mapping context.
+ * Initialize a datastore with GORM, registering it under its default connection source qualifier
+ * and by its type.
*
- * @param datastore The datastore containing the entities
- */
- @CompileDynamic
- void registerConstraints(Object datastore) {
- if (datastore == null) return
-
- try {
- def context = ((Datastore) datastore).mappingContext
- def factory = context.mappingFactory
- if (factory.hasProperty('entityContext')) {
- def constraintsEvaluator = factory.entityContext.getBean(Class.forName('org.grails.datastore.gorm.validation.constraints.eval.ConstraintsEvaluator', false, GormRegistry.classLoader))
- if (constraintsEvaluator != null) {
- for (entity in context.persistentEntities) {
- constraintsEvaluator.evaluate(entity.javaClass)
- }
- }
- }
- } catch (Throwable e) {
- log.debug('Could not register GORM constraints: {}', e.message)
- }
- }
-
- /**
- * Initialize a datastore with GORM.
- * Orchestrates constraint registration and datastore registration.
- * Note: Entity-specific registration is still handled by GormEnhancer.
+ * Constraint registration is not done here: {@link GormEnhancer#registerConstraints(Datastore)} is
+ * the single path for that, and duplicating it would mean an extra O(entities) pass on every
+ * bootstrap. Entity-specific registration is handled by {@link GormEnhancer}.
*
* @param datastore The datastore to initialize
* @param defaultQualifier The default connection source qualifier
*/
void initializeDatastore(Object datastore, String defaultQualifier) {
if (datastore == null) return
-
- // Register constraints
- registerConstraints(datastore)
-
- // Register datastore with default qualifier
+
Datastore typedDatastore = (Datastore) datastore
registerDatastore(defaultQualifier, typedDatastore)
datastoresByType.put(typedDatastore.getClass(), typedDatastore)
@@ -957,12 +931,11 @@ class GormRegistry {
// Always (re)register API singletons so classloader or datastore changes do not leave stale API instances.
final Class cls = persistentEntity.javaClass
- DatastoreResolver resolver = createClassDatastoreResolver(cls)
Datastore datastore = enhancer.datastore
- GormStaticApi staticApi = createStaticApi(cls, datastore, resolver, ConnectionSource.DEFAULT)
- GormInstanceApi instanceApi = createInstanceApi(cls, datastore, resolver, enhancer.failOnError, enhancer.markDirty)
- GormValidationApi validationApi = createValidationApi(cls, datastore, resolver)
+ GormStaticApi staticApi = createStaticApi(cls, datastore, ConnectionSource.DEFAULT)
+ GormInstanceApi instanceApi = createInstanceApi(cls, datastore, enhancer.failOnError, enhancer.markDirty)
+ GormValidationApi validationApi = createValidationApi(cls, datastore)
registerEntityApis(className, staticApi, instanceApi, validationApi)
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy
index f92ba34baa..83e9b42712 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy
@@ -45,6 +45,7 @@ import org.grails.datastore.mapping.core.connections.ConnectionSources
import org.grails.datastore.mapping.core.connections.ConnectionSourcesProvider
import org.grails.datastore.mapping.model.MappingContext
import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
import org.grails.datastore.mapping.query.api.BuildableCriteria
import org.grails.datastore.mapping.transactions.TransactionCapableDatastore
@@ -98,6 +99,28 @@ class GormStaticApi<D> extends AbstractGormApi<D> implements GormAllOperations<D
return null
}
+ /**
+ * The multi-tenancy mode of the datastore this API currently resolves to.
+ *
+ * Derived on each call rather than captured at construction: an API is no longer bound to a single
+ * datastore for its lifetime, so a captured value could describe a datastore this API is no longer
+ * operating against.
+ *
+ * @return The multi-tenancy mode, or {@link MultiTenancySettings.MultiTenancyMode#NONE} when the
+ * datastore declares none
+ */
+ MultiTenancySettings.MultiTenancyMode getMultiTenancyMode() {
+ Datastore ds = getDatastore()
+ if (ds instanceof MultiTenantCapableDatastore) {
+ return ((MultiTenantCapableDatastore) ds).getMultiTenancyMode() ?: MultiTenancySettings.MultiTenancyMode.NONE
+ }
+ if (ds instanceof ConnectionSourcesProvider) {
+ ConnectionSources connectionSources = ((ConnectionSourcesProvider) ds).getConnectionSources()
+ return connectionSources?.defaultConnectionSource?.settings?.multiTenancy?.mode ?: MultiTenancySettings.MultiTenancyMode.NONE
+ }
+ return MultiTenancySettings.MultiTenancyMode.NONE
+ }
+
@Override
protected <T1> T1 executeQualified(String qualifier, SessionCallback<T1> callback) {
GormStaticApi<D> qualifiedApi = registry.findStaticApi(persistentClass, qualifier)
@@ -387,14 +410,21 @@ class GormStaticApi<D> extends AbstractGormApi<D> implements GormAllOperations<D
@Override
D first(Map params) {
Map queryParams = new LinkedHashMap(params ?: [:])
- queryParams.max = 1
- queryParams.order = 'asc'
if (!queryParams.containsKey('sort')) {
- String idPropertyName = getGormPersistentEntity()?.identity?.name
+ String idPropertyName = singleIdentityPropertyName()
if (idPropertyName) {
queryParams.sort = idPropertyName
}
}
+ if (!queryParams.containsKey('sort')) {
+ // Nothing to order by — an entity with a composite key has no single identity property.
+ // Issuing `order` with no `sort` and a limit of 1 would return an arbitrary row, so read
+ // the result in its natural order and take the first element.
+ List<D> unordered = list(queryParams)
+ return unordered ? unordered[0] : null
+ }
+ queryParams.max = 1
+ queryParams.order = 'asc'
List<D> resultList = list(queryParams)
resultList ? resultList[0] : null
}
@@ -412,18 +442,37 @@ class GormStaticApi<D> extends AbstractGormApi<D> implements GormAllOperations<D
@Override
D last(Map params) {
Map queryParams = new LinkedHashMap(params ?: [:])
- queryParams.max = 1
- queryParams.order = 'desc'
if (!queryParams.containsKey('sort')) {
- String idPropertyName = getGormPersistentEntity()?.identity?.name
+ String idPropertyName = singleIdentityPropertyName()
if (idPropertyName) {
queryParams.sort = idPropertyName
}
}
+ if (!queryParams.containsKey('sort')) {
+ // See first(Map): with no property to order by, read the natural order and take the last
+ // element rather than reversing an unordered query.
+ List<D> unordered = list(queryParams)
+ return unordered ? unordered[-1] : null
+ }
+ queryParams.max = 1
+ queryParams.order = 'desc'
List<D> resultList = list(queryParams)
resultList ? resultList[0] : null
}
+ /**
+ * @return The name of the entity's single identity property, or {@code null} when it has none —
+ * an entity mapped with a composite key has a {@code compositeIdentity} instead, whose
+ * constituent properties are not a meaningful substitute for identity ordering.
+ */
+ private String singleIdentityPropertyName() {
+ PersistentEntity entity = getGormPersistentEntity()
+ if (entity == null || entity.getCompositeIdentity()) {
+ return null
+ }
+ return entity.identity?.name
+ }
+
@Override
BuildableCriteria createCriteria() {
execute({ Session session ->
@@ -477,14 +526,18 @@ class GormStaticApi<D> extends AbstractGormApi<D> implements GormAllOperations<D
@Override
Number deleteAll() {
- execute({ Session session ->
- session.deleteAll(new DetachedCriteria(persistentClass))
- } as SessionCallback<Number>)
+ deleteAll(Collections.emptyMap())
}
@Override
Number deleteAll(Map params) {
- deleteAll()
+ execute({ Session session ->
+ Number deleted = session.deleteAll(new DetachedCriteria(persistentClass))
+ if (params?.flush) {
+ session.flush()
+ }
+ return deleted
+ } as SessionCallback<Number>)
}
@Override
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/multitenancy/TenantDelegatingGormOperations.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/multitenancy/TenantDelegatingGormOperations.groovy
index e5bfd22142..3c64be9695 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/multitenancy/TenantDelegatingGormOperations.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/multitenancy/TenantDelegatingGormOperations.groovy
@@ -29,6 +29,7 @@ import grails.gorm.multitenancy.Tenants
import org.grails.datastore.gorm.finders.FinderMethod
import org.grails.datastore.mapping.core.Datastore
import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
import org.grails.datastore.mapping.query.api.BuildableCriteria
import org.grails.datastore.mapping.query.api.Criteria
@@ -53,119 +54,119 @@ class TenantDelegatingGormOperations<D> implements GormAllOperations<D> {
@Override
def propertyMissing(D instance, String name) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.propertyMissing(instance, name)
}
}
@Override
boolean instanceOf(D instance, Class cls) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.instanceOf(instance, cls)
}
}
@Override
D lock(D instance) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.lock(instance)
}
}
@Override
def <T> T mutex(D instance, Closure<T> callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.mutex(instance, callable)
}
}
@Override
D refresh(D instance) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.refresh(instance)
}
}
@Override
D save(D instance) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.save(instance)
}
}
@Override
D insert(D instance) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.insert(instance)
}
}
@Override
D insert(D instance, Map params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.insert(instance, params)
}
}
@Override
D merge(D instance, Map params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.merge(instance, params)
}
}
@Override
D save(D instance, boolean validate) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.save(instance, validate)
}
}
@Override
D save(D instance, Map params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.save(instance, params)
}
}
@Override
Serializable ident(D instance) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.ident(instance)
}
}
@Override
D attach(D instance) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.attach(instance)
}
}
@Override
boolean isAttached(D instance) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.isAttached(instance)
}
}
@Override
void discard(D instance) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.discard(instance)
}
}
@Override
void delete(D instance) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.delete(instance)
}
}
@Override
void delete(D instance, Map params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.delete(instance, params)
}
}
@@ -182,98 +183,98 @@ class TenantDelegatingGormOperations<D> implements GormAllOperations<D> {
@Override
DetachedCriteria<D> where(Closure callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.where(callable)
}
}
@Override
DetachedCriteria<D> whereLazy(Closure callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.whereLazy(callable)
}
}
@Override
DetachedCriteria<D> whereAny(Closure callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.whereAny(callable)
}
}
@Override
List<D> findAll(Closure callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAll(callable)
}
}
@Override
List<D> findAll(Map args, Closure callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAll(args, callable)
}
}
@Override
D find(Closure callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.find(callable)
}
}
@Override
List<Serializable> saveAll(Object... objectsToSave) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.saveAll(objectsToSave)
}
}
@Override
List<Serializable> saveAll(Iterable<?> objectsToSave) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.saveAll(objectsToSave)
}
}
@Override
Number deleteAll() {
- (Number)Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ (Number)withTenantId() {
allOperations.deleteAll()
}
}
@Override
Number deleteAll(Map params) {
- (Number)Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ (Number)withTenantId() {
allOperations.deleteAll(params)
}
}
@Override
void deleteAll(Object... objectsToDelete) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.deleteAll(objectsToDelete)
}
}
@Override
void deleteAll(Map params, Object... objectsToDelete) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.deleteAll(params, objectsToDelete)
}
}
@Override
void deleteAll(Iterable objectsToDelete) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.deleteAll(objectsToDelete)
}
}
@Override
void deleteAll(Map params, Iterable objectsToDelete) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.deleteAll(params, objectsToDelete)
}
}
@@ -285,497 +286,497 @@ class TenantDelegatingGormOperations<D> implements GormAllOperations<D> {
@Override
D get(Serializable id) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.get(id)
}
}
@Override
D read(Serializable id) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.read(id)
}
}
@Override
D load(Serializable id) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.load(id)
}
}
@Override
D proxy(Serializable id) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.proxy(id)
}
}
@Override
List<D> getAll(Iterable<Serializable> ids) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.getAll(ids)
}
}
@Override
List<D> getAll(Serializable... ids) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.getAll(ids)
}
}
@Override
List<D> getAll() {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.getAll()
}
}
@Override
BuildableCriteria createCriteria() {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.createCriteria()
}
}
@Override
def <T> T withCriteria(@DelegatesTo(Criteria) Closure<T> callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.withCriteria(callable)
}
}
@Override
def <T> T withCriteria(Map builderArgs, @DelegatesTo(Criteria) Closure callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.withCriteria(builderArgs, callable)
}
}
@Override
D lock(Serializable id) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.lock(id)
}
}
@Override
D merge(D d) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.merge(d)
}
}
@Override
Integer count() {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.count()
}
}
@Override
Integer getCount() {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.getCount()
}
}
@Override
boolean exists(Serializable id) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.exists(id)
}
}
@Override
List<D> list(Map params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.list(params)
}
}
@Override
List<D> list() {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.list()
}
}
@Override
List<D> findAll(Map params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAll(params)
}
}
@Override
List<D> findAll() {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAll()
}
}
@Override
List<D> findAll(D example) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAll(example)
}
}
@Override
List<D> findAll(D example, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAll(example, args)
}
}
@Override
D first() {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.first()
}
}
@Override
D first(String propertyName) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.first(propertyName)
}
}
@Override
D first(Map queryParams) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.first(queryParams)
}
}
@Override
D last() {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.last()
}
}
@Override
D last(String propertyName) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.last(propertyName)
}
}
@Override
Object methodMissing(String methodName, Object arg) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.methodMissing(methodName, arg)
}
}
@Override
Object propertyMissing(String property) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.propertyMissing(property)
}
}
@Override
void propertyMissing(String property, Object value) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.propertyMissing(property, value)
}
}
@Override
D last(Map queryParams) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.last(queryParams)
}
}
@Override
List<D> findAllWhere(Map queryMap) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAllWhere(queryMap)
}
}
@Override
List<D> findAllWhere(Map queryMap, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAllWhere(queryMap, args)
}
}
@Override
D find(D example) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.find(example)
}
}
@Override
D find(D example, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.find(example, args)
}
}
@Override
D findWhere(Map queryMap) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findWhere(queryMap)
}
}
@Override
D findWhere(Map queryMap, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findWhere(queryMap, args)
}
}
@Override
D findOrCreateWhere(Map queryMap) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findOrCreateWhere(queryMap)
}
}
@Override
D findOrSaveWhere(Map queryMap) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findOrSaveWhere(queryMap)
}
}
@Override
def <T> T withSession(Closure<T> callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.withSession(callable)
}
}
@Override
def <T> T withDatastoreSession(Closure<T> callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.withDatastoreSession(callable)
}
}
@Override
def <T> T withTransaction(Closure<T> callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.withTransaction(callable)
}
}
@Override
def <T> T withNewTransaction(Closure<T> callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.withNewTransaction(callable)
}
}
@Override
def <T> T withTransaction(Map transactionProperties, Closure<T> callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.withTransaction(transactionProperties, callable)
}
}
@Override
def <T> T withNewTransaction(Map transactionProperties, Closure<T> callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.withNewTransaction(transactionProperties, callable)
}
}
@Override
def <T> T withTransaction(TransactionDefinition definition, Closure<T> callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.withTransaction(definition, callable)
}
}
@Override
def <T> T withNewSession(Closure<T> callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.withNewSession(callable)
}
}
@Override
def <T> T withStatelessSession(Closure<T> callable) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.withStatelessSession(callable)
}
}
@Override
List executeQuery(CharSequence query) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.executeQuery(query)
}
}
@Override
List executeQuery(CharSequence query, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.executeQuery(query, args)
}
}
@Override
List executeQuery(CharSequence query, Map params, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.executeQuery(query, params, args)
}
}
@Override
List executeQuery(CharSequence query, Collection params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.executeQuery(query, params)
}
}
@Override
List executeQuery(CharSequence query, Object... params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.executeQuery(query, params)
}
}
@Override
List executeQuery(CharSequence query, Collection params, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.executeQuery(query, params, args)
}
}
@Override
Integer executeUpdate(CharSequence query) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.executeUpdate(query)
}
}
@Override
Integer executeUpdate(CharSequence query, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.executeUpdate(query, args)
}
}
@Override
Integer executeUpdate(CharSequence query, Map params, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.executeUpdate(query, params, args)
}
}
@Override
Integer executeUpdate(CharSequence query, Collection params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.executeUpdate(query, params)
}
}
@Override
Integer executeUpdate(CharSequence query, Object... params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.executeUpdate(query, params)
}
}
@Override
Integer executeUpdate(CharSequence query, Collection params, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.executeUpdate(query, params, args)
}
}
@Override
D find(CharSequence query) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.find(query)
}
}
@Override
D find(CharSequence query, Map params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.find(query, params)
}
}
@Override
D find(CharSequence query, Map params, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.find(query, params, args)
}
}
@Override
D find(CharSequence query, Collection params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.find(query, params)
}
}
@Override
D find(CharSequence query, Object[] params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.find(query, params)
}
}
@Override
D find(CharSequence query, Collection params, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.find(query, params, args)
}
}
@Override
List<D> findAll(CharSequence query) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAll(query)
}
}
@Override
List<D> findAll(CharSequence query, Map params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAll(query, params)
}
}
@Override
List<D> findAll(CharSequence query, Map params, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAll(query, params, args)
}
}
@Override
List<D> findAll(CharSequence query, Collection params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAll(query, params)
}
}
@Override
List<D> findAll(CharSequence query, Object[] params) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAll(query, params)
}
}
@Override
List<D> findAll(CharSequence query, Collection params, Map args) {
- Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) {
+ withTenantId() {
allOperations.findAll(query, params, args)
}
}
@@ -794,4 +795,18 @@ class TenantDelegatingGormOperations<D> implements GormAllOperations<D> {
GormAllOperations<D> withTenant(Serializable tenantId) {
allOperations.withTenant(tenantId)
}
+
+ /**
+ * Runs the delegated call with this wrapper's tenant bound.
+ *
+ * Uses the datastore instance this wrapper was constructed with rather than looking one up by
+ * its class: the instance is already known, and a type lookup would fail for a datastore that is
+ * not registered by type, or resolve to a sibling when several share one type.
+ */
+ private <T> T withTenantId(Closure<T> callable) {
+ if (datastore instanceof MultiTenantCapableDatastore) {
+ return Tenants.withId((MultiTenantCapableDatastore) datastore, tenantId, callable)
+ }
+ return Tenants.withId((Class<? extends Datastore>) datastore.getClass(), tenantId, callable)
+ }
}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy
index ba6c3806dc..15ea4f7ce7 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/transform/ServiceTransformation.groovy
@@ -243,7 +243,7 @@ class ServiceTransformation extends AbstractTraitApplyingGormASTTransformation i
addDatastoreMethods(impl, datastoreType, targetDomainClass, propertiesFields)
- copyAnnotations(classNode, impl)
+ copyAnnotations(classNode, impl, null, null, true)
AnnotationNode serviceAnnotation = findAnnotation((AnnotatedNode)impl, Service)
if (serviceAnnotation != null && serviceAnnotation.getMember('name') == null) {
@@ -326,7 +326,9 @@ class ServiceTransformation extends AbstractTraitApplyingGormASTTransformation i
// Copy annotations after implement() so that @Query GString values are
// already replaced with constX(IMPLEMENTED) before being copied to methodImpl.
- copyAnnotations(method, methodImpl)
+ // Skip types methodImpl already carries: @NotTransactional above and anything
+ // implement() added would otherwise be duplicated, which the compiler rejects.
+ copyAnnotations(method, methodImpl, null, null, true)
if (!Modifier.isProtected(method.modifiers)) {
if (!TransactionalTransform.hasTransactionalAnnotation(methodImpl)) {
diff --git a/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/CurrentTenantHolderSpec.groovy b/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/CurrentTenantHolderSpec.groovy
index 73a5222341..b994a5bc7e 100644
--- a/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/CurrentTenantHolderSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/CurrentTenantHolderSpec.groovy
@@ -20,6 +20,7 @@ package grails.gorm.multitenancy
import org.grails.datastore.mapping.core.Datastore
import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.multitenancy.exceptions.TenantException
import spock.lang.Specification
/**
@@ -49,6 +50,57 @@ class CurrentTenantHolderSpec extends Specification {
}
}
+ void "get() returns the tenant when several datastores are bound to the same one"() {
+ given: "the shape Tenants.withTenant produces — the same tenant keyed by both instance and type"
+ def datastore = Mock(Datastore)
+ CurrentTenantHolder.set(datastore.class, 'tenant1')
+ CurrentTenantHolder.set(datastore, 'tenant1')
+
+ expect: "the answer is unambiguous, so it is returned"
+ CurrentTenantHolder.get() == 'tenant1'
+
+ cleanup:
+ CurrentTenantHolder.remove(datastore)
+ CurrentTenantHolder.remove(datastore.class)
+ }
+
+ void "get() throws rather than guessing when different datastores hold different tenants"() {
+ given:
+ def datastoreA = Mock(Datastore)
+ def datastoreB = Mock(Datastore)
+ CurrentTenantHolder.set(datastoreA, 'tenantA')
+ CurrentTenantHolder.set(datastoreB, 'tenantB')
+
+ when:
+ CurrentTenantHolder.get()
+
+ then: "returning an arbitrary one of the two is how cross-tenant data leaks happen"
+ TenantException e = thrown()
+ e.message.contains('tenantA')
+ e.message.contains('tenantB')
+ e.message.contains('get(Datastore)')
+
+ cleanup:
+ CurrentTenantHolder.remove(datastoreA)
+ CurrentTenantHolder.remove(datastoreB)
+ }
+
+ void "get(Datastore) still answers unambiguously when different datastores hold different tenants"() {
+ given:
+ def datastoreA = Mock(Datastore)
+ def datastoreB = Mock(Datastore)
+ CurrentTenantHolder.set(datastoreA, 'tenantA')
+ CurrentTenantHolder.set(datastoreB, 'tenantB')
+
+ expect:
+ CurrentTenantHolder.get(datastoreA) == 'tenantA'
+ CurrentTenantHolder.get(datastoreB) == 'tenantB'
+
+ cleanup:
+ CurrentTenantHolder.remove(datastoreA)
+ CurrentTenantHolder.remove(datastoreB)
+ }
+
void "get(Datastore) returns null when nothing is bound for that datastore"() {
given:
def datastore = Mock(Datastore)
diff --git a/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/TenantsSpec.groovy b/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/TenantsSpec.groovy
index 10197c44fd..3b76565bf0 100644
--- a/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/TenantsSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/grails/gorm/multitenancy/TenantsSpec.groovy
@@ -22,6 +22,7 @@ import org.grails.datastore.mapping.core.Datastore
import org.grails.datastore.mapping.core.Session
import org.grails.datastore.mapping.core.connections.ConnectionSource
import org.grails.datastore.mapping.core.connections.ConnectionSources
+import org.grails.datastore.mapping.core.exceptions.ConfigurationException
import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
@@ -284,7 +285,30 @@ class TenantsSpec extends Specification {
thrown(UnsupportedOperationException)
}
- void "withId(domainClass, tenantId, callable) resolves the datastore for that domain class before delegating"() {
+ void "withId(datastoreClass, tenantId, callable) resolves the datastore by type before delegating"() {
+ given:
+ def mtds = Stub(MixedDatastore) {
+ getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DATABASE
+ withNewSession(_, _) >> { Serializable tid, Closure c -> c.call(Stub(Session)) }
+ }
+ useLocator(Stub(Datastore), mtds, Stub(Datastore))
+
+ expect: "the datastore type, not the domain class, decides which datastore is used"
+ Tenants.withId(MixedDatastore, 'tenant1') { -> 'ran' } == 'ran'
+ }
+
+ void "withId(datastoreClass, tenantId, callable) throws for a datastore that does not support multi-tenancy"() {
+ given:
+ useLocator(Stub(Datastore), Stub(Datastore))
+
+ when:
+ Tenants.withId(MixedDatastore, 'tenant1') { }
+
+ then:
+ thrown(UnsupportedOperationException)
+ }
+
+ void "withIdForDomain(domainClass, tenantId, callable) resolves the datastore for that domain class before delegating"() {
given:
def mtds = Stub(MixedDatastore) {
getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DATABASE
@@ -293,27 +317,41 @@ class TenantsSpec extends Specification {
useLocator(Stub(Datastore), null, mtds)
expect:
- Tenants.withId(String, 'tenant1') { -> 'ran' } == 'ran'
+ Tenants.withIdForDomain(String, 'tenant1') { -> 'ran' } == 'ran'
}
- void "withId(domainClass, tenantId, callable) throws for a datastore that does not support multi-tenancy"() {
+ void "withIdForDomain(domainClass, tenantId, callable) throws for a datastore that does not support multi-tenancy"() {
given:
useLocator(Stub(Datastore), null, Stub(Datastore))
when:
- Tenants.withId(String, 'tenant1') { }
+ Tenants.withIdForDomain(String, 'tenant1') { }
then:
thrown(UnsupportedOperationException)
}
- void "withTenant(domainClass, tenantId, callable) binds the tenant for the datastore resolved for that domain class"() {
+ void "withTenantForDomain(domainClass, tenantId, callable) binds the tenant for the datastore resolved for that domain class"() {
given:
def ds = Stub(Datastore)
useLocator(Stub(Datastore), null, ds)
expect:
- Tenants.withTenant(String, 'tenant1') { CurrentTenantHolder.get(ds) } == 'tenant1'
+ Tenants.withTenantForDomain(String, 'tenant1') { CurrentTenantHolder.get(ds) } == 'tenant1'
+ }
+
+ void "withTenantForDomain(domainClass, tenantId, callable) binds the tenant without opening a session"() {
+ given: "a datastore that fails the spec if any session is opened"
+ def ds = Mock(Datastore)
+ useLocator(Stub(Datastore), null, ds)
+
+ when:
+ def result = Tenants.withTenantForDomain(String, 'tenant1') { CurrentTenantHolder.get(ds) }
+
+ then: "as the javadoc states, the caller owns the session — none is created here"
+ result == 'tenant1'
+ 0 * ds.connect()
+ 0 * ds.connect(_)
}
void "withoutId(MultiTenantCapableDatastore, callable) runs a 0-arg closure directly for a shared-connection mode"() {
@@ -402,19 +440,34 @@ class TenantsSpec extends Specification {
thrown(IllegalArgumentException)
}
- void "withId(MultiTenantCapableDatastore, tenantId, callable) swallows getDatastoreForTenantId failures and falls through"() {
- given:
+ void "withId(MultiTenantCapableDatastore, tenantId, callable) falls through when the tenant has no datastore yet"() {
+ given: "a misconfiguration the probe cannot resolve — an unknown tenant or a missing connection"
def session = Stub(Session)
def mtds = Stub(MixedDatastore) {
getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DATABASE
- getDatastoreForTenantId('tenant1') >> { throw new IllegalStateException('boom') }
+ getDatastoreForTenantId('tenant1') >> { throw new ConfigurationException('DataSource not found') }
withNewSession('tenant1', _) >> { String tid, Closure body -> body.call(session) }
}
- expect:
+ expect: "the session-creating path is still taken, which resolves the tenant again and reports any real failure"
Tenants.withId(mtds, 'tenant1') { -> 'ran' } == 'ran'
}
+ void "withId(MultiTenantCapableDatastore, tenantId, callable) propagates unexpected failures from getDatastoreForTenantId"() {
+ given:
+ def mtds = Stub(MixedDatastore) {
+ getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DATABASE
+ getDatastoreForTenantId('tenant1') >> { throw new IllegalStateException('boom') }
+ }
+
+ when:
+ Tenants.withId(mtds, 'tenant1') { -> 'ran' }
+
+ then: "only unknown-tenant/missing-connection failures are tolerated; anything else surfaces to the caller"
+ IllegalStateException e = thrown()
+ e.message == 'boom'
+ }
+
void "withId(MultiTenantCapableDatastore, tenantId, callable) uses the shared-connection withSession path, dispatching by closure arity"() {
given:
def session = Stub(Session)
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiRegistrySpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiRegistrySpec.groovy
index bdccc59d48..08c79159ea 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiRegistrySpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiRegistrySpec.groovy
@@ -113,6 +113,59 @@ class AbstractGormApiRegistrySpec extends Specification {
secondaryApi == api
}
+ void "removeDatastore evicts only the APIs backed by the datastore being removed"() {
+ given:
+ def registry = GormRegistry.instance
+ def testRegistry = new TestGormApiRegistry(registry)
+ def removed = Stub(Datastore)
+ def retained = Stub(Datastore)
+ testRegistry.register(TestEntity.name, new DummyApi(removed))
+ testRegistry.register(OtherTestEntity.name, new DummyApi(retained))
+
+ when:
+ testRegistry.removeDatastore(removed)
+
+ then:
+ !testRegistry.containsKey(TestEntity.name)
+ testRegistry.containsKey(OtherTestEntity.name)
+ }
+
+ void "removeDatastore keeps an API whose datastore cannot be resolved"() {
+ given: "an API backed by a resolver that throws, as a tenant-aware resolver does with no tenant bound"
+ def testRegistry = new TestGormApiRegistry(GormRegistry.instance)
+ def throwingApi = new DummyApi({ throw new IllegalStateException('No tenant bound') } as DatastoreResolver)
+ testRegistry.register(TestEntity.name, throwingApi)
+
+ when:
+ testRegistry.removeDatastore(Stub(Datastore))
+
+ then: "the registration survives — a transient resolution failure is not a positive identity match"
+ testRegistry.get(TestEntity.name).is(throwingApi)
+ }
+
+ void "removeDatastore keeps a qualified API whose datastore cannot be resolved"() {
+ given:
+ def registry = GormRegistry.instance
+ def testRegistry = new TestGormApiRegistry(registry)
+ def defaultDatastore = Stub(Datastore)
+ def secondaryDatastore = Stub(Datastore)
+ testRegistry.register(TestEntity.name, new DummyApi(defaultDatastore))
+ registry.registerDatastore(ConnectionSource.DEFAULT, defaultDatastore)
+ registry.registerDatastore('secondary', secondaryDatastore)
+ registry.registerEntityDatastore(TestEntity.name, ConnectionSource.DEFAULT, defaultDatastore)
+ registry.registerEntityDatastore(TestEntity.name, 'secondary', secondaryDatastore)
+ testRegistry.qualifyWith = new DummyApi({ throw new IllegalStateException('No tenant bound') } as DatastoreResolver)
+
+ and: "the qualified API has been materialized"
+ testRegistry.get(TestEntity.name, 'secondary')
+
+ when:
+ testRegistry.removeDatastore(Stub(Datastore))
+
+ then:
+ testRegistry.isAllocated(TestEntity.name, 'secondary')
+ }
+
void "test clear"() {
given:
def testRegistry = new TestGormApiRegistry(GormRegistry.instance)
@@ -147,15 +200,23 @@ class AbstractGormApiRegistrySpec extends Specification {
static class TestEntity {
}
-
+
+ static class OtherTestEntity {
+ }
+
static class DummyApi extends AbstractDatastoreApi {
DummyApi(Datastore ds) {
super(ds)
}
+
+ DummyApi(DatastoreResolver resolver) {
+ super(resolver)
+ }
}
static class TestGormApiRegistry extends AbstractGormApiRegistry<AbstractDatastoreApi> {
AbstractDatastoreApi qualifiedApi
+ AbstractDatastoreApi qualifyWith
TestGormApiRegistry(GormRegistry registry) {
super(registry)
@@ -163,7 +224,7 @@ class AbstractGormApiRegistrySpec extends Specification {
@Override
protected AbstractDatastoreApi qualify(AbstractDatastoreApi api, String qualifier) {
- qualifiedApi = new DummyApi(api.datastore)
+ qualifiedApi = qualifyWith ?: new DummyApi(api.datastore)
return qualifiedApi
}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiSpec.groovy
index 42836fb026..4dcbfea01f 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/AbstractGormApiSpec.groovy
@@ -116,8 +116,17 @@ class AbstractGormApiSpec extends Specification {
extendedMethods.any { it.name == 'customExtraMethod' }
!extendedMethods.any { it.name == 'get' }
- and: "excluded Object/GroovyObject-level methods never appear in either list"
- !methods.any { it.name in AbstractGormApi.EXCLUDES }
+ and: "the api object's own plumbing never appears in either list, whatever EXCLUDES happens to contain"
+ !methods.any {
+ it.name in ['getClass', 'getMetaClass', 'setMetaClass', 'getProperty', 'setProperty', 'invokeMethod',
+ 'wait', 'notify', 'notifyAll', 'equals', 'hashCode', 'toString',
+ 'getMethods', 'getExtendedMethods', 'getTransactionManager', 'setTransactionManager',
+ 'getQualifier', 'getGormPersistentEntity']
+ }
+ !extendedMethods.any {
+ it.name in ['getMethods', 'getExtendedMethods', 'getTransactionManager', 'setTransactionManager',
+ 'getQualifier', 'getGormPersistentEntity']
+ }
when: "requesting the methods again"
def methodsAgain = api.getMethods()
@@ -126,6 +135,20 @@ class AbstractGormApiSpec extends Specification {
methodsAgain.is(methods)
}
+ void "EXCLUDES stays publicly readable and immutable"() {
+ when: "read from outside the AbstractGormApi hierarchy, as third-party GORM implementations do"
+ List<String> excludes = AbstractGormApi.EXCLUDES
+
+ then:
+ excludes.containsAll(['getMethods', 'getExtendedMethods', 'setTransactionManager', 'getTransactionManager'])
+
+ when:
+ excludes << 'somethingElse'
+
+ then:
+ thrown(UnsupportedOperationException)
+ }
+
void "ConstantDatastoreResolver always resolves to the datastore it was constructed with"() {
given:
def ds = Stub(Datastore)
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ActiveSessionDatastoreSelectorSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ActiveSessionDatastoreSelectorSpec.groovy
index 5a5c788301..c2dd1161b2 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ActiveSessionDatastoreSelectorSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ActiveSessionDatastoreSelectorSpec.groovy
@@ -18,8 +18,14 @@
*/
package org.grails.datastore.gorm
+import grails.gorm.annotation.Entity
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.connections.ConnectionSource
+import org.grails.datastore.mapping.simple.SimpleMapDatastore
+import org.grails.datastore.mapping.transactions.SessionHolder
+import org.springframework.transaction.support.TransactionSynchronizationManager
import spock.lang.Specification
class ActiveSessionDatastoreSelectorSpec extends Specification {
@@ -68,6 +74,33 @@ class ActiveSessionDatastoreSelectorSpec extends Specification {
selector.select(registry, TestEntity.name) == null
}
- private static class TestEntity {
+ void 'the discovery scan does not disturb the thread-bound sessions of the datastores it probes'() {
+ given: "two real datastores, only one of which has a session bound on this thread"
+ def selector = new ActiveSessionDatastoreSelector()
+ GormRegistry registry = GormRegistry.instance
+ def probed = new SimpleMapDatastore(TestEntity)
+ def owner = new SimpleMapDatastore(TestEntity)
+ registry.registerDatastore(ConnectionSource.DEFAULT, owner)
+ registry.registerDatastore('probed', probed)
+ Session probedSession = probed.connect()
+ DatastoreUtils.bindSession(probedSession)
+ SessionHolder probedHolder = (SessionHolder) TransactionSynchronizationManager.getResource(probed)
+
+ when: "routing asks every registered datastore whether it holds a session"
+ selector.select(registry, TestEntity.name)
+
+ then: "the probed datastore's binding survives the probe — a routing decision for one entity must not unbind another datastore's holder"
+ TransactionSynchronizationManager.getResource(probed).is(probedHolder)
+ probed.hasCurrentSession()
+
+ cleanup:
+ DatastoreUtils.unbindSession(probedSession)
+ probed.close()
+ owner.close()
+ }
+
+ @Entity
+ static class TestEntity {
+ String name
}
}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolverSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolverSpec.groovy
index bb3a5ffc54..9611194ea9 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolverSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/ConnectionSourceNameResolverSpec.groovy
@@ -51,4 +51,55 @@ class ConnectionSourceNameResolverSpec extends Specification {
ConnectionSourceNameResolver.resolveConnectionSourceNames(datastore) == [ConnectionSource.DEFAULT]
ConnectionSourceNameResolver.resolveDefaultConnectionSourceName(datastore) == ConnectionSource.DEFAULT
}
+
+ void "isConnectionSourceName recognises a declared connection source"() {
+ expect:
+ ConnectionSourceNameResolver.isConnectionSourceName(datastore, ConnectionSource.DEFAULT)
+ }
+
+ void "isConnectionSourceName answers false for a tenant id without throwing"() {
+ expect: "an unknown name is a plain false — no ConfigurationException, so no stack trace is built per operation"
+ !ConnectionSourceNameResolver.isConnectionSourceName(datastore, 'someTenantId')
+ }
+
+ void "isConnectionSourceName answers false for a null name or a datastore with no connection sources"() {
+ given:
+ def noConnectionSources = Stub(ConnectionSourcesProvider) {
+ getConnectionSources() >> null
+ }
+
+ expect:
+ !ConnectionSourceNameResolver.isConnectionSourceName(datastore, null)
+ !ConnectionSourceNameResolver.isConnectionSourceName(new Object(), 'anything')
+ !ConnectionSourceNameResolver.isConnectionSourceName(noConnectionSources, 'anything')
+ }
+
+ void "isConnectionSourceName recognises a datastore's own default connection"() {
+ given: "a child datastore built for a single connection, which enumerates only that connection"
+ def leaf = Stub(ConnectionSourcesProvider) {
+ getConnectionSources() >> Stub(ConnectionSources) {
+ getDefaultConnectionSource() >> Stub(ConnectionSource) { getName() >> 'secondary' }
+ }
+ }
+
+ expect: "resolving its own connection name is recognised, so it is not mistaken for a tenant id"
+ ConnectionSourceNameResolver.isConnectionSourceName(leaf, 'secondary')
+ !ConnectionSourceNameResolver.isConnectionSourceName(leaf, 'tenant1')
+ }
+
+ void "isConnectionSourceName recognises every declared connection, not just the default"() {
+ given:
+ def provider = Stub(ConnectionSourcesProvider) {
+ getConnectionSources() >> Stub(ConnectionSources) {
+ getAllConnectionSources() >> [
+ Stub(ConnectionSource) { getName() >> ConnectionSource.DEFAULT },
+ Stub(ConnectionSource) { getName() >> 'analytics' }
+ ]
+ }
+ }
+
+ expect:
+ ConnectionSourceNameResolver.isConnectionSourceName(provider, 'analytics')
+ !ConnectionSourceNameResolver.isConnectionSourceName(provider, 'reporting')
+ }
}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormEnhancerCoverageSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormEnhancerCoverageSpec.groovy
index 49360fba5d..fe199bf94a 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormEnhancerCoverageSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormEnhancerCoverageSpec.groovy
@@ -173,6 +173,54 @@ class GormEnhancerCoverageSpec extends Specification {
then:
thrown(MissingMethodException)
}
+
+ void "registerApiFactories runs before any entity is registered, so adapter APIs are used from the first entity on"() {
+ given: "an enhancer that registers a factory through the dedicated hook, as the Hibernate and Mongo adapters do"
+ def registry = GormRegistry.instance
+ def order = []
+
+ when:
+ new GormEnhancer(datastore, datastore.transactionManager, new ConnectionSourceSettings()) {
+ @Override
+ protected void registerApiFactories() {
+ order << 'factories'
+ registry.registerApiFactory(SimpleMapDatastore, new DefaultGormApiFactory())
+ }
+
+ @Override
+ void registerEntity(org.grails.datastore.mapping.model.PersistentEntity entity) {
+ order << "entity:${entity.name}".toString()
+ super.registerEntity(entity)
+ }
+ }
+
+ then: "factory registration happens first — an entity registered before it would get the wrong API type"
+ order.first() == 'factories'
+ order.size() > 1
+ }
+
+ void "registerApiFactories is independent of registerConstraints"() {
+ given: "a subclass that suppresses constraint registration entirely"
+ def registry = GormRegistry.instance
+ boolean factoriesRegistered = false
+
+ when:
+ new GormEnhancer(datastore, datastore.transactionManager, new ConnectionSourceSettings()) {
+ @Override
+ protected void registerApiFactories() {
+ factoriesRegistered = true
+ registry.registerApiFactory(SimpleMapDatastore, new DefaultGormApiFactory())
+ }
+
+ @Override
+ protected void registerConstraints(Datastore ds) {
+ // deliberately skipped
+ }
+ }
+
+ then: "factory registration still happens — it is not coupled to the constraints lifecycle step"
+ factoriesRegistered
+ }
}
@Entity
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryCoverageSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryCoverageSpec.groovy
index cda16f65bd..10289b26fd 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryCoverageSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormRegistryCoverageSpec.groovy
@@ -307,7 +307,6 @@ class GormRegistryCoverageSpec extends Specification {
def secondaryDs = Stub(Datastore)
def multiConnectionDs = Stub(MultipleConnectionSourceDatastore) {
getDatastoreForConnection('secondary') >> secondaryDs
- routesUnqualifiedToMappedConnection() >> true
}
when:
@@ -317,6 +316,59 @@ class GormRegistryCoverageSpec extends Specification {
registry.getDatastoreByString(GormRegistryCoverageThing.name, 'secondary') == secondaryDs
}
+ void "registerEntityDatastores routes unqualified operations to the first declared connection, deterministically"() {
+ given: "an entity mapped only to non-default connections"
+ def registry = new GormRegistry()
+ def analyticsDs = Stub(Datastore)
+ def reportingDs = Stub(Datastore)
+ def multiConnectionDs = Stub(MultipleConnectionSourceDatastore) {
+ getDatastoreForConnection('analytics') >> analyticsDs
+ getDatastoreForConnection('reporting') >> reportingDs
+ }
+
+ when:
+ registry.registerEntityDatastores(GormRegistryCoverageThing.name, multiConnectionDs, ['analytics', 'reporting'], null)
+
+ then: "the unqualified route is the FIRST declared connection, not whichever entry an iterator yields"
+ registry.getDatastoreByString(GormRegistryCoverageThing.name, ConnectionSource.DEFAULT) == analyticsDs
+
+ when: "the same entity is registered with the declaration order reversed"
+ registry.registerEntityDatastores(GormRegistryCoverageThing.name, multiConnectionDs, ['reporting', 'analytics'], null)
+
+ then: "the unqualified route follows declaration order rather than staying arbitrary"
+ registry.getDatastoreByString(GormRegistryCoverageThing.name, ConnectionSource.DEFAULT) == reportingDs
+ }
+
+ void "registerEntityDatastore establishes the unqualified route from the first connection registered"() {
+ given:
+ def registry = new GormRegistry()
+ def analyticsDs = Stub(Datastore)
+ def reportingDs = Stub(Datastore)
+
+ when:
+ registry.registerEntityDatastore(GormRegistryCoverageThing.name, 'analytics', analyticsDs)
+ registry.registerEntityDatastore(GormRegistryCoverageThing.name, 'reporting', reportingDs)
+
+ then: "both qualified routes resolve, and the unqualified one is pinned to the first registration"
+ registry.getDatastoreByString(GormRegistryCoverageThing.name, 'analytics') == analyticsDs
+ registry.getDatastoreByString(GormRegistryCoverageThing.name, 'reporting') == reportingDs
+ registry.getDatastoreByString(GormRegistryCoverageThing.name, ConnectionSource.DEFAULT) == analyticsDs
+ }
+
+ void "registerEntityDatastore lets an explicit DEFAULT registration override the pinned unqualified route"() {
+ given:
+ def registry = new GormRegistry()
+ def analyticsDs = Stub(Datastore)
+ def defaultDs = Stub(Datastore)
+
+ when:
+ registry.registerEntityDatastore(GormRegistryCoverageThing.name, 'analytics', analyticsDs)
+ registry.registerEntityDatastore(GormRegistryCoverageThing.name, ConnectionSource.DEFAULT, defaultDs)
+
+ then:
+ registry.getDatastoreByString(GormRegistryCoverageThing.name, ConnectionSource.DEFAULT) == defaultDs
+ }
+
void "registerEntityDatastores swallows connection resolution failures and keeps the parent datastore"() {
given:
def registry = new GormRegistry()
@@ -379,13 +431,22 @@ class GormRegistryCoverageSpec extends Specification {
!registry.createDynamicFinders(datastore).isEmpty()
}
- void "registerConstraints does nothing for a null datastore and swallows failures for one without constraint support"() {
+ void "initializeDatastore ignores a null datastore and registers a real one by qualifier and type"() {
given:
def registry = new GormRegistry()
- expect:
- registry.registerConstraints(null) == null
- registry.registerConstraints(datastore) == null
+ when:
+ registry.initializeDatastore(null, ConnectionSource.DEFAULT)
+
+ then:
+ registry.getDatastore(ConnectionSource.DEFAULT) == null
+
+ when:
+ registry.initializeDatastore(datastore, ConnectionSource.DEFAULT)
+
+ then:
+ registry.getDatastore(ConnectionSource.DEFAULT).is(datastore)
+ registry.apiResolver.findDatastoreByType(SimpleMapDatastore).is(datastore)
}
void "registerEntity registers static, instance and validation apis plus the entity's default datastore mapping"() {
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiSpec.groovy
index ec8e4e9bf8..91a7439d4b 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiSpec.groovy
@@ -22,6 +22,7 @@ import grails.gorm.annotation.Entity
import grails.gorm.multitenancy.Tenants
import org.grails.datastore.mapping.core.Datastore
import org.grails.datastore.mapping.core.Session
+import org.grails.datastore.mapping.core.SessionCallback
import org.grails.datastore.mapping.core.connections.ConnectionSource
import org.grails.datastore.mapping.core.connections.ConnectionSources
import org.grails.datastore.mapping.core.connections.ConnectionSourcesProvider
@@ -353,18 +354,42 @@ class GormStaticApiSpec extends Specification {
api.count() == 0
}
- void "deleteAll(Map) delegates to deleteAll()"() {
+ void "deleteAll(Map) deletes every instance and reports how many"() {
given:
def api = new GormStaticApi(GormStaticApiThing, datastore, [])
new GormStaticApiThing(name: 'a').save(flush: true)
+ new GormStaticApiThing(name: 'b').save(flush: true)
when:
- api.deleteAll([:])
+ def deleted = api.deleteAll([:])
then:
+ deleted == 2
api.count() == 0
}
+ void "deleteAll(Map) honours the flush argument"() {
+ given:
+ def session = Mock(Session)
+ def api = new GormStaticApi(GormStaticApiThing, datastore, []) {
+ @Override protected Object execute(SessionCallback callback) { callback.doInSession(session) }
+ }
+
+ when:
+ api.deleteAll([flush: true])
+
+ then:
+ 1 * session.deleteAll(_) >> 3
+ 1 * session.flush()
+
+ when:
+ api.deleteAll([:])
+
+ then: "without flush: true the session is left to the caller's flush policy"
+ 1 * session.deleteAll(_) >> 3
+ 0 * session.flush()
+ }
+
void "deleteAll(Iterable) and deleteAll(varargs) remove exactly the given instances"() {
given:
def api = new GormStaticApi(GormStaticApiThing, datastore, [])
diff --git a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/FirstAndLastMethodSpec.groovy b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/FirstAndLastMethodSpec.groovy
index 7c65cdc6f5..2b86c982f0 100644
--- a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/FirstAndLastMethodSpec.groovy
+++ b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/FirstAndLastMethodSpec.groovy
@@ -163,7 +163,7 @@ class FirstAndLastMethodSpec extends GrailsDataTckSpec {
}
@PendingFeatureIf(
- value = { System.getProperty('hibernate5.gorm.suite') || System.getProperty('hibernate7.gorm.suite') },
+ value = { System.getProperty('hibernate5.gorm.suite') },
reason = 'Was previously @Ignore'
)
void "Test first and last method with composite key"() {
diff --git a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/GetAllSpec.groovy b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/GetAllSpec.groovy
new file mode 100644
index 0000000000..fa7ffff656
--- /dev/null
+++ b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/GetAllSpec.groovy
@@ -0,0 +1,89 @@
+/*
+ * 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.apache.grails.data.testing.tck.tests
+
+import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec
+import org.apache.grails.data.testing.tck.domains.Person
+
+/**
+ * The contract for {@code getAll(ids)}: the result is positionally aligned with the ids supplied,
+ * so callers can zip the two lists together. Every adapter must honour it.
+ */
+class GetAllSpec extends GrailsDataTckSpec {
+
+ @Override
+ void setupSpec() {
+ manager.registerDomainClasses(Person)
+ }
+
+ def 'getAll returns entities in the order the ids were supplied, not the order the database chose'() {
+ given:
+ def bob = new Person(firstName: 'Bob', lastName: 'Builder').save(flush: true)
+ def fred = new Person(firstName: 'Fred', lastName: 'Flintstone').save(flush: true)
+ def joe = new Person(firstName: 'Joe', lastName: 'Doe').save(flush: true)
+ manager.session.clear()
+
+ when:
+ def results = Person.getAll(joe.id, bob.id, fred.id)
+
+ then:
+ results.size() == 3
+ results*.firstName == ['Joe', 'Bob', 'Fred']
+ }
+
+ def 'getAll preserves duplicate ids, returning one element per requested id'() {
+ given:
+ def bob = new Person(firstName: 'Bob', lastName: 'Builder').save(flush: true)
+ def fred = new Person(firstName: 'Fred', lastName: 'Flintstone').save(flush: true)
+ manager.session.clear()
+
+ when:
+ def results = Person.getAll(bob.id, fred.id, bob.id)
+
+ then:
+ results.size() == 3
+ results*.firstName == ['Bob', 'Fred', 'Bob']
+ }
+
+ def 'getAll yields a null at the position of an id that resolves to no row'() {
+ given:
+ def bob = new Person(firstName: 'Bob', lastName: 'Builder').save(flush: true)
+ def fred = new Person(firstName: 'Fred', lastName: 'Flintstone').save(flush: true)
+ def missingId = fred.id
+ fred.delete(flush: true)
+ manager.session.clear()
+
+ when:
+ def results = Person.getAll(bob.id, missingId)
+
+ then: 'the slot is kept so positions still line up with the requested ids'
+ results.size() == 2
+ results[0].firstName == 'Bob'
+ results[1] == null
+ }
+
+ def 'getAll returns an empty list for empty input'() {
+ given:
+ new Person(firstName: 'Bob', lastName: 'Builder').save(flush: true)
+ manager.session.clear()
+
+ expect:
+ Person.getAll([]) == []
+ }
+}
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 f5e9c7afb5..8313792d87 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
@@ -218,10 +218,12 @@ public abstract class AbstractDatastore implements Datastore, StatelessDatastore
/**
* @return Whether {@link #getCurrentSession()} would return an existing session rather than
* opening a new one: a validated (still connected) session is bound to the current thread.
- * Delegates to {@link #getSessionResolver()} so both methods read the same state.
+ * Reads the same state as {@link #getCurrentSession()} but leaves it untouched — this is a
+ * predicate, and callers such as datastore-routing scans invoke it on datastores they are only
+ * inspecting.
*/
public boolean hasCurrentSession() {
- return getSessionResolver().resolve() != null;
+ return getSessionResolver().hasSession();
}
/**
diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy
index 035dbe8cf4..08ae76158d 100644
--- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy
+++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy
@@ -43,6 +43,18 @@ interface SessionResolver {
*/
Session resolve()
+ /**
+ * Whether {@link #resolve()} would return a session, answered <b>without side effects</b>.
+ *
+ * {@link #resolve()} does housekeeping — it evicts disconnected sessions and may unbind an
+ * emptied holder — which makes it unsuitable for purely interrogative callers. Routing and
+ * discovery code asks many datastores whether they hold a session before choosing one; that
+ * probe must not mutate the thread-bound state of the datastores it merely inspects.
+ *
+ * @return {@code true} if a still-connected session is bound to the current context
+ */
+ boolean hasSession()
+
/**
* Binds a session owned by this resolver's datastore to the current context, making it the
* session {@link #resolve()} returns. Nested bindings stack: binding a second session pushes
diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolver.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolver.groovy
index d33b37ba90..19d39d8d54 100644
--- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolver.groovy
+++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolver.groovy
@@ -68,6 +68,23 @@ class TransactionSynchronizationSessionResolver implements SessionResolver {
return session
}
+ @Override
+ boolean hasSession() {
+ SessionHolder holder = (SessionHolder) TransactionSynchronizationManager.getResource(datastore)
+ if (holder == null) {
+ return false
+ }
+ // Read the holder without popping: unlike resolve(), this must leave the thread-bound state
+ // exactly as it found it, because callers use it to interrogate datastores they are not
+ // (yet) operating on.
+ for (Session session in holder.getSessions()) {
+ if (session != null && session.isConnected()) {
+ return true
+ }
+ }
+ return false
+ }
+
@Override
void bind(Session session) {
if (session.getDatastore() != datastore) {
diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/MultipleConnectionSourceCapableDatastore.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/MultipleConnectionSourceCapableDatastore.java
index 3b427cd1d7..5a4335603a 100644
--- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/MultipleConnectionSourceCapableDatastore.java
+++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/MultipleConnectionSourceCapableDatastore.java
@@ -36,21 +36,4 @@ public interface MultipleConnectionSourceCapableDatastore extends Datastore {
* @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() {
- return true;
- }
}
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 30a27d010d..6b04d55de2 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
@@ -357,19 +357,36 @@ class AstUtils {
}
static void copyAnnotations(final AnnotatedNode from, final AnnotatedNode to) {
- copyAnnotations(from, to, null, null)
+ copyAnnotations(from, to, null, null, false)
}
static void copyAnnotations(final AnnotatedNode from, final AnnotatedNode to, final Set<String> included, final Set<String> excluded) {
+ copyAnnotations(from, to, included, excluded, false)
+ }
+
+ /**
+ * Copies the annotations of one node onto another.
+ *
+ * @param from The node to copy the annotations from
+ * @param to The node to copy the annotations to
+ * @param included If non-null, only annotations whose class name appears here are copied
+ * @param excluded If non-null, annotations whose class name appears here are not copied
+ * @param skipExisting When {@code true} an annotation is not copied if {@code to} already carries one of the
+ * same type. Callers that may write onto a node they have already annotated themselves should opt in.
+ * The default is {@code false}, because repeatable annotations legitimately occur more than once on the
+ * same node and skipping them would silently produce an incomplete set.
+ */
+ static void copyAnnotations(final AnnotatedNode from, final AnnotatedNode to, final Set<String> included, final Set<String> excluded, final boolean skipExisting) {
final List<AnnotationNode> annotationsToCopy = from.getAnnotations()
for (final AnnotationNode node : annotationsToCopy) {
String annotationClassName = node.getClassNode().getName()
if ((excluded == null || !excluded.contains(annotationClassName)) &&
(included == null || included.contains(annotationClassName))) {
- if (to.getAnnotations(node.getClassNode()).isEmpty()) {
- final AnnotationNode copyOfAnnotationNode = cloneAnnotation(node)
- to.addAnnotation(copyOfAnnotationNode)
+ if (skipExisting && !to.getAnnotations(node.getClassNode()).isEmpty()) {
+ continue
}
+ final AnnotationNode copyOfAnnotationNode = cloneAnnotation(node)
+ to.addAnnotation(copyOfAnnotationNode)
}
}
}
diff --git a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolverSpec.groovy b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolverSpec.groovy
index 228720557a..af5cb784c7 100644
--- a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolverSpec.groovy
+++ b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/TransactionSynchronizationSessionResolverSpec.groovy
@@ -153,6 +153,66 @@ class TransactionSynchronizationSessionResolverSpec extends Specification {
TransactionSynchronizationManager.getResource(datastore).is(holder)
}
+ def "hasSession() reports whether a connected session is bound"() {
+ expect:
+ !resolver.hasSession()
+
+ when:
+ Session session = connectedSession()
+ resolver.bind(session)
+
+ then:
+ resolver.hasSession()
+
+ when:
+ resolver.unbind()
+
+ then:
+ !resolver.hasSession()
+ }
+
+ def "hasSession() is false when the only bound session is disconnected"() {
+ given:
+ Session session = Mock(Session)
+ session.getDatastore() >> datastore
+ session.isConnected() >> false
+ DatastoreUtils.bindSession(session)
+
+ expect:
+ !resolver.hasSession()
+ }
+
+ def "hasSession() sees a connected session beneath a disconnected one"() {
+ given:
+ Session live = connectedSession()
+ Session stale = Mock(Session)
+ stale.getDatastore() >> datastore
+ stale.isConnected() >> false
+ DatastoreUtils.bindSession(live)
+ DatastoreUtils.bindNewSession(stale)
+
+ expect:
+ resolver.hasSession()
+ }
+
+ def "hasSession() does not evict a disconnected session or unbind the holder"() {
+ given: "a bound holder whose only session has been disconnected — resolve() would clean this up"
+ Session session = Mock(Session)
+ session.getDatastore() >> datastore
+ session.isConnected() >> false
+ DatastoreUtils.bindSession(session)
+ SessionHolder holder = (SessionHolder) TransactionSynchronizationManager.getResource(datastore)
+
+ when: "the datastore is merely interrogated, as a routing scan does across many datastores"
+ boolean answer = resolver.hasSession()
+
+ then: "the answer is correct and the thread-bound state is exactly as it was"
+ !answer
+ TransactionSynchronizationManager.getResource(datastore).is(holder)
+ holder.containsSession(session)
+ holder.size() == 1
+ }
+
def "a session bound on one thread is not visible to another thread"() {
given:
Session session = connectedSession()
diff --git a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/reflect/AstUtilsSpec.groovy b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/reflect/AstUtilsSpec.groovy
index 84e1e6b58d..28b060c570 100644
--- a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/reflect/AstUtilsSpec.groovy
+++ b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/reflect/AstUtilsSpec.groovy
@@ -57,7 +57,22 @@ class AstUtilsSpec extends Specification {
to.getAnnotations(annotationType).size() == 1
}
- void "copyAnnotations does not add a second annotation of a type the target already carries"() {
+ void "copyAnnotations copies repeatable annotations by default"() {
+ given: "a source carrying the same annotation type twice, as a repeatable annotation legitimately does"
+ def from = newMethodNode()
+ def to = newMethodNode()
+ def annotationType = ClassHelper.make("jakarta.validation.constraints.Pattern")
+ from.addAnnotation(new AnnotationNode(annotationType))
+ from.addAnnotation(new AnnotationNode(annotationType))
+
+ when:
+ AstUtils.copyAnnotations(from, to)
+
+ then: "both are copied — the default must not silently drop all but the first"
+ to.getAnnotations(annotationType).size() == 2
+ }
+
+ void "copyAnnotations with skipExisting does not add a type the target already carries"() {
given: "a target that a caller has already annotated directly, e.g. to avoid double-adding @NotTransactional/@ReadOnly"
def from = newMethodNode()
def to = newMethodNode()
@@ -66,12 +81,27 @@ class AstUtilsSpec extends Specification {
to.addAnnotation(new AnnotationNode(annotationType))
when:
- AstUtils.copyAnnotations(from, to)
+ AstUtils.copyAnnotations(from, to, null, null, true)
then: "the target keeps its own single instance rather than gaining a duplicate, which Groovy's compiler rejects"
to.getAnnotations(annotationType).size() == 1
}
+ void "copyAnnotations without skipExisting keeps the pre-existing annotation and the copy"() {
+ given:
+ def from = newMethodNode()
+ def to = newMethodNode()
+ def annotationType = ClassHelper.make("grails.gorm.transactions.NotTransactional")
+ from.addAnnotation(new AnnotationNode(annotationType))
+ to.addAnnotation(new AnnotationNode(annotationType))
+
+ when:
+ AstUtils.copyAnnotations(from, to)
+
+ then: "the default is unchanged from the general-purpose contract — callers opt in to deduplication"
+ to.getAnnotations(annotationType).size() == 2
+ }
+
private static MethodNode newMethodNode() {
new MethodNode("test", 0, ClassHelper.VOID_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, null)
}
diff --git a/grails-doc/src/en/guide/GORM/multiTenancy.adoc b/grails-doc/src/en/guide/GORM/multiTenancy.adoc
new file mode 100644
index 0000000000..ae740fcd88
--- /dev/null
+++ b/grails-doc/src/en/guide/GORM/multiTenancy.adoc
@@ -0,0 +1,135 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+
+A multi-tenant application serves several tenants from one deployment. GORM resolves the tenant for each
+operation from the tenant bound to the current thread, so the entry points below are how application code
+says "run this work as tenant X".
+
+=== Running Work As a Tenant
+
+`grails.gorm.multitenancy.Tenants` is the entry point.
+
+==== withId
+
+`Tenants.withId(tenantId) { ... }` runs the closure as the given tenant *and manages the session for it*: a
+session is opened for the scope of the call and closed on exit. In `DATABASE` mode that is also what selects
+the tenant's connection, so this is the method to reach for in almost all application code.
+
+[source,groovy]
+----
+import grails.gorm.multitenancy.Tenants
+
+Tenants.withId('acme') {
+ new Person(name: 'Fred').save()
+ Person.count()
+}
+----
+
+The closure may take up to two parameters — the tenant id and the session:
+
+[source,groovy]
+----
+Tenants.withId('acme') { String tenantId -> Person.count() }
+Tenants.withId('acme') { String tenantId, session -> Person.count() }
+----
+
+Two further overloads select which datastore the tenant applies to when an application has more than one:
+
+[source,groovy]
+----
+// by datastore type
+Tenants.withId(HibernateDatastore, 'acme') { Person.count() }
+
+// by the datastore that maps a given domain class
+Tenants.withIdForDomain(Person, 'acme') { Person.count() }
+----
+
+NOTE: `withId(Class, ...)` takes a *datastore* type; `withIdForDomain(Class, ...)` takes a *domain* class. They
+need separate names because both erase to the same signature.
+
+==== withTenant
+
+`Tenants.withTenant(tenantId) { ... }` binds the tenant to the current thread and nothing more. It opens no
+session and manages no connection — the closure runs in whatever session the caller has already established,
+and the caller remains responsible for opening and closing it.
+
+[source,groovy]
+----
+Person.withNewSession {
+ Tenants.withTenant('acme') {
+ Person.count()
+ }
+}
+----
+
+Reach for `withTenant` when a session already exists and you only need to change which tenant the enclosed
+work resolves to; reach for `withId` when the work needs a session of its own. As with `withId`, a
+`withTenantForDomain(Class, tenantId, closure)` overload picks the datastore by domain class.
+
+==== Other Entry Points
+
+[cols="1,3"]
+|===
+|Method |Description
+
+|`Tenants.currentId()`
+|The tenant id currently in effect, resolved from the bound tenant or, failing that, the configured tenant resolver. Throws if none can be found.
+
+|`Tenants.withCurrent { ... }`
+|Runs the closure under the tenant `currentId()` resolves to.
+
+|`Tenants.withoutId { ... }`
+|Runs the closure with no tenant. In `DATABASE` mode this targets the default data source; in the shared-connection modes it drops the tenant filter from queries. Use with care — it sees every tenant's data.
+
+|`Tenants.eachTenant { ... }`
+|Runs the closure once per tenant.
+|===
+
+=== Reading the Bound Tenant
+
+`grails.gorm.multitenancy.CurrentTenantHolder` holds the tenant bound to the current thread. A thread can
+hold a different tenant for each datastore in play, so lookups are scoped by datastore:
+
+[source,groovy]
+----
+import grails.gorm.multitenancy.CurrentTenantHolder
+
+Serializable tenantId = CurrentTenantHolder.get(datastore)
+----
+
+`get()` without a datastore returns the bound tenant when the answer is unambiguous, `null` when no tenant is
+bound, and throws `TenantException` when different datastores hold different tenants — returning an arbitrary
+one of them is how cross-tenant reads and writes happen. Prefer `get(Datastore)`.
+
+The remaining methods bind and unbind:
+
+[source,groovy]
+----
+CurrentTenantHolder.set(datastore, 'acme')
+CurrentTenantHolder.remove(datastore)
+
+CurrentTenantHolder.withTenant(datastore, 'acme') { /* bound for this closure only */ }
+CurrentTenantHolder.withoutTenant(datastore) { /* no tenant for this closure only */ }
+----
+
+Bindings may also be made against a datastore *type* rather than an instance; a binding for a specific
+instance wins over one for its type.
+
+TIP: Prefer `Tenants` in application code. `CurrentTenantHolder` is the lower-level thread binding that
+`Tenants` is built on, and is useful mainly when integrating with code that manages sessions itself.
diff --git a/grails-doc/src/en/guide/GORM/quickStartGuide/basicCRUD.adoc b/grails-doc/src/en/guide/GORM/quickStartGuide/basicCRUD.adoc
index bdcc7abd69..e17c73693c 100644
--- a/grails-doc/src/en/guide/GORM/quickStartGuide/basicCRUD.adoc
+++ b/grails-doc/src/en/guide/GORM/quickStartGuide/basicCRUD.adoc
@@ -157,6 +157,44 @@ Unlike the link:{domainClassesRef}save.html[save] method, the `delete` method do
Using `flush: true` ensures the delete is executed immediately, so any errors are raised at that point.
+==== Deleting Every Instance
+
+`deleteAll()` deletes every persisted instance of a domain class and returns the number of rows removed:
+
+[source,groovy]
+----
+Number removed = Person.deleteAll()
+----
+
+An argument map may be supplied. It controls *how* the delete is executed — it does not narrow *what* is
+deleted. The supported argument is `flush`:
+
+[source,groovy]
+----
+Person.deleteAll(flush: true)
+----
+
+WARNING: `deleteAll()` and `deleteAll(Map)` remove every row for the domain class. There is no argument that
+turns them into a partial delete.
+
+To delete a subset, build a query and delete through it:
+
+[source,groovy]
+----
+Person.where { age < 18 }.deleteAll()
+----
+
+The other `deleteAll` overloads delete only the instances you hand them:
+
+[source,groovy]
+----
+def (fred, bob) = [Person.get(1), Person.get(2)]
+
+Person.deleteAll(fred, bob) // varargs
+Person.deleteAll([fred, bob]) // any Iterable
+Person.deleteAll([flush: true], fred, bob)
+----
+
=== Querying
To dynamically build queries based on optional parameters a common pattern is to use `DetachedCriteria` and progressively compose filters depending on the provided inputs.
diff --git a/grails-doc/src/en/guide/toc.yml b/grails-doc/src/en/guide/toc.yml
index b0e4a5c3ff..6fc9fcb78a 100644
--- a/grails-doc/src/en/guide/toc.yml
+++ b/grails-doc/src/en/guide/toc.yml
@@ -106,6 +106,7 @@ GORM:
quickStartGuide:
title: Quick Start Guide
basicCRUD: Basic CRUD
+ multiTenancy: Multi-Tenancy
furtherReading: Further Reading on GORM
theWebLayer:
diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index cfb81c4111..7fca641818 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -1940,3 +1940,87 @@ refreshes. Any context not started through `SpringApplication` — the CLI, plai
tests — keeps the previous fallback behaviour unchanged: a plain `DefaultStackTraceFilterer` with no
configuration applied. The filterer is JVM-global rather than per-context, so in a JVM hosting more than one
application context the last one booted supplies the filterer for all of them.
+
+==== 39. GORM API Resolution Moves to a Registry
+
+GORM no longer allocates an API object for every (entity, connection) pair up front. Per-entity, per-qualifier
+state now lives in a registry, and APIs are built on first use by a pluggable factory. For applications this is
+invisible — `Book.list()`, dynamic finders, and `Book.withNewSession { }` behave as before. It does change the
+extension points that out-of-tree GORM implementations subclass.
+
+===== GormEnhancer
+
+The following members of `org.grails.datastore.gorm.GormEnhancer` were removed:
+
+[cols="1,2", options="header"]
+|===
+| Removed
+| Migration note
+
+| `enhance()` and `enhance(PersistentEntity)`
+| Entity registration now happens as each entity is registered — `GormEnhancer`'s constructor registers every
+entity in the datastore's mapping context, and `registerEntity(PersistentEntity)` registers one. Delete calls to
+`enhance()`; a datastore is usable as soon as its enhancer is constructed.
+
+| `dynamicEnhance`
+| The flag gated `enhance()` and was always `false` when constructed from `ConnectionSourceSettings`. GORM now
+installs `methodMissing`/`propertyMissing` on an entity's metaclass only for classes that do not implement
+`GormEntity` — a `GormEntity` already carries compiled dispatch, so nothing is installed over it.
+
+| `finders`
+| Use `createDynamicFinders(Datastore)`, or read the finders from the API object you are working with.
+
+| `transactionManager`
+| Obtain the transaction manager from the datastore, e.g. `((TransactionCapableDatastore) datastore).transactionManager`.
+
+| `GormEnhancer(Datastore)` and `GormEnhancer(Datastore, PlatformTransactionManager, boolean, boolean, boolean)`
+| Use `GormEnhancer(Datastore, PlatformTransactionManager, ConnectionSourceSettings)`, which carries `failOnError`
+and `markDirty`.
+|===
+
+A new `registerApiFactories()` hook is called once, before any entity is registered. Adapters that build
+specialised API objects should override it:
+
+[source,groovy]
+----
+class MyGormEnhancer extends GormEnhancer {
+
+ @Override
+ protected void registerApiFactories() {
+ GormRegistry.instance.registerApiFactory(MyDatastore, new MyGormApiFactory())
+ }
+}
+----
+
+WARNING: Earlier snapshots of this work registered API factories from `registerConstraints(Datastore)`.
+Move that code to `registerApiFactories()`; factory registration must not depend on the constraints lifecycle step.
+
+===== GormStaticApi
+
+`GormStaticApi.multiTenancyMode` is no longer a field captured at construction. It is now a derived property
+resolved from the datastore the API currently routes to, because an API is no longer bound to one datastore for
+its lifetime. Reading `multiTenancyMode` from a subclass continues to work; assigning it does not.
+
+Subclasses should read the persistent entity through `getGormPersistentEntity()` rather than the deprecated
+`persistentEntity` field, and must not re-declare either member — a shadowing field diverges from the base
+class's value with no compiler warning.
+
+===== Tenants
+
+`Tenants.withId(Class, Serializable, Closure)` continues to take a *datastore* type. A new
+`Tenants.withIdForDomain(Class, Serializable, Closure)` takes a *domain* class instead, and
+`Tenants.withTenantForDomain(Class, Serializable, Closure)` is its `withTenant` counterpart. The names differ
+because all three erase to the same signature; existing `withId(SomeDatastore, id) { }` calls are unaffected.
+
+See <<multiTenancy,Multi-Tenancy>> for the full multi-tenancy API.
+
+===== CurrentTenantHolder.get()
+
+`grails.gorm.multitenancy.CurrentTenantHolder.get()` — the no-argument form — now throws `TenantException` when
+different datastores hold different tenants on the calling thread, rather than returning an arbitrary one of
+them. Pass the datastore you are operating on:
+
+[source,groovy]
+----
+CurrentTenantHolder.get(datastore)
+----