jdaugherty commented on code in PR #15779:
URL: https://github.com/apache/grails-core/pull/15779#discussion_r3670053180
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java:
##########
@@ -55,12 +62,47 @@
@SuppressWarnings({"rawtypes", "unchecked"})
public abstract class AbstractDatastore implements Datastore,
StatelessDatastore, ServiceRegistry {
protected static final Logger LOG =
LoggerFactory.getLogger(AbstractDatastore.class);
+
+ /**
+ * A minimal {@link ApplicationEventPublisher} that composes a {@link
SimpleApplicationEventMulticaster}
+ * rather than hand-rolling dispatch, so listener type/source filtering,
ordering, and thread-safe
+ * listener management are Spring's, not a partial reimplementation.
+ */
+ private static final class MulticasterApplicationEventPublisher implements
ApplicationEventPublisher {
Review Comment:
This is a third implementation of an abstraction we already ship.
`org.grails.datastore.gorm.events.ConfigurableApplicationEventPublisher` is
exactly `ApplicationEventPublisher + addApplicationListener`, and
`DefaultApplicationEventPublisher` is exactly this class (it just filters via
`SmartApplicationListener` instead of `SimpleApplicationEventMulticaster`).
The only reason you can't reuse it is module direction — it lives in
`grails-datamapping-core`, which has an `api` dependency on
`grails-datastore-core`. That's a five-minute fix: move the one-method
interface (and optionally the default impl) down into `grails-datastore-core`,
leave a deprecated subinterface behind in the old package for source
compatibility. Then this private class becomes
`DefaultApplicationEventPublisher` and the reflective fallback at line 218
collapses into an `instanceof` check.
As it stands we'd have three publisher implementations with three different
listener-selection behaviours (`SimpleApplicationEventMulticaster` here,
hand-rolled `SmartApplicationListener` filtering in
`DefaultApplicationEventPublisher`, full Spring semantics in
`ConfigurableApplicationContextEventPublisher`), which is worse than the
situation this change set out to fix.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java:
##########
@@ -80,8 +122,9 @@ public AbstractDatastore(MappingContext mappingContext,
PropertyResolver connect
ConfigurableApplicationContext ctx,
TPCacheAdapterRepository cacheAdapterRepository) {
this.mappingContext = mappingContext;
this.connectionDetails = connectionDetails;
- setApplicationContext(ctx);
this.cacheAdapterRepository = cacheAdapterRepository;
+ this.sessionResolver = new ThreadLocalSessionResolver<>(this);
Review Comment:
`this` escapes the constructor here — `ThreadLocalSessionResolver` captures
a partially-constructed `AbstractDatastore`, and the very next line calls the
overridable `setApplicationContext(ctx)`. It happens to be safe today because
the resolver only stores the reference and never dereferences it during
construction, but it is a fragile invariant to rely on in a base class every
datastore extends. Constructing the resolver lazily in `getSessionResolver()`
(or storing the resolver's datastore reference as a supplier) would remove the
hazard.
Also: the field is declared as a raw `SessionResolver` (line 100) and
`getSessionResolver()` returns it raw, so the `<S extends Session>` type
parameter on the interface buys nothing and every consumer has to cast. Either
drop the type parameter or return `SessionResolver<? extends Session>` — see my
note on `SessionResolver.groovy`.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java:
##########
@@ -106,8 +149,28 @@ public <T extends Service> Iterable<T> getServices() {
return serviceRegistry.getServices();
}
+ /**
+ * Closes every session held by the current thread's {@link
SessionHolder}, if any. Since
+ * {@link TransactionSynchronizationManager} is thread-local, and {@link
#sessionResolver} is
+ * just a view over the same state, this only reaches the thread invoking
{@code @PreDestroy} -
+ * sessions bound on other threads are not visible here and must be closed
by their own owning
+ * thread.
+ */
@PreDestroy
public void destroy() {
+ if (TransactionSynchronizationManager.hasResource(this)) {
+ Object resource =
TransactionSynchronizationManager.unbindResource(this);
+ if (resource instanceof SessionHolder) {
+ for (Session session : new ArrayList<>(((SessionHolder)
resource).getSessions())) {
+ try {
+ session.disconnect();
Review Comment:
This calls `session.disconnect()` directly rather than going through
`DatastoreUtils.closeSession(session)`, which already does exactly this — null
guard, debug log, and swallow-with-log on failure. Using it removes the
hand-rolled try/catch and keeps shutdown consistent with every other close path
in the module. (`ThreadLocalSessionResolver.unbind()` in this same PR correctly
uses `closeSessionOrRegisterDeferredClose`; `destroy()` should not diverge.)
Separately: this unbinds and closes unconditionally, with no check of
`SessionHolder.isSynchronizedWithTransaction()`. If `@PreDestroy` fires on a
thread that is inside a Spring-managed transaction,
`DatastoreTransactionManager`/`SpringSessionSynchronization` still hold that
same `SessionHolder` and will operate on a closed session during
commit/rollback. At minimum this should skip holders that are synchronized with
a transaction rather than yanking them.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java:
##########
@@ -122,6 +185,46 @@ public void destroy() {
public void setApplicationContext(ApplicationContext ctx) {
applicationContext = ctx;
+ if (ctx instanceof ApplicationEventPublisher) {
Review Comment:
The `applicationEventPublisherExplicitlySet` guard is applied
asymmetrically: it protects the `ctx == null` branch but not this one. So a
caller that does
```java
datastore.setApplicationEventPublisher(myPublisher);
datastore.setApplicationContext(ctx); // myPublisher silently discarded
```
loses their publisher and every listener registered on it, while
`setApplicationContext(null)` preserves it. The new spec only covers the null
case (`"setApplicationContext(null) does not discard an explicitly-installed
custom publisher"`), so the inconsistent half is untested.
Either honour the flag in both branches, or drop the flag and document that
`setApplicationContext` always wins. Whichever you pick, please add the
non-null counterpart test — as written the flag reads like it protects the
caller but only does so half the time.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java:
##########
@@ -223,7 +326,7 @@ public ConfigurableApplicationContext
getApplicationContext() {
}
public ApplicationEventPublisher getApplicationEventPublisher() {
- return getApplicationContext();
+ return applicationEventPublisher;
Review Comment:
Worth calling out explicitly in the PR description: this is a behavioural
change for datastores constructed with a null `ApplicationContext`. Previously
`getApplicationEventPublisher()` returned `getApplicationContext()`, i.e.
`null`; now `setApplicationContext(null)` installs a default multicaster so it
is always non-null.
Every `if (publisher != null)` guard in the codebase —
`Query.doList()`/`doCount()`, `HibernateQuery` — now takes the publish branch,
allocating a `PreQueryEvent` and running a multicast per query for datastores
that previously published nothing. Functionally harmless with zero listeners,
but it is a per-query allocation added in a PR about reducing allocation, and
it changes what `Datastore.getApplicationEventPublisher()` promises. If the
intent is "never null", say so in the `Datastore` javadoc and drop the now-dead
null checks; if not, keep returning null when nothing is configured.
(Genuinely good that this no longer routes through the `@Deprecated`
`getApplicationContext()`.)
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy:
##########
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.grails.datastore.mapping.core
+
+import groovy.transform.CompileStatic
+
+/**
+ * Resolver for the current session bound to a datastore in the current
context (thread).
+ * Implementations compose over {@link
org.grails.datastore.mapping.transactions.SessionHolder}/
+ * {@link
org.springframework.transaction.support.TransactionSynchronizationManager}
rather than
+ * maintaining independent state, so this never disagrees with Spring's own
transactional session
+ * bookkeeping.
+ *
+ * @author borinquenkid
+ * @since 8.0
+ */
+@CompileStatic
+interface SessionResolver<S extends Session> {
+
+ /** Resolves the current session based on current context (thread) */
+ S resolve()
+
+ /** Binds a session to the current context */
+ void bind(S session)
+
+ /** Unbinds the current session */
+ void unbind()
Review Comment:
The doc comment says "Unbinds the current session", but the only
implementation also **closes** it (`closeSessionOrRegisterDeferredClose`).
That's a meaningful difference for anyone implementing this interface or
calling it with a session they don't own — `bind(session); unbind()` destroys a
session the caller may still be using, which is not what "unbind" implies
anywhere else in this codebase
(`TransactionSynchronizationManager.unbindResource` doesn't close, and
`DatastoreUtils` deliberately has separate `bindSession`/`closeSession`
operations).
Either document it as `unbindAndClose()`/rename it, or make `unbind()`
purely detach and leave closing to the caller. As the contract for a brand-new
public interface, this needs to be unambiguous before implementations start
appearing in the adapter PRs.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy:
##########
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.grails.datastore.mapping.core
+
+import groovy.transform.CompileStatic
+
+/**
+ * Resolver for the current session bound to a datastore in the current
context (thread).
+ * Implementations compose over {@link
org.grails.datastore.mapping.transactions.SessionHolder}/
+ * {@link
org.springframework.transaction.support.TransactionSynchronizationManager}
rather than
+ * maintaining independent state, so this never disagrees with Spring's own
transactional session
+ * bookkeeping.
+ *
+ * @author borinquenkid
+ * @since 8.0
+ */
+@CompileStatic
+interface SessionResolver<S extends Session> {
Review Comment:
Two API notes on the new interface:
1. `<S extends Session>` is never used by any consumer —
`Datastore.getSessionResolver()` and `AbstractDatastore.sessionResolver` are
both raw, so the parameter only forces unchecked casts (see
`ThreadLocalSessionResolver.resolve()`'s `(S)` cast). Drop it, or thread it
through `Datastore` properly.
2. `@author borinquenkid` — the convention in this module is a real name
(`@author Graeme Rocher`); GitHub handles don't appear in any existing
`@author` tag here.
Minor, but this is permanent public API in a core package.
##########
grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/services/DefaultServiceRegistrySpec.groovy:
##########
@@ -53,6 +53,8 @@ class DefaultServiceRegistrySpec extends Specification {
}
}
-class TestService implements Service, ITestService {}
+class TestService implements Service, ITestService {
+ Datastore datastore
Review Comment:
This is a coverage regression, and I think it's leftover from the trait
change that got reverted (`Service.groovy`'s diff is now a single blank line).
Declaring `Datastore datastore` on `TestService` generates
`getDatastore()`/`setDatastore()` on the class itself, which take precedence
over the trait's implementations. So after this change:
- `'test load services into service registry'` no longer exercises
`Service`'s own accessors or its private `datastore` field — it exercises a
plain Groovy property.
- `'test that all Service trait methods are marked as Generated'` now
asserts `@Generated` on Groovy's property accessors rather than on the
trait-contributed methods, which is precisely what that test exists to verify.
Both specs still pass, which is what makes this worth catching. Please
revert `TestService` to `class TestService implements Service, ITestService {}`.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/AbstractConnectionSourceFactory.java:
##########
@@ -88,6 +101,17 @@ public ConnectionSource<T, S> createRuntime(String name,
PropertyResolver config
S settings = buildRuntimeSettings(name, configuration,
fallbackSettings);
return create(name, settings);
}
+
+ /**
+ * Creates the settings for the given configuration
+ * @param configuration The configuration
+ * @return The settings
+ */
+ @SuppressWarnings("unchecked")
+ public S createSettings(PropertyResolver configuration) {
+ ConnectionSourceSettings fallbackSettings =
buildFallbackSettings(configuration);
+ return (S) buildSettings(ConnectionSource.DEFAULT, configuration,
fallbackSettings, true);
Review Comment:
Three things here:
1. `buildSettings` is already declared to return `S` (line 120), so the
`(S)` cast and the `@SuppressWarnings("unchecked")` are both no-ops. Remove
them.
2. It hard-codes `ConnectionSource.DEFAULT` / `isDefaultDataSource = true`,
so it can only ever produce settings for the default connection. `create(name,
configuration)` derives that flag from the name; this method can't. If
`GormRegistry` needs per-connection settings this signature won't serve, and
`AbstractConnectionSourceFactorySpec` doesn't cover a named connection.
3. Nothing in this PR calls it. Given (1) and (2), the shape hasn't been
validated against a real consumer — I'd rather this landed with the code that
uses it.
Extracting `buildFallbackSettings` is a genuine improvement, though; that
part is fine to keep.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/ThreadLocalSessionResolver.groovy:
##########
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.grails.datastore.mapping.core
+
+import groovy.transform.CompileStatic
+
+import
org.springframework.transaction.support.TransactionSynchronizationManager
+
+import org.grails.datastore.mapping.transactions.SessionHolder
+
+/**
+ * The default {@link SessionResolver}, backed by the same {@link
SessionHolder}/
+ * {@link TransactionSynchronizationManager} state as {@link DatastoreUtils}'s
session binding -
+ * a thin, stateless view over the one authoritative session stack for its
owning datastore, rather
+ * than an independent thread-local store that could disagree with it. Nested
bindings are supported
+ * because {@link SessionHolder} itself is a stack: {@link #bind(Session)}
pushes, {@link #resolve()}
+ * returns the top, and {@link #unbind()} closes and pops only the top
session, restoring the outer
+ * binding rather than discarding the whole holder.
+ *
+ * @author borinquenkid
+ * @since 8.0
+ */
+@CompileStatic
+class ThreadLocalSessionResolver<S extends Session> implements
SessionResolver<S> {
+
+ private final Datastore datastore
+
+ ThreadLocalSessionResolver(Datastore datastore) {
+ this.datastore = datastore
+ }
+
+ @Override
+ S resolve() {
+ SessionHolder holder = (SessionHolder)
TransactionSynchronizationManager.getResource(datastore)
+ return holder != null ? (S) holder.getSession() : null
+ }
+
+ @Override
+ void bind(S session) {
+ DatastoreUtils.bindNewSession(session)
+ }
+
+ @Override
+ void unbind() {
+ SessionHolder holder = (SessionHolder)
TransactionSynchronizationManager.getResource(datastore)
Review Comment:
This is the third copy of the unbind sequence in this PR
(`DatastoreUtils.unbindSession`, `DatastoreUtils.executeWithNewSession`'s
`finally`, and here). Once the key mismatch above is fixed, this whole method
is:
```groovy
void unbind() {
S session = resolve()
if (session != null) {
DatastoreUtils.unbindSession(session)
}
}
```
which also picks up `unbindSession`'s `containsSession` guard and its
warn-logging for the "nothing bound" case.
Separately — now that the independent `ThreadLocal` is gone (good change),
the class name is misleading: it holds no `ThreadLocal` and is a view over
`TransactionSynchronizationManager`.
`TransactionSynchronizationSessionResolver` or `SessionHolderSessionResolver`
would describe what it actually is. Worth renaming now, before the adapter PRs
reference it.
##########
grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/model/AbstractPersistentEntityGetTenantIdSpec.groovy:
##########
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.datastore.mapping.model
+
+import org.grails.datastore.mapping.core.connections.ConnectionSourceSettings
+import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
+import spock.lang.Specification
+
+class AbstractPersistentEntityGetTenantIdSpec extends Specification {
+
+ def "getTenantId resolves the TenantId property in DISCRIMINATOR mode"() {
+ given:
+ TestMappingContext context = new TestMappingContext()
+ context.initialize(discriminatorSettings())
Review Comment:
`AbstractMappingContext.initialize(ConnectionSourceSettings)` is
`protected`; this only compiles because Groovy ignores that. Per AGENTS.md
("Test via public APIs — never invoke internal implementations, package-private
methods, or bypass the public surface directly") this isn't a valid way to set
up the spec.
More importantly, it's diagnostic: the reason the setup needs a protected
lifecycle hook is that the production fallback it exercises
(`AbstractPersistentEntity.getTenantId()`'s lazy scan) can't be reached through
public API at all — see my comment on `AbstractPersistentEntity.java:101`. If
you can't drive the scenario through a public entry point, that's a strong
signal the production code shouldn't be handling it yet.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/transactions/CustomizableRollbackTransactionAttribute.java:
##########
@@ -51,38 +53,51 @@ public CustomizableRollbackTransactionAttribute(int
propagationBehavior, List<Ro
super(propagationBehavior, rollbackRules);
}
- public
CustomizableRollbackTransactionAttribute(org.springframework.transaction.interceptor.TransactionAttribute
other) {
+ public CustomizableRollbackTransactionAttribute(TransactionAttribute
other) {
super();
- setPropagationBehavior(other.getPropagationBehavior());
- setIsolationLevel(other.getIsolationLevel());
- setTimeout(other.getTimeout());
- setReadOnly(other.isReadOnly());
- setName(other.getName());
+ copyFrom(other);
}
public CustomizableRollbackTransactionAttribute(TransactionDefinition
other) {
super();
- setPropagationBehavior(other.getPropagationBehavior());
- setIsolationLevel(other.getIsolationLevel());
- setTimeout(other.getTimeout());
- setReadOnly(other.isReadOnly());
- setName(other.getName());
+ copyFrom(other);
}
public
CustomizableRollbackTransactionAttribute(CustomizableRollbackTransactionAttribute
other) {
- this((RuleBasedTransactionAttribute) other);
+ super();
+ copyFrom(other);
}
public
CustomizableRollbackTransactionAttribute(RuleBasedTransactionAttribute other) {
+ super();
+ copyFrom(other);
+ }
+
+ protected void copyFrom(TransactionDefinition other) {
+ setPropagationBehavior(other.getPropagationBehavior());
+ setIsolationLevel(other.getIsolationLevel());
+ setTimeout(other.getTimeout());
+ setReadOnly(other.isReadOnly());
+ setName(other.getName());
+ if (other instanceof TransactionAttribute) {
+ TransactionAttribute otherAttribute = (TransactionAttribute) other;
+ setQualifier(otherAttribute.getQualifier());
+ setLabels(otherAttribute.getLabels());
Review Comment:
This reintroduces, for labels, exactly the aliasing defect that was just
fixed for rollback rules. Spring's
`DefaultTransactionAttribute.setLabels(Collection<String>)` stores the
reference (`this.labels = labels;` — no copy), so the copy and the source share
one mutable collection.
`new LinkedHashSet<>(otherAttribute.getLabels())` (or `List.copyOf`) fixes
it.
The new spec doesn't catch this: `"copy constructor preserves qualifier,
labels, and connection metadata"` asserts label *values*, not independence. The
rollback-rules test asserts independence — labels needs the same treatment.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/transactions/CustomizableRollbackTransactionAttribute.java:
##########
@@ -51,38 +53,51 @@ public CustomizableRollbackTransactionAttribute(int
propagationBehavior, List<Ro
super(propagationBehavior, rollbackRules);
}
- public
CustomizableRollbackTransactionAttribute(org.springframework.transaction.interceptor.TransactionAttribute
other) {
+ public CustomizableRollbackTransactionAttribute(TransactionAttribute
other) {
super();
- setPropagationBehavior(other.getPropagationBehavior());
- setIsolationLevel(other.getIsolationLevel());
- setTimeout(other.getTimeout());
- setReadOnly(other.isReadOnly());
- setName(other.getName());
+ copyFrom(other);
}
public CustomizableRollbackTransactionAttribute(TransactionDefinition
other) {
super();
- setPropagationBehavior(other.getPropagationBehavior());
- setIsolationLevel(other.getIsolationLevel());
- setTimeout(other.getTimeout());
- setReadOnly(other.isReadOnly());
- setName(other.getName());
+ copyFrom(other);
}
public
CustomizableRollbackTransactionAttribute(CustomizableRollbackTransactionAttribute
other) {
- this((RuleBasedTransactionAttribute) other);
+ super();
+ copyFrom(other);
}
public
CustomizableRollbackTransactionAttribute(RuleBasedTransactionAttribute other) {
+ super();
+ copyFrom(other);
+ }
+
+ protected void copyFrom(TransactionDefinition other) {
+ setPropagationBehavior(other.getPropagationBehavior());
+ setIsolationLevel(other.getIsolationLevel());
+ setTimeout(other.getTimeout());
+ setReadOnly(other.isReadOnly());
+ setName(other.getName());
+ if (other instanceof TransactionAttribute) {
+ TransactionAttribute otherAttribute = (TransactionAttribute) other;
+ setQualifier(otherAttribute.getQualifier());
+ setLabels(otherAttribute.getLabels());
+ }
+ if (other instanceof RuleBasedTransactionAttribute) {
+ List<RollbackRuleAttribute> otherRules =
((RuleBasedTransactionAttribute) other).getRollbackRules();
Review Comment:
Two problems, both avoidable by reusing Spring's own copy constructor:
1. `RuleBasedTransactionAttribute.getRollbackRules()` **never returns null**
— it lazily assigns `new ArrayList<>()` and returns it. So the `otherRules !=
null` branch is dead, and worse, calling the getter here *mutates* `other` by
installing a fresh list on it. Use the field via the copy ctor instead of the
lazy getter.
2. `copyFrom` silently drops `DefaultTransactionAttribute.descriptor` and
`timeoutString`. `timeoutString` matters: `resolveAttributeStrings()` turns it
into the effective timeout, so a copy of an attribute built from
`@Transactional(timeoutString = "...")` loses its timeout and falls back to
`TIMEOUT_DEFAULT`.
Spring already provides
`RuleBasedTransactionAttribute(RuleBasedTransactionAttribute other)`, which
does `super(other)` plus `new ArrayList<>(other.rollbackRules)`. For the
`RuleBasedTransactionAttribute`/`CustomizableRollbackTransactionAttribute`
overloads this reduces to:
```java
public
CustomizableRollbackTransactionAttribute(RuleBasedTransactionAttribute other) {
super(other);
if (other instanceof CustomizableRollbackTransactionAttribute crta) {
this.inheritRollbackOnly = crta.inheritRollbackOnly;
this.connection = crta.connection;
}
}
```
which stays correct automatically as Spring adds fields.
##########
grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/AbstractDatastoreSpec.groovy:
##########
@@ -0,0 +1,319 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.grails.datastore.mapping.core
+
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
Review Comment:
`PersistentEntity` is imported but never used.
Also, the import ordering in all the new specs doesn't follow the convention
the rest of the module uses (`java` → `groovy` → `jakarta` → other →
`org.springframework`/`io.spring` → `org.grails`, groups blank-line separated —
see the checkstyle `ImportOrderCheck` config and e.g. `DatastoreUtilsSpec`).
Here `org.grails` comes first; in `ConnectionSourceSettingsBuilderSpec`
`jakarta.persistence.FlushModeType` comes after `org.grails`. Checkstyle
doesn't run on Groovy or on test source sets so nothing enforces it, but
consistency with the surrounding code is worth having.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/DatastoreUtils.java:
##########
@@ -361,6 +361,42 @@ public static void execute(final Datastore datastore,
final VoidSessionCallback
}
}
+ /**
+ * Execute the given callback with a new session, regardless of whether an
existing session is present
+ * @param datastore The datastore
+ * @param callback The callback
+ * @param <T> The return type
+ * @return The result of the callback
+ */
+ public static <T> T executeWithNewSession(Datastore datastore,
SessionCallback<T> callback) {
+ Session session = bindNewSession(datastore.connect());
+ try {
+ return callback.doInSession(session);
+ }
+ finally {
Review Comment:
This `finally` block is `DatastoreUtils.unbindSession(Session)` re-inlined
(it's ~40 lines up in this same file — `removeSession` / unbind-if-last /
`closeSessionOrRegisterDeferredClose`, plus a `containsSession` guard this
version lacks). `ThreadLocalSessionResolver.unbind()` in this PR is a *third*
copy. Please delegate:
```java
public static <T> T executeWithNewSession(Datastore datastore,
SessionCallback<T> callback) {
Session session = bindNewSession(datastore.connect());
try {
return callback.doInSession(session);
}
finally {
unbindSession(session);
}
}
```
There's also a key inconsistency: `bindNewSession(session)` binds against
`session.getDatastore()`, but the `finally` reads `getResource(datastore)`. For
`ChildHibernateDatastore` and other cases where `datastore.connect()` returns a
session owned by a different `Datastore` instance, the bind and the cleanup
target different TSM keys — the holder is never cleaned up and the session
leaks. `unbindSession(session)` keys off the session consistently and avoids
this entirely.
Both overloads are also unused anywhere in this PR, so nothing exercises the
divergent-datastore case.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/Datastore.java:
##########
@@ -39,6 +39,17 @@
*/
public interface Datastore extends ServiceRegistry {
+ /**
+ * @return The session resolver for this datastore. The default
implementation is a stateless
+ * view over the same {@link
org.grails.datastore.mapping.transactions.SessionHolder}/
+ * {@link
org.springframework.transaction.support.TransactionSynchronizationManager}
state used
+ * elsewhere for this datastore, so implementations only need to override
this if they resolve
+ * sessions some other way.
+ */
Review Comment:
The `default` method is the right compatibility call, but it allocates a
fresh `ThreadLocalSessionResolver` on every invocation. Two consequences:
1. `datastore.getSessionResolver() != datastore.getSessionResolver()` for
any implementation that doesn't override it, so callers can never cache/compare
resolvers, and `AbstractDatastore` (which does hold a stable instance) and a
bare implementor behave differently. That's an easy source of downstream bugs
in (2/3) if `GormRegistry` ever keys anything by resolver identity.
2. In a PR whose stated purpose is turning O(M×N) allocation into O(M+N), a
public accessor that allocates per call is the wrong default.
Given `Datastore` is an interface you can't hold state in, I'd rather this
method not be on `Datastore` at all in this PR — `AbstractDatastore` already
exposes a stable `getSessionResolver()`, and nothing here or in (2/3) consumes
the interface-level method. If it must stay, please document that the returned
resolver is stateless and may be a new instance per call.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java:
##########
@@ -122,6 +185,46 @@ public void destroy() {
public void setApplicationContext(ApplicationContext ctx) {
applicationContext = ctx;
+ if (ctx instanceof ApplicationEventPublisher) {
+ this.applicationEventPublisher = (ApplicationEventPublisher) ctx;
+ }
+ else if (ctx == null && !applicationEventPublisherExplicitlySet) {
+ this.applicationEventPublisher = new
MulticasterApplicationEventPublisher();
+ }
+ }
+
+ public void setApplicationEventPublisher(ApplicationEventPublisher
applicationEventPublisher) {
+ this.applicationEventPublisher = applicationEventPublisher;
+ this.applicationEventPublisherExplicitlySet = true;
+ }
+
+ /**
+ * Adds an application listener to the datastore. Registers against {@link
#getApplicationEventPublisher()}
+ * rather than the raw field, so the listener reaches whatever publisher
this datastore (or a subclass
+ * overriding {@link #getApplicationEventPublisher()} with its own field)
actually publishes events through.
+ *
+ * @param listener The listener
+ * @throws IllegalStateException if the configured publisher exposes no
way to register a listener - silently
+ * dropping the listener would violate this method's contract that the
listener receives future events
+ */
+ public void addApplicationListener(ApplicationListener<?> listener) {
+ ApplicationEventPublisher publisher = getApplicationEventPublisher();
+ if (publisher instanceof ConfigurableApplicationContext) {
+ ((ConfigurableApplicationContext)
publisher).addApplicationListener(listener);
+ }
+ else if (publisher instanceof MulticasterApplicationEventPublisher) {
+ ((MulticasterApplicationEventPublisher)
publisher).addApplicationListener(listener);
+ }
+ else if (publisher != null) {
+ try {
+ Method method =
publisher.getClass().getMethod("addApplicationListener",
ApplicationListener.class);
Review Comment:
This is duck-typing `ConfigurableApplicationEventPublisher`. Note this is
not an exotic fallback — it is the *primary* path for every real datastore:
`MongoDatastore`, `SimpleMapDatastore`, `HibernateDatastore` and
`Neo4jDatastore` all return a `ConfigurableApplicationEventPublisher` from
`getApplicationEventPublisher()`, which is neither a
`ConfigurableApplicationContext` nor the private multicaster above. So in
production this method reaches every listener registration through reflection.
Move `ConfigurableApplicationEventPublisher` into `grails-datastore-core`
(see my comment on line 71) and this whole branch becomes:
```java
else if (publisher instanceof ConfigurableApplicationEventPublisher cfg) {
cfg.addApplicationListener(listener);
}
else if (publisher != null) {
throw new IllegalStateException(...);
}
```
which is checked at compile time, has no per-call reflection, and gives the
same fail-fast guarantee you added for the unsupported case.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java:
##########
@@ -171,7 +274,7 @@ public Session getCurrentSession() throws
ConnectionNotFoundException {
}
public boolean hasCurrentSession() {
- return TransactionSynchronizationManager.hasResource(this);
+ return sessionResolver.resolve() != null;
Review Comment:
This changes the meaning of a `Datastore` public-API method that every
adapter inherits, and it does not actually achieve the alignment it was changed
for.
- Old: `hasCurrentSession()` == "a `SessionHolder` is bound for this
datastore".
- New: "the holder's top session is non-null".
Those differ for a bound-but-empty holder, which is reachable
(`SessionHolder.removeSession` is called from
`AbstractAttributeStoringSession.disconnect()`, `DatastoreUtils.unbindSession`,
`executeWithNewSession`, and now `ThreadLocalSessionResolver.unbind()`), and
`DatastoreUtils.execute`/`doWithSession` branch on this value and then call
`bindSession(...)`, which throws from
`TransactionSynchronizationManager.bindResource` if a resource is already bound
for the key.
More importantly, the stated goal was that `hasCurrentSession()` and
`getCurrentSession()` agree — they still don't. `getCurrentSession()` →
`DatastoreUtils.doGetSession` uses `sessionHolder.getValidatedSession()`, which
evicts and returns `null` for a disconnected session; `resolve()` uses the
unvalidated `getSession()`. So a holder whose top session has been disconnected
reports `hasCurrentSession() == true` while `getCurrentSession()` opens a brand
new session.
If the resolver is meant to be the single authoritative lookup, `resolve()`
should use `getValidatedSession()`. Either way this needs a test that exercises
the disconnected-session and empty-holder cases, otherwise I'd rather leave
`hasCurrentSession()` as `hasResource(this)` in this PR.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/ThreadLocalSessionResolver.groovy:
##########
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.grails.datastore.mapping.core
+
+import groovy.transform.CompileStatic
+
+import
org.springframework.transaction.support.TransactionSynchronizationManager
+
+import org.grails.datastore.mapping.transactions.SessionHolder
+
+/**
+ * The default {@link SessionResolver}, backed by the same {@link
SessionHolder}/
+ * {@link TransactionSynchronizationManager} state as {@link DatastoreUtils}'s
session binding -
+ * a thin, stateless view over the one authoritative session stack for its
owning datastore, rather
+ * than an independent thread-local store that could disagree with it. Nested
bindings are supported
+ * because {@link SessionHolder} itself is a stack: {@link #bind(Session)}
pushes, {@link #resolve()}
+ * returns the top, and {@link #unbind()} closes and pops only the top
session, restoring the outer
+ * binding rather than discarding the whole holder.
+ *
+ * @author borinquenkid
+ * @since 8.0
+ */
+@CompileStatic
+class ThreadLocalSessionResolver<S extends Session> implements
SessionResolver<S> {
+
+ private final Datastore datastore
+
+ ThreadLocalSessionResolver(Datastore datastore) {
+ this.datastore = datastore
+ }
+
+ @Override
+ S resolve() {
+ SessionHolder holder = (SessionHolder)
TransactionSynchronizationManager.getResource(datastore)
+ return holder != null ? (S) holder.getSession() : null
+ }
+
+ @Override
+ void bind(S session) {
+ DatastoreUtils.bindNewSession(session)
Review Comment:
`bind()` binds against `session.getDatastore()` (via `bindNewSession`),
while `resolve()` (line 51) and `unbind()` (line 62) both read
`this.datastore`. When those differ — a session obtained from a
`ChildHibernateDatastore`, or any resolver handed a session it doesn't own —
`bind(session)` silently writes to one TSM key and `resolve()` reads another,
so the bind appears to succeed and then vanishes, and `unbind()` operates on
the wrong holder.
`ThreadLocalSessionResolverSpec` stubs `session.getDatastore() >> datastore`
in every feature, so this case is never exercised. Please either key everything
off `session.getDatastore()`, or reject a mismatched session up front:
```groovy
void bind(S session) {
assert session.datastore.is(datastore) : "session belongs to a different
datastore"
DatastoreUtils.bindNewSession(session)
}
```
and add a spec for the mismatched case.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/query/Query.java:
##########
@@ -646,7 +646,7 @@ private List doList() {
ApplicationEventPublisher publisher =
session.getDatastore().getApplicationEventPublisher();
if (publisher != null) {
- publisher.publishEvent(new PreQueryEvent(this));
+ publisher.publishEvent(new PreQueryEvent(session.getDatastore(),
this));
Review Comment:
This is a no-op. `PreQueryEvent(Query)` delegates to
`AbstractQueryEvent(Query)`, which already does
`super(query.getSession().getDatastore())` — the same `session.getDatastore()`
you're now passing explicitly (`session` here is `this.session`, and
`query.getSession()` returns it). Identical event source before and after.
Unrelated churn in a `SessionResolver` PR; please revert. Same for the
`(Map<String, Object>[])` cast added in `DatastoreUtils.createPropertyResolver`
— the array assignment was already unchecked, the cast changes nothing.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/transactions/CustomizableRollbackTransactionAttribute.java:
##########
@@ -51,38 +53,51 @@ public CustomizableRollbackTransactionAttribute(int
propagationBehavior, List<Ro
super(propagationBehavior, rollbackRules);
}
- public
CustomizableRollbackTransactionAttribute(org.springframework.transaction.interceptor.TransactionAttribute
other) {
+ public CustomizableRollbackTransactionAttribute(TransactionAttribute
other) {
super();
- setPropagationBehavior(other.getPropagationBehavior());
- setIsolationLevel(other.getIsolationLevel());
- setTimeout(other.getTimeout());
- setReadOnly(other.isReadOnly());
- setName(other.getName());
+ copyFrom(other);
}
public CustomizableRollbackTransactionAttribute(TransactionDefinition
other) {
super();
- setPropagationBehavior(other.getPropagationBehavior());
- setIsolationLevel(other.getIsolationLevel());
- setTimeout(other.getTimeout());
- setReadOnly(other.isReadOnly());
- setName(other.getName());
+ copyFrom(other);
}
public
CustomizableRollbackTransactionAttribute(CustomizableRollbackTransactionAttribute
other) {
- this((RuleBasedTransactionAttribute) other);
+ super();
+ copyFrom(other);
}
public
CustomizableRollbackTransactionAttribute(RuleBasedTransactionAttribute other) {
+ super();
+ copyFrom(other);
Review Comment:
This is a user-visible transaction-semantics change and should not ride
along in a `SessionResolver` infrastructure PR.
Before,
`CustomizableRollbackTransactionAttribute(RuleBasedTransactionAttribute)` — and
`(CustomizableRollbackTransactionAttribute)`, which delegated to it — copied
**only** `inheritRollbackOnly`. Nothing else. Now they copy propagation,
isolation, timeout, readOnly, name, qualifier, labels **and rollback rules**.
That reaches production paths.
`DefaultTransactionService.withNewTransaction(TransactionDefinition, Closure)`
does `new CustomizableRollbackTransactionAttribute(definition)`;
`GrailsTransactionTemplate` does the same for non-`Customizable` attributes.
Rollback rules now propagate into the derived attribute where they previously
didn't — so a `NoRollbackRuleAttribute` on the source will now suppress a
rollback that used to happen (`rollbackOn` returns `true` when no rule matches,
`false` when a `NoRollbackRuleAttribute` wins). That is a silent change to
whether user transactions roll back.
It may well be the correct fix for a latent bug, but it needs: its own PR, a
note in the description, tests through
`GrailsTransactionTemplate`/`DefaultTransactionService` (not just the attribute
in isolation), and a look at whether it warrants a docs/upgrade note. Please
split it out.
(The `"...on $ex"` → string-concat fixes further down are a genuine catch —
GString syntax in a `.java` file was printing literal `$ex`. Happy to see those
land, ideally in the same split-out PR.)
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/services/Service.groovy:
##########
@@ -46,4 +46,5 @@ trait Service<T> {
void setDatastore(Datastore datastore) {
this.datastore = datastore
}
+
Review Comment:
This file's entire diff is one added blank line. Please revert — the
`Service` trait shouldn't appear in this PR at all.
Relatedly: the `DefaultServiceRegistrySpec` change looks like leftover from
the reverted trait rework (commit "restore concrete Service trait methods…").
See my comment there.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AbstractPersistentEntity.java:
##########
@@ -98,6 +98,15 @@ public PersistentProperty[] getCompositeIdentity() {
}
public TenantId getTenantId() {
+ if (this.tenantId == null && persistentProperties != null &&
Review Comment:
This fallback is unreachable through any public API, and it has real costs.
**Unreachable:** it only fires when `context.getMultiTenancyMode()` becomes
`DISCRIMINATOR` *after* `initialize()` already ran. The only way to do that is
`AbstractMappingContext.initialize(ConnectionSourceSettings)`, which is
`protected` — and the `MappingContext.initialize`/`setMultiTenancyMode`
promotions that would have made it reachable were (correctly) reverted out of
this PR. `AbstractPersistentEntityGetTenantIdSpec` can only reach it by calling
that protected hook from a test. So this is dead defensive code guarding a
state no caller can produce.
**Cost 1 — mutation from a getter, unsynchronized:** `tenantId` is a
private, non-`volatile` field on an object shared across every request thread,
and `getTenantId()` is called on hot paths (`AbstractGormMappingFactory`,
`MultiTenantEventListener`, `HibernateGormStaticApi`). Writing to it from a
getter with no memory barrier is exactly the kind of shared-mutable-state issue
this module works hard to avoid.
**Cost 2 — no negative caching:** for a multi-tenant entity with no
`TenantId` property, `this.tenantId` stays null, so the full
`persistentProperties` scan re-runs on *every* call, forever. In a change set
justified by O(M+N) scaling, adding an unbounded repeated O(properties) scan to
a hot getter is the wrong direction.
If downstream work genuinely needs mode-independent tenant-property lookup,
please add it as an explicit method (e.g. `findTenantIdProperty()`) in the PR
that needs it, and leave this getter's DISCRIMINATOR-only contract intact.
The `isMultiTenant` → `isMultiTenant()` swap at line 165 is also unrelated:
`isMultiTenant` is a `final` field and the accessor just returns it, so it is a
no-op today — and a silent behaviour change the moment any subclass overrides
`isMultiTenant()`. Please revert it.
##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/transactions/CustomizableRollbackTransactionAttribute.java:
##########
@@ -51,38 +53,51 @@ public CustomizableRollbackTransactionAttribute(int
propagationBehavior, List<Ro
super(propagationBehavior, rollbackRules);
}
- public
CustomizableRollbackTransactionAttribute(org.springframework.transaction.interceptor.TransactionAttribute
other) {
+ public CustomizableRollbackTransactionAttribute(TransactionAttribute
other) {
super();
- setPropagationBehavior(other.getPropagationBehavior());
- setIsolationLevel(other.getIsolationLevel());
- setTimeout(other.getTimeout());
- setReadOnly(other.isReadOnly());
- setName(other.getName());
+ copyFrom(other);
}
public CustomizableRollbackTransactionAttribute(TransactionDefinition
other) {
super();
- setPropagationBehavior(other.getPropagationBehavior());
- setIsolationLevel(other.getIsolationLevel());
- setTimeout(other.getTimeout());
- setReadOnly(other.isReadOnly());
- setName(other.getName());
+ copyFrom(other);
}
public
CustomizableRollbackTransactionAttribute(CustomizableRollbackTransactionAttribute
other) {
- this((RuleBasedTransactionAttribute) other);
+ super();
+ copyFrom(other);
}
public
CustomizableRollbackTransactionAttribute(RuleBasedTransactionAttribute other) {
+ super();
+ copyFrom(other);
+ }
+
+ protected void copyFrom(TransactionDefinition other) {
Review Comment:
`copyFrom` is `protected` and is invoked from four constructors. A subclass
overriding it would see its own fields uninitialised — the classic
overridable-call-from-constructor hazard (SpotBugs
`MC_OVERRIDABLE_METHOD_CALL_IN_CONSTRUCTOR`). Since nothing needs to extend it,
make it `private` (or `final`).
##########
grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/AbstractDatastoreSpec.groovy:
##########
@@ -0,0 +1,319 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.grails.datastore.mapping.core
+
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.springframework.context.ApplicationEvent
+import org.springframework.context.ApplicationEventPublisher
+import org.springframework.context.ApplicationListener
+import org.springframework.context.ConfigurableApplicationContext
+import org.springframework.context.PayloadApplicationEvent
+import org.springframework.context.support.GenericApplicationContext
+import org.springframework.core.env.PropertyResolver
+import spock.lang.Specification
+
+class AbstractDatastoreSpec extends Specification {
+
+ void "test that getApplicationEventPublisher returns the application
context if set"() {
+ given:
+ def mappingContext = Mock(MappingContext)
+ def ctx = new GenericApplicationContext()
+ ctx.refresh()
+ def datastore = new TestDatastore(mappingContext,
(PropertyResolver)null, ctx)
+
+ expect:
+ datastore.applicationEventPublisher == ctx
+ datastore.applicationContext == ctx
+ }
+
+ void "test that SessionCreationEvent is published when connect is
called"() {
+ given:
+ def mappingContext = Mock(MappingContext)
+ def events = []
+ def publisher = [
Review Comment:
`publisher` is assigned here and never used — this feature registers its
listener via `ctx.addApplicationListener` instead. Same dead local in `"test
that getApplicationEventPublisher returns the standalone publisher if set"` at
line 78 (there it *is* used, so only this one is dead). There's also trailing
whitespace on the blank lines around it.
CodeNarc/Checkstyle are disabled for test source sets so nothing will flag
these, which is exactly why they're worth cleaning up by hand.
##########
grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/connections/ConnectionSourceSettingsBuilderSpec.groovy:
##########
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.datastore.mapping.core.connections
+
+import org.grails.datastore.mapping.config.Settings
+import org.grails.datastore.mapping.core.DatastoreUtils
+import jakarta.persistence.FlushModeType
+import spock.lang.Specification
+
+class ConnectionSourceSettingsBuilderSpec extends Specification {
+
+ void "the 3-arg constructor applies a fallBackConfiguration when the
property source has no override"() {
+ given:
+ def config = DatastoreUtils.createPropertyResolver([:])
+ def fallback = new
ConnectionSourceSettings().flushMode(FlushModeType.COMMIT)
Review Comment:
This spec exists to cover a constructor that no production code calls — the
3-arg `ConnectionSourceSettingsBuilder(PropertyResolver, String, Object)` added
in this PR has zero callers in the repo. A test whose only purpose is to
justify otherwise-unused API isn't really coverage.
If `GormRegistry` needs it in (2/3), add the constructor there alongside its
caller. (Note `ConfigurationBuilder` already exposes the 3-arg form via Groovy
default parameters; the subclass ctor is only needed because Groovy doesn't
inherit constructors — so this is purely plumbing for a future consumer.)
##########
grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/model/AbstractPersistentEntityGetTenantIdSpec.groovy:
##########
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.datastore.mapping.model
+
+import org.grails.datastore.mapping.core.connections.ConnectionSourceSettings
+import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
+import spock.lang.Specification
+
+class AbstractPersistentEntityGetTenantIdSpec extends Specification {
+
+ def "getTenantId resolves the TenantId property in DISCRIMINATOR mode"() {
+ given:
+ TestMappingContext context = new TestMappingContext()
+ context.initialize(discriminatorSettings())
+ def entity = context.addPersistentEntity(TenantScopedEntity)
+
+ expect:
+ entity.getTenantId() != null
+ entity.getTenantId().name == 'tenantId'
+ }
+
+ def "getTenantId returns null when multi-tenancy mode is not
DISCRIMINATOR"() {
+ given:
+ TestMappingContext context = new TestMappingContext()
+ def entity = context.addPersistentEntity(TenantScopedEntity)
+
+ expect:
+ context.multiTenancyMode == MultiTenancySettings.MultiTenancyMode.NONE
+ entity.getTenantId() == null
+ }
+
+ def "getTenantId does not throw and returns null when entity
initialization is deferred"() {
+ given:
+ TestMappingContext context = new TestMappingContext()
+ context.initialize(discriminatorSettings())
+ context.setCanInitializeEntities(false)
+ def entity = context.addPersistentEntity(TenantScopedEntity)
+
+ expect:
+ entity.getTenantId() == null
+ }
+
+ def "getTenantId lazily resolves the TenantId property when the context
switches to DISCRIMINATOR mode after the entity was already initialized"() {
+ given: "an entity initialized while the context is still in NONE mode,
so initialize()'s eager loop never assigns tenantId"
+ TestMappingContext context = new TestMappingContext()
+ def entity = context.addPersistentEntity(TenantScopedEntity)
+
+ expect:
+ context.multiTenancyMode == MultiTenancySettings.MultiTenancyMode.NONE
+ entity.getTenantId() == null
+
+ when: "the context is reconfigured into DISCRIMINATOR mode afterwards"
+ context.initialize(discriminatorSettings())
+
+ then: "getTenantId() falls back to a lazy scan of the
already-populated persistentProperties instead of staying stuck at null"
+ entity.getTenantId() != null
+ entity.getTenantId().name == 'tenantId'
+ }
+
+ def "getTenantId's lazy scan completes without a match and returns null
for a multi-tenant entity with no tenantId property"() {
+ given: "initialized in NONE mode - DISCRIMINATOR mode at initialize()
time would reject this class for lacking a tenant identifier property"
+ TestMappingContext context = new TestMappingContext()
+ def entity =
context.addPersistentEntity(TenantScopedEntityWithoutTenantIdProperty)
+
+ when: "the context is reconfigured into DISCRIMINATOR mode afterwards,
same as the successful-lookup case above"
+ context.initialize(discriminatorSettings())
+
+ then: "the lazy scan runs to completion without finding a TenantId
property, rather than throwing or looping forever"
+ entity.getTenantId() == null
+ }
+
+ def "getTenantId's lazy fallback short-circuits on isMultiTenant() for a
non-multi-tenant entity, even in DISCRIMINATOR mode"() {
+ given:
+ TestMappingContext context = new TestMappingContext()
+ context.initialize(discriminatorSettings())
+ def entity = context.addPersistentEntity(NonTenantScopedEntity)
+
+ expect:
+ !entity.isMultiTenant()
+ entity.getTenantId() == null
+ }
+
+ private static ConnectionSourceSettings discriminatorSettings() {
+ ConnectionSourceSettings settings = new ConnectionSourceSettings()
+ settings.multiTenancy.mode =
MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR
+ return settings
+ }
+}
+
+interface MultiTenant {
Review Comment:
This declares a **public top-level
`org.grails.datastore.mapping.model.MultiTenant`** interface, in the same
package as production classes, from a test file — along with three public
top-level entity classes. Any production or test code in that package that
references `MultiTenant` unqualified now silently resolves to this one instead
of `org.grails.datastore.mapping.multitenancy.MultiTenant`. That is a landmine
for whoever touches this package next.
It also only works because `ClassUtils.isMultiTenant(Class)` matches on
`getSimpleName().equals("MultiTenant")` rather than on the actual type — so the
test is passing by exploiting an implementation detail of the very mechanism
it's supposed to be verifying, and would keep passing if that detail were
tightened.
Please implement the real
`org.grails.datastore.mapping.multitenancy.MultiTenant` and make the fixture
classes static nested classes of the spec.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]