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/09/30 15:20:05 UTC
(grails-core) 01/11: Add test coverage for org.grails.datastore.gorm.events (core)
This is an automated email from the ASF dual-hosted git repository.
jdaugherty pushed a commit to branch test/document-datamapping-events
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 3c671da192ab4c965e574a4d004ef72363615538
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Fri Aug 14 10:12:12 2026 -0500
Add test coverage for org.grails.datastore.gorm.events (core)
DomainEventListener, DefaultApplicationEventPublisher, and
ConfigurableApplicationContextEventPublisher had zero test coverage.
Adds mock-based Spock specs driving each class through its public
constructor/method/onApplicationEvent surface only.
Coverage: DomainEventListener 0% -> 92% lines / 91% branches / 96%
methods; DefaultApplicationEventPublisher 0% -> 95% / 81% / 100%;
ConfigurableApplicationContextEventPublisher 0% -> 100% / n/a / 100%.
Three branches are documented as intentionally left uncovered in
DomainEventListenerSpec: invokeEvent's ea==null path is unreachable
via the public API, and its eventMethod.getParameterTypes().length==1
path is confirmed (via decompiling spring-core) structurally dead —
findAndCacheEvent only ever caches zero-argument hook methods, so
event-argument-accepting hooks can never be invoked.
---
...ableApplicationContextEventPublisherSpec.groovy | 45 +-
.../DefaultApplicationEventPublisherSpec.groovy | 163 ++++++
.../gorm/events/DomainEventListenerSpec.groovy | 556 +++++++++++++++++++++
3 files changed, 763 insertions(+), 1 deletion(-)
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisherSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisherSpec.groovy
index 0d0dd4225e..4bf657ada7 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisherSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisherSpec.groovy
@@ -19,14 +19,18 @@
package org.grails.datastore.gorm.events
import org.springframework.context.ApplicationContext
+import org.springframework.context.ApplicationEvent
import org.springframework.context.ApplicationListener
+import org.springframework.context.ConfigurableApplicationContext
+import org.springframework.context.PayloadApplicationEvent
import org.springframework.context.event.ContextRefreshedEvent
import org.springframework.context.support.GenericApplicationContext
import spock.lang.Specification
/**
* Covers the publisher being built by the container rather than handed a context, which is what
- * lets a datastore reference it as a bean definition instead of an already-constructed object.
+ * lets a datastore reference it as a bean definition instead of an already-constructed object,
+ * and that every operation is delegated to whichever context it ends up holding.
*/
class ConfigurableApplicationContextEventPublisherSpec extends Specification {
@@ -112,4 +116,43 @@ class ConfigurableApplicationContextEventPublisherSpec extends Specification {
then:
thrown(IllegalStateException)
}
+
+ void 'addApplicationListener delegates to the wrapped ApplicationContext'() {
+ given:
+ ConfigurableApplicationContext wrapped = Mock(ConfigurableApplicationContext)
+ def publisher = new ConfigurableApplicationContextEventPublisher(wrapped)
+ ApplicationListener listener = Mock(ApplicationListener)
+
+ when:
+ publisher.addApplicationListener(listener)
+
+ then:
+ 1 * wrapped.addApplicationListener(listener)
+ }
+
+ void 'publishEvent(ApplicationEvent) delegates to the wrapped ApplicationContext'() {
+ given:
+ ConfigurableApplicationContext wrapped = Mock(ConfigurableApplicationContext)
+ def publisher = new ConfigurableApplicationContextEventPublisher(wrapped)
+ ApplicationEvent event = new PayloadApplicationEvent<>(this, 'payload')
+
+ when:
+ publisher.publishEvent(event)
+
+ then:
+ 1 * wrapped.publishEvent(event)
+ }
+
+ void 'publishEvent(Object) delegates to the wrapped ApplicationContext as a plain payload'() {
+ given:
+ ConfigurableApplicationContext wrapped = Mock(ConfigurableApplicationContext)
+ def publisher = new ConfigurableApplicationContextEventPublisher(wrapped)
+ Object payload = 'a plain payload'
+
+ when:
+ publisher.publishEvent(payload)
+
+ then:
+ 1 * wrapped.publishEvent(payload)
+ }
}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/DefaultApplicationEventPublisherSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/DefaultApplicationEventPublisherSpec.groovy
new file mode 100644
index 0000000000..2c4e5fc689
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/DefaultApplicationEventPublisherSpec.groovy
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.grails.datastore.gorm.events
+
+import spock.lang.Specification
+
+import org.springframework.context.ApplicationEvent
+import org.springframework.context.ApplicationListener
+import org.springframework.context.PayloadApplicationEvent
+import org.springframework.context.event.SmartApplicationListener
+
+class DefaultApplicationEventPublisherSpec extends Specification {
+
+ DefaultApplicationEventPublisher publisher = new DefaultApplicationEventPublisher()
+
+ void "publishEvent(ApplicationEvent) does nothing when no listeners are registered"() {
+ given:
+ ApplicationEvent event = new PayloadApplicationEvent<>(this, 'payload')
+
+ when:
+ publisher.publishEvent(event)
+
+ then:
+ noExceptionThrown()
+ }
+
+ void "publishEvent(Object) does nothing when no listeners are registered"() {
+ when:
+ publisher.publishEvent('a plain payload')
+
+ then:
+ noExceptionThrown()
+ }
+
+ void "publishEvent notifies a plain ApplicationListener regardless of event or source type"() {
+ given:
+ ApplicationListener listener = Mock(ApplicationListener)
+ publisher.addApplicationListener(listener)
+ ApplicationEvent event = new PayloadApplicationEvent<>(this, 'payload')
+
+ when:
+ publisher.publishEvent(event)
+
+ then:
+ 1 * listener.onApplicationEvent(event)
+ }
+
+ void "publishEvent notifies every registered listener in order"() {
+ given:
+ ApplicationListener first = Mock(ApplicationListener)
+ ApplicationListener second = Mock(ApplicationListener)
+ publisher.addApplicationListener(first)
+ publisher.addApplicationListener(second)
+ ApplicationEvent event = new PayloadApplicationEvent<>(this, 'payload')
+
+ when:
+ publisher.publishEvent(event)
+
+ then:
+ 1 * first.onApplicationEvent(event)
+ 1 * second.onApplicationEvent(event)
+ }
+
+ void "publishEvent notifies a SmartApplicationListener when it supports both the event type and source type"() {
+ given:
+ SmartApplicationListener listener = Mock(SmartApplicationListener) {
+ supportsEventType(_) >> true
+ supportsSourceType(_) >> true
+ }
+ publisher.addApplicationListener(listener)
+ ApplicationEvent event = new PayloadApplicationEvent<>(this, 'payload')
+
+ when:
+ publisher.publishEvent(event)
+
+ then:
+ 1 * listener.onApplicationEvent(event)
+ }
+
+ void "publishEvent skips a SmartApplicationListener that does not support the event type"() {
+ given:
+ SmartApplicationListener listener = Mock(SmartApplicationListener) {
+ supportsEventType(_) >> false
+ }
+ publisher.addApplicationListener(listener)
+ ApplicationEvent event = new PayloadApplicationEvent<>(this, 'payload')
+
+ when:
+ publisher.publishEvent(event)
+
+ then:
+ 0 * listener.onApplicationEvent(_)
+ }
+
+ void "publishEvent skips a SmartApplicationListener that supports the event type but not the source type"() {
+ given:
+ SmartApplicationListener listener = Mock(SmartApplicationListener) {
+ supportsEventType(_) >> true
+ supportsSourceType(_) >> false
+ }
+ publisher.addApplicationListener(listener)
+ ApplicationEvent event = new PayloadApplicationEvent<>(this, 'payload')
+
+ when:
+ publisher.publishEvent(event)
+
+ then:
+ 0 * listener.onApplicationEvent(_)
+ }
+
+ void "publishEvent(Object) wraps the payload in a PayloadApplicationEvent sourced from the publisher itself"() {
+ given:
+ ApplicationListener listener = Mock(ApplicationListener)
+ publisher.addApplicationListener(listener)
+
+ when:
+ publisher.publishEvent('a plain payload')
+
+ then:
+ 1 * listener.onApplicationEvent({ ApplicationEvent e ->
+ e instanceof PayloadApplicationEvent &&
+ e.source.is(publisher) &&
+ e.payload == 'a plain payload'
+ })
+ }
+
+ void "publishEvent(Object) applies the same SmartApplicationListener filtering as publishEvent(ApplicationEvent)"() {
+ given:
+ SmartApplicationListener supported = Mock(SmartApplicationListener) {
+ supportsEventType(_) >> true
+ supportsSourceType(_) >> true
+ }
+ SmartApplicationListener unsupported = Mock(SmartApplicationListener) {
+ supportsEventType(_) >> false
+ }
+ publisher.addApplicationListener(supported)
+ publisher.addApplicationListener(unsupported)
+
+ when:
+ publisher.publishEvent('a plain payload')
+
+ then:
+ 1 * supported.onApplicationEvent(_)
+ 0 * unsupported.onApplicationEvent(_)
+ }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/DomainEventListenerSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/DomainEventListenerSpec.groovy
new file mode 100644
index 0000000000..3ddf2d0118
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/DomainEventListenerSpec.groovy
@@ -0,0 +1,556 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.grails.datastore.gorm.events
+
+import java.sql.Timestamp
+
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import org.springframework.beans.factory.config.AutowireCapableBeanFactory
+import org.springframework.context.ApplicationEvent
+import org.springframework.context.ConfigurableApplicationContext
+import org.springframework.context.PayloadApplicationEvent
+
+import org.grails.datastore.mapping.config.Entity
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.core.connections.ConnectionSourceSettings
+import org.grails.datastore.mapping.core.connections.ConnectionSources
+import org.grails.datastore.mapping.core.connections.ConnectionSourcesProvider
+import org.grails.datastore.mapping.dirty.checking.DirtyCheckable
+import org.grails.datastore.mapping.engine.EntityAccess
+import org.grails.datastore.mapping.engine.event.MergeEvent
+import org.grails.datastore.mapping.engine.event.PersistEvent
+import org.grails.datastore.mapping.engine.event.PostDeleteEvent
+import org.grails.datastore.mapping.engine.event.PostInsertEvent
+import org.grails.datastore.mapping.engine.event.PostLoadEvent
+import org.grails.datastore.mapping.engine.event.PostUpdateEvent
+import org.grails.datastore.mapping.engine.event.PreDeleteEvent
+import org.grails.datastore.mapping.engine.event.PreInsertEvent
+import org.grails.datastore.mapping.engine.event.PreLoadEvent
+import org.grails.datastore.mapping.engine.event.PreUpdateEvent
+import org.grails.datastore.mapping.engine.event.SaveOrUpdateEvent
+import org.grails.datastore.mapping.engine.event.ValidationEvent
+import org.grails.datastore.mapping.model.ClassMapping
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.PersistentProperty
+import org.grails.datastore.mapping.model.config.GormProperties
+
+/**
+ * Note on coverage gaps left deliberately untested:
+ * - {@code invokeEvent}'s {@code ea != null} branch is always true through every public before-
+ * and after-hook method, which never passes a null {@code EntityAccess}; the {@code ea == null}
+ * path is unreachable via the public API.
+ * - {@code invokeEvent}'s {@code eventMethod.getParameterTypes().length == 1} branch can never be
+ * taken: {@code findAndCacheEvent} caches hooks via Spring's {@code ReflectionUtils.findMethod(Class, String)},
+ * which (confirmed via decompiling spring-core) only ever matches zero-argument methods, so a
+ * cached {@code eventMethod} can never have one parameter. Event-argument-accepting hooks appear
+ * to be an unreachable, effectively dead capability.
+ * - The protected {@code DomainEventListener(ConnectionSourcesProvider, MappingContext)}
+ * constructor exists solely for subclassing (e.g. {@code grails.gorm.rx.events.DomainEventListener}),
+ * which is covered by its own module's spec; exercising it here would duplicate that coverage.
+ */
+class DomainEventListenerSpec extends Specification {
+
+ void "registers itself as a mapping context listener and creates event caches for entities present at construction time"() {
+ given:
+ RecordingDomain domain = new RecordingDomain()
+ PersistentEntity entity = entityFor(RecordingDomain)
+ MappingContext mappingContext = Mock(MappingContext) {
+ getPersistentEntities() >> [entity]
+ }
+ Datastore datastore = plainDatastore(mappingContext)
+
+ when:
+ DomainEventListener listener = new DomainEventListener(datastore)
+
+ then:
+ 1 * mappingContext.addMappingContextListener(_)
+
+ when: 'the pre-existing entity\'s hook is invoked'
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> domain }
+ listener.beforeInsert(entity, ea)
+
+ then: 'it fires immediately, proving the cache was created eagerly at construction time'
+ domain.invoked == ['beforeInsert']
+ }
+
+ void "persistentEntityAdded creates event caches for a newly discovered entity"() {
+ given:
+ RecordingDomain domain = new RecordingDomain()
+ PersistentEntity entity = entityFor(RecordingDomain)
+ Datastore datastore = plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] })
+ DomainEventListener listener = new DomainEventListener(datastore)
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> domain }
+
+ expect: 'the hook is not yet wired up before the entity is added'
+ listener.beforeInsert(entity, ea)
+ domain.invoked.isEmpty()
+
+ when:
+ listener.persistentEntityAdded(entity)
+ listener.beforeInsert(entity, ea)
+
+ then:
+ domain.invoked == ['beforeInsert']
+ }
+
+ void "supportsEventType accepts AbstractPersistenceEvent subtypes and rejects unrelated ApplicationEvents"() {
+ given:
+ DomainEventListener listener = new DomainEventListener(plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }))
+
+ expect:
+ listener.supportsEventType(PreInsertEvent)
+ !listener.supportsEventType(PayloadApplicationEvent)
+ }
+
+ void "beforeInsert sets an initial numeric version to 0 when the entity is versioned"() {
+ given:
+ PersistentEntity entity = entityFor(NoHooksDomain, true, Long)
+ DomainEventListener listener = new DomainEventListener(plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }))
+ EntityAccess ea = Mock(EntityAccess) {
+ getEntity() >> new NoHooksDomain()
+ getPersistentEntity() >> entity
+ }
+
+ when:
+ listener.beforeInsert(entity, ea)
+
+ then:
+ 1 * ea.setProperty(GormProperties.VERSION, 0)
+ }
+
+ void "beforeInsert sets an initial java.sql.Timestamp version when the version type is a Timestamp"() {
+ given:
+ PersistentEntity entity = entityFor(NoHooksDomain, true, Timestamp)
+ DomainEventListener listener = new DomainEventListener(plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }))
+ EntityAccess ea = Mock(EntityAccess) {
+ getEntity() >> new NoHooksDomain()
+ getPersistentEntity() >> entity
+ }
+
+ when:
+ listener.beforeInsert(entity, ea)
+
+ then:
+ 1 * ea.setProperty(GormProperties.VERSION, { it instanceof Timestamp })
+ }
+
+ void "beforeInsert sets an initial java.util.Date version when the version type is a plain Date"() {
+ given:
+ PersistentEntity entity = entityFor(NoHooksDomain, true, Date)
+ DomainEventListener listener = new DomainEventListener(plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }))
+ EntityAccess ea = Mock(EntityAccess) {
+ getEntity() >> new NoHooksDomain()
+ getPersistentEntity() >> entity
+ }
+
+ when:
+ listener.beforeInsert(entity, ea)
+
+ then:
+ 1 * ea.setProperty(GormProperties.VERSION, { it.class == Date })
+ }
+
+ void "beforeInsert does not set a version when the version type is neither Number, Timestamp, nor Date"() {
+ given:
+ PersistentEntity entity = entityFor(NoHooksDomain, true, String)
+ DomainEventListener listener = new DomainEventListener(plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }))
+ EntityAccess ea = Mock(EntityAccess) {
+ getEntity() >> new NoHooksDomain()
+ getPersistentEntity() >> entity
+ }
+
+ when:
+ listener.beforeInsert(entity, ea)
+
+ then:
+ 0 * ea.setProperty(*_)
+ }
+
+ void "beforeInsert does not set a version when the entity is not versioned"() {
+ given:
+ PersistentEntity entity = entityFor(NoHooksDomain, false)
+ DomainEventListener listener = new DomainEventListener(plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }))
+ EntityAccess ea = Mock(EntityAccess) { getEntity() >> new NoHooksDomain() }
+
+ when:
+ listener.beforeInsert(entity, ea)
+
+ then:
+ 0 * ea.setProperty(*_)
+ }
+
+ void "beforeInsert returns true without error when the entity was never registered with the listener"() {
+ given:
+ PersistentEntity entity = entityFor(NoHooksDomain)
+ DomainEventListener listener = new DomainEventListener(plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }))
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> new NoHooksDomain() }
+
+ expect:
+ listener.beforeInsert(entity, ea)
+ }
+
+ void "beforeInsert returns true without error when the domain class defines no beforeInsert hook"() {
+ given:
+ PersistentEntity entity = entityFor(NoHooksDomain)
+ DomainEventListener listener = new DomainEventListener(plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }))
+ listener.persistentEntityAdded(entity)
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> new NoHooksDomain() }
+
+ expect:
+ listener.beforeInsert(entity, ea)
+ }
+
+ @Unroll
+ void "the 2-arg #methodName(entity, ea) convenience overload dispatches to the corresponding hook"() {
+ given:
+ RecordingDomain domain = new RecordingDomain()
+ PersistentEntity entity = entityFor(RecordingDomain)
+ DomainEventListener listener = new DomainEventListener(plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }))
+ listener.persistentEntityAdded(entity)
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> domain }
+
+ when:
+ listener."$methodName"(entity, ea)
+
+ then:
+ domain.invoked == [hookName]
+
+ where:
+ methodName | hookName
+ 'beforeInsert' | 'beforeInsert'
+ 'beforeUpdate' | 'beforeUpdate'
+ 'beforeDelete' | 'beforeDelete'
+ 'beforeLoad' | 'beforeLoad'
+ 'afterInsert' | 'afterInsert'
+ 'afterUpdate' | 'afterUpdate'
+ 'afterDelete' | 'afterDelete'
+ 'afterLoad' | 'afterLoad'
+ }
+
+ void "afterInsert activates dirty checking on entities that implement DirtyCheckable"() {
+ given:
+ DirtyCheckableDomain domain = Spy(DirtyCheckableDomain)
+ PersistentEntity entity = entityFor(DirtyCheckableDomain)
+ DomainEventListener listener = new DomainEventListener(plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }))
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> domain }
+
+ when:
+ listener.afterInsert(entity, ea)
+
+ then:
+ 1 * domain.trackChanges()
+ }
+
+ void "afterUpdate re-activates dirty checking on entities that implement DirtyCheckable"() {
+ given:
+ DirtyCheckableDomain domain = Spy(DirtyCheckableDomain)
+ PersistentEntity entity = entityFor(DirtyCheckableDomain)
+ DomainEventListener listener = new DomainEventListener(plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }))
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> domain }
+
+ when:
+ listener.afterUpdate(entity, ea)
+
+ then:
+ 1 * domain.trackChanges()
+ }
+
+ void "afterLoad activates dirty checking on entities that implement DirtyCheckable"() {
+ given:
+ DirtyCheckableDomain domain = Spy(DirtyCheckableDomain)
+ PersistentEntity entity = entityFor(DirtyCheckableDomain)
+ DomainEventListener listener = new DomainEventListener(plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }))
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> domain }
+
+ when:
+ listener.afterLoad(entity, ea)
+
+ then:
+ 1 * domain.trackChanges()
+ }
+
+ void "afterDelete does not activate dirty checking since the entity is no longer trackable"() {
+ given:
+ DirtyCheckableDomain domain = Spy(DirtyCheckableDomain)
+ PersistentEntity entity = entityFor(DirtyCheckableDomain)
+ DomainEventListener listener = new DomainEventListener(plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }))
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> domain }
+
+ when:
+ listener.afterDelete(entity, ea)
+
+ then:
+ 0 * domain.trackChanges()
+ }
+
+ void "afterLoad autowires the entity when the datastore's default connection source is configured to autowire"() {
+ given:
+ NoHooksDomain domain = new NoHooksDomain()
+ PersistentEntity entity = entityFor(NoHooksDomain, false, null, false)
+ AutowireCapableBeanFactory beanFactory = Mock(AutowireCapableBeanFactory)
+ ConfigurableApplicationContext appContext = Stub(ConfigurableApplicationContext) {
+ getAutowireCapableBeanFactory() >> beanFactory
+ }
+ Datastore datastore = connectionAwareDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }, true, appContext)
+ DomainEventListener listener = new DomainEventListener(datastore)
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> domain }
+
+ when:
+ listener.afterLoad(entity, ea)
+
+ then:
+ 1 * beanFactory.autowireBeanProperties(domain, AutowireCapableBeanFactory.AUTOWIRE_BY_NAME, false)
+ }
+
+ void "afterLoad autowires the entity when the entity's own mapping requests autowire even though the datastore default does not"() {
+ given:
+ NoHooksDomain domain = new NoHooksDomain()
+ PersistentEntity entity = entityFor(NoHooksDomain, false, null, true)
+ AutowireCapableBeanFactory beanFactory = Mock(AutowireCapableBeanFactory)
+ ConfigurableApplicationContext appContext = Stub(ConfigurableApplicationContext) {
+ getAutowireCapableBeanFactory() >> beanFactory
+ }
+ Datastore datastore = plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }, appContext)
+ DomainEventListener listener = new DomainEventListener(datastore)
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> domain }
+
+ when:
+ listener.afterLoad(entity, ea)
+
+ then:
+ 1 * beanFactory.autowireBeanProperties(domain, AutowireCapableBeanFactory.AUTOWIRE_BY_NAME, false)
+ }
+
+ void "afterLoad does not autowire the entity when neither the datastore default nor the entity's mapping request it"() {
+ given:
+ NoHooksDomain domain = new NoHooksDomain()
+ PersistentEntity entity = entityFor(NoHooksDomain, false, null, false)
+ AutowireCapableBeanFactory beanFactory = Mock(AutowireCapableBeanFactory)
+ ConfigurableApplicationContext appContext = Stub(ConfigurableApplicationContext) {
+ getAutowireCapableBeanFactory() >> beanFactory
+ }
+ Datastore datastore = plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }, appContext)
+ DomainEventListener listener = new DomainEventListener(datastore)
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> domain }
+
+ when:
+ listener.afterLoad(entity, ea)
+
+ then:
+ 0 * beanFactory.autowireBeanProperties(*_)
+ }
+
+ void "afterLoad requests autowiring without error when the datastore has no ApplicationContext to autowire through"() {
+ given:
+ NoHooksDomain domain = new NoHooksDomain()
+ PersistentEntity entity = entityFor(NoHooksDomain, false, null, false)
+ Datastore datastore = connectionAwareDatastore(Stub(MappingContext) { getPersistentEntities() >> [] }, true, null)
+ DomainEventListener listener = new DomainEventListener(datastore)
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> domain }
+
+ when:
+ listener.afterLoad(entity, ea)
+
+ then:
+ notThrown(NullPointerException)
+ }
+
+ @Unroll
+ void "onApplicationEvent dispatches a #eventType.simpleName to the #hookName hook"() {
+ given:
+ RecordingDomain domain = new RecordingDomain()
+ PersistentEntity entity = entityFor(RecordingDomain)
+ Datastore datastore = plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] })
+ DomainEventListener listener = new DomainEventListener(datastore)
+ listener.persistentEntityAdded(entity)
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> domain }
+ ApplicationEvent event = eventType.newInstance(datastore, entity, ea)
+
+ when:
+ listener.onApplicationEvent(event)
+
+ then:
+ domain.invoked == [hookName]
+
+ where:
+ eventType | hookName
+ PreInsertEvent | 'beforeInsert'
+ PostInsertEvent | 'afterInsert'
+ PreUpdateEvent | 'beforeUpdate'
+ PostUpdateEvent | 'afterUpdate'
+ PreDeleteEvent | 'beforeDelete'
+ PostDeleteEvent | 'afterDelete'
+ PreLoadEvent | 'beforeLoad'
+ PostLoadEvent | 'afterLoad'
+ }
+
+ @Unroll
+ void "onApplicationEvent silently ignores a #eventType.simpleName since domain events define no hook for it"() {
+ given:
+ RecordingDomain domain = new RecordingDomain()
+ PersistentEntity entity = entityFor(RecordingDomain)
+ Datastore datastore = plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] })
+ DomainEventListener listener = new DomainEventListener(datastore)
+ listener.persistentEntityAdded(entity)
+ EntityAccess ea = Stub(EntityAccess) { getEntity() >> domain }
+
+ when:
+ listener.onApplicationEvent(eventType.newInstance(datastore, entity, ea))
+
+ then:
+ noExceptionThrown()
+ domain.invoked.isEmpty()
+
+ where:
+ eventType << [SaveOrUpdateEvent, ValidationEvent, MergeEvent, PersistEvent]
+ }
+
+ @Unroll
+ void "onApplicationEvent cancels a #eventType.simpleName when its before-hook returns false"() {
+ given:
+ CancellingDomain domain = new CancellingDomain()
+ PersistentEntity entity = entityFor(CancellingDomain)
+ Datastore datastore = plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] })
+ DomainEventListener listener = new DomainEventListener(datastore)
+ listener.persistentEntityAdded(entity)
+ EntityAccess ea = Mock(EntityAccess) { getEntity() >> domain }
+ ApplicationEvent event = eventType.newInstance(datastore, entity, ea)
+
+ when:
+ listener.onApplicationEvent(event)
+
+ then:
+ event.cancelled
+ 0 * ea.refresh()
+
+ where:
+ eventType << [PreInsertEvent, PreUpdateEvent, PreDeleteEvent]
+ }
+
+ void "onApplicationEvent refreshes the entity access after a successful beforeInsert hook since beforeInsert is a refresh event"() {
+ given:
+ RecordingDomain domain = new RecordingDomain()
+ PersistentEntity entity = entityFor(RecordingDomain)
+ Datastore datastore = plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] })
+ DomainEventListener listener = new DomainEventListener(datastore)
+ listener.persistentEntityAdded(entity)
+ EntityAccess ea = Mock(EntityAccess) { getEntity() >> domain }
+
+ when:
+ listener.onApplicationEvent(new PreInsertEvent(datastore, entity, ea))
+
+ then:
+ 1 * ea.refresh()
+ }
+
+ void "onApplicationEvent does not refresh the entity access after a successful beforeLoad hook since beforeLoad is not a refresh event"() {
+ given:
+ RecordingDomain domain = new RecordingDomain()
+ PersistentEntity entity = entityFor(RecordingDomain)
+ Datastore datastore = plainDatastore(Stub(MappingContext) { getPersistentEntities() >> [] })
+ DomainEventListener listener = new DomainEventListener(datastore)
+ listener.persistentEntityAdded(entity)
+ EntityAccess ea = Mock(EntityAccess) { getEntity() >> domain }
+
+ when:
+ listener.onApplicationEvent(new PreLoadEvent(datastore, entity, ea))
+
+ then:
+ 0 * ea.refresh()
+ }
+
+ private PersistentEntity entityFor(Class<?> javaClass, boolean versioned = false, Class<?> versionType = null,
+ boolean mappedAutowire = false) {
+ PersistentProperty version = versioned ? Stub(PersistentProperty) { getType() >> versionType } : null
+ ClassMapping mapping = Stub(ClassMapping) {
+ getMappedForm() >> new Entity(autowire: mappedAutowire)
+ }
+ Stub(PersistentEntity) {
+ getJavaClass() >> javaClass
+ isVersioned() >> versioned
+ getVersion() >> version
+ getMapping() >> mapping
+ }
+ }
+
+ private Datastore plainDatastore(MappingContext mappingContext, ConfigurableApplicationContext appContext = null) {
+ Stub(Datastore) {
+ getMappingContext() >> mappingContext
+ getApplicationContext() >> appContext
+ }
+ }
+
+ private Datastore connectionAwareDatastore(MappingContext mappingContext, boolean autowire,
+ ConfigurableApplicationContext appContext = null) {
+ ConnectionSource connectionSource = Stub(ConnectionSource) {
+ getSettings() >> new ConnectionSourceSettings(autowire: autowire)
+ }
+ ConnectionSources connectionSources = Stub(ConnectionSources) {
+ getDefaultConnectionSource() >> connectionSource
+ }
+ Stub(Datastore, additionalInterfaces: [ConnectionSourcesProvider]) {
+ getMappingContext() >> mappingContext
+ getApplicationContext() >> appContext
+ getConnectionSources() >> connectionSources
+ }
+ }
+}
+
+class RecordingDomain {
+
+ List<String> invoked = []
+
+ void beforeInsert() { invoked << 'beforeInsert' }
+
+ void beforeUpdate() { invoked << 'beforeUpdate' }
+
+ void beforeDelete() { invoked << 'beforeDelete' }
+
+ void beforeLoad() { invoked << 'beforeLoad' }
+
+ void afterInsert() { invoked << 'afterInsert' }
+
+ void afterUpdate() { invoked << 'afterUpdate' }
+
+ void afterDelete() { invoked << 'afterDelete' }
+
+ void afterLoad() { invoked << 'afterLoad' }
+}
+
+class CancellingDomain {
+
+ boolean beforeInsert() { false }
+
+ boolean beforeUpdate() { false }
+
+ boolean beforeDelete() { false }
+}
+
+class NoHooksDomain {
+
+}
+
+class DirtyCheckableDomain implements DirtyCheckable {
+
+}