From 9f421cf2c9dbe2833e7281691e7778d4b0795623 Mon Sep 17 00:00:00 2001 From: RAJ-debug858 Date: Thu, 21 Aug 2025 20:14:29 -0700 Subject: [PATCH 1/5] Complete changes for Hibernate session integration test fix (#14920) --- .../en/guide/testing/integrationTesting.adoc | 50 +++++ .../IntegrationSpecConfigurerExtension.groovy | 5 +- .../spock/WithSessionSpecExtension.groovy | 95 ++++++++++ .../test/support/GrailsTestInterceptor.groovy | 26 +++ .../grails/test/support/GrailsTestMode.groovy | 1 + .../GrailsTestSessionInterceptor.groovy | 178 ++++++++++++++++++ .../tests/SessionBindingComparisonSpec.groovy | 120 ++++++++++++ .../tests/WithSessionIntegrationSpec.groovy | 126 +++++++++++++ .../mixin/integration/WithSession.groovy | 48 +++++ .../testing/WithSessionTransformation.groovy | 129 +++++++++++++ 10 files changed, 777 insertions(+), 1 deletion(-) create mode 100644 grails-test-core/src/main/groovy/org/grails/test/spock/WithSessionSpecExtension.groovy create mode 100644 grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestSessionInterceptor.groovy create mode 100644 grails-test-examples/hibernate-integration-tests/src/integration-test/groovy/functional/tests/SessionBindingComparisonSpec.groovy create mode 100644 grails-test-examples/hibernate-integration-tests/src/integration-test/groovy/functional/tests/WithSessionIntegrationSpec.groovy create mode 100644 grails-testing-support-core/src/main/groovy/grails/testing/mixin/integration/WithSession.groovy create mode 100644 grails-testing-support-core/src/main/groovy/org/grails/compiler/injection/testing/WithSessionTransformation.groovy diff --git a/grails-doc/src/en/guide/testing/integrationTesting.adoc b/grails-doc/src/en/guide/testing/integrationTesting.adoc index dd5e6635769..4b845640291 100644 --- a/grails-doc/src/en/guide/testing/integrationTesting.adoc +++ b/grails-doc/src/en/guide/testing/integrationTesting.adoc @@ -137,6 +137,56 @@ NOTE: It isn't possible to make `grails.gorm.transactions.Rollback` behave the s This has the downside that you cannot implement it differently for different cases (as Spring does for testing). +==== Session Binding Without Transactions + +By default, integration tests wrap each test method in a transaction. However, this doesn't always match the runtime behavior of your application. In a running Grails application, the Open Session In View (OISV) pattern provides a Hibernate session for the duration of the request, but operations outside of `@Transactional` methods don't have an active transaction. + +To test code that relies on having a session but not a transaction (matching the application's runtime behavior), you can use the `@WithSession` annotation: + +[source,groovy] +---- +import grails.testing.mixin.integration.Integration +import grails.testing.mixin.integration.WithSession +import spock.lang.* + +@Integration +@WithSession +class ExampleServiceSpec extends Specification { + + // Disable automatic transaction wrapping + static transactional = false + + void "test non-transactional service method"() { + when: "calling a service method that does SELECT operations" + def count = MyDomain.count() // Works with session only + + then: "the operation succeeds" + count >= 0 + + when: "trying to save with flush" + new MyDomain(name: "test").save(flush: true) + + then: "it fails because no transaction is active" + thrown(Exception) // Matches runtime behavior + } +} +---- + +The `@WithSession` annotation: + +* Binds a Hibernate session to the test thread without starting a transaction +* Mimics the OISV pattern used in running applications +* Allows testing of service methods that rely on session availability but are not transactional +* Can be applied at class or method level +* Supports specifying specific datasources: `@WithSession(datasources = ["secondary"])` + +This is particularly useful when: + +* Testing non-transactional service methods that perform read operations +* Ensuring test behavior matches production behavior +* Debugging issues where tests pass but the application fails (or vice versa) due to transaction differences + + ==== DirtiesContext diff --git a/grails-test-core/src/main/groovy/org/grails/test/spock/IntegrationSpecConfigurerExtension.groovy b/grails-test-core/src/main/groovy/org/grails/test/spock/IntegrationSpecConfigurerExtension.groovy index 6b4c18e7742..43492cf45ff 100644 --- a/grails-test-core/src/main/groovy/org/grails/test/spock/IntegrationSpecConfigurerExtension.groovy +++ b/grails-test-core/src/main/groovy/org/grails/test/spock/IntegrationSpecConfigurerExtension.groovy @@ -60,7 +60,10 @@ class IntegrationSpecConfigurerExtension implements IAnnotationDrivenExtension { + + @Override + void visitSpecAnnotation(WithSession annotation, SpecInfo spec) { + final context = Holders.getApplicationContext() + if (context) { + for (FeatureInfo info : spec.getAllFeatures()) { + info.addInterceptor(new WithSessionMethodInterceptor(context, annotation)) + } + } + } + + @Override + void visitFeatureAnnotation(WithSession annotation, FeatureInfo feature) { + final context = Holders.getApplicationContext() + if (context) { + feature.addInterceptor(new WithSessionMethodInterceptor(context, annotation)) + } + } + + @CompileStatic + static class WithSessionMethodInterceptor implements IMethodInterceptor { + private final ApplicationContext applicationContext + private final WithSession annotation + + WithSessionMethodInterceptor(ApplicationContext applicationContext, WithSession annotation) { + this.applicationContext = applicationContext + this.annotation = annotation + } + + @Override + void intercept(IMethodInvocation invocation) { + final instance = invocation.instance ?: invocation.sharedInstance + if (instance && applicationContext) { + GrailsTestSessionInterceptor sessionInterceptor = new GrailsTestSessionInterceptor(applicationContext) + + // Configure datasources from annotation + if (annotation.datasources().length > 0) { + // Create a mock test object with datasources property + def testWrapper = new Expando() + testWrapper.withSession = annotation.datasources() as List + sessionInterceptor.shouldBindSessions(testWrapper) + } else { + // Bind all datasources + sessionInterceptor.shouldBindSessions([withSession: true]) + } + + try { + sessionInterceptor.init() + invocation.proceed() + } finally { + sessionInterceptor.destroy() + } + } else { + invocation.proceed() + } + } + } +} \ No newline at end of file diff --git a/grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestInterceptor.groovy b/grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestInterceptor.groovy index a392bd88c2e..74cfac71959 100644 --- a/grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestInterceptor.groovy +++ b/grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestInterceptor.groovy @@ -30,6 +30,7 @@ class GrailsTestInterceptor { private String[] testClassSuffixes private GrailsTestTransactionInterceptor transactionInterceptor + private GrailsTestSessionInterceptor sessionInterceptor private GrailsTestRequestEnvironmentInterceptor requestEnvironmentInterceptor GrailsTestInterceptor(Object test, GrailsTestMode mode, ApplicationContext appCtx, String[] testClassSuffixes) { @@ -41,12 +42,14 @@ class GrailsTestInterceptor { void init() { autowireIfNecessary() + initSessionIfNecessary() initTransactionIfNecessary() initRequestEnvironmentIfNecessary() } void destroy() { destroyTransactionIfNecessary() + destroySessionIfNecessary() requestEnvironmentInterceptor?.destroy() } @@ -63,6 +66,25 @@ class GrailsTestInterceptor { if (mode.autowire) createAutowirer().autowire(test) } + protected initSessionIfNecessary() { + // Check if we should bind sessions without transactions + // This happens when: + // 1. Not wrapping in transaction (to avoid conflict) + // 2. Either mode.bindSession is true OR test has @WithSession annotation + if (!mode.wrapInTransaction) { + def localSessionInterceptor = createSessionInterceptor() + if (mode.bindSession || localSessionInterceptor.shouldBindSessions(test)) { + sessionInterceptor = localSessionInterceptor + sessionInterceptor.init() + } + } + } + + protected destroySessionIfNecessary() { + sessionInterceptor?.destroy() + sessionInterceptor = null + } + protected initTransactionIfNecessary() { def localTransactionInterceptor = createTransactionInterceptor() if (mode.wrapInTransaction && localTransactionInterceptor.isTransactional(test)) { @@ -101,6 +123,10 @@ class GrailsTestInterceptor { new GrailsTestTransactionInterceptor(appCtx) } + protected GrailsTestSessionInterceptor createSessionInterceptor() { + new GrailsTestSessionInterceptor(appCtx) + } + protected GrailsTestRequestEnvironmentInterceptor createRequestEnvironmentInterceptor() { new GrailsTestRequestEnvironmentInterceptor(appCtx) } diff --git a/grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestMode.groovy b/grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestMode.groovy index 1220d6519ca..4b3ba960529 100644 --- a/grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestMode.groovy +++ b/grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestMode.groovy @@ -24,6 +24,7 @@ class GrailsTestMode { boolean autowire = false boolean wrapInRequestEnvironment = false boolean wrapInTransaction = false + boolean bindSession = false GrailsTestInterceptor createInterceptor(Object test, ApplicationContext appCtx, String[] testClassSuffixes) { new GrailsTestInterceptor(test, this, appCtx, testClassSuffixes) diff --git a/grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestSessionInterceptor.groovy b/grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestSessionInterceptor.groovy new file mode 100644 index 00000000000..ce50c4da4f2 --- /dev/null +++ b/grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestSessionInterceptor.groovy @@ -0,0 +1,178 @@ +/* + * 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.test.support + +import grails.testing.mixin.integration.WithSession +import org.grails.datastore.mapping.core.connections.ConnectionSource +import org.hibernate.FlushMode +import org.hibernate.Session +import org.hibernate.SessionFactory +import org.springframework.context.ApplicationContext +import org.springframework.orm.hibernate5.SessionHolder +import org.springframework.transaction.support.TransactionSynchronizationManager +import groovy.transform.CompileStatic +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * Binds Hibernate sessions to the test thread without starting transactions. + * This mimics the OISV (Open Session In View) pattern used in running applications. + */ +@CompileStatic +class GrailsTestSessionInterceptor { + + private static final Logger LOG = LoggerFactory.getLogger(GrailsTestSessionInterceptor) + private static final String WITH_SESSION = "withSession" + + private final ApplicationContext applicationContext + private final Map sessionFactories = [:] + private final Map sessionHolders = [:] + private final Set datasourcesToBind = [] as Set + + GrailsTestSessionInterceptor(ApplicationContext applicationContext) { + this.applicationContext = applicationContext + initializeSessionFactories() + } + + private void initializeSessionFactories() { + def datasourceNames = [] + + if (applicationContext.containsBean('sessionFactory')) { + datasourceNames << ConnectionSource.DEFAULT + } + + for (name in applicationContext.grailsApplication.config.keySet()) { + if (name.startsWith('dataSource_')) { + datasourceNames << name - 'dataSource_' + } + } + + for (datasourceName in datasourceNames) { + boolean isDefault = datasourceName == ConnectionSource.DEFAULT + String suffix = isDefault ? '' : '_' + datasourceName + String beanName = "sessionFactory$suffix" + + if (applicationContext.containsBean(beanName)) { + sessionFactories[datasourceName] = applicationContext.getBean(beanName, SessionFactory) + } + } + } + + /** + * Determines if sessions should be bound for the given test instance. + */ + boolean shouldBindSessions(Object test) { + if (!test) return false + + // Check for class-level annotation + WithSession classAnnotation = test.class.getAnnotation(WithSession) + if (classAnnotation) { + configureDatasources(classAnnotation) + return true + } + + // Check for property-based configuration + def value = test.hasProperty(WITH_SESSION) ? test[WITH_SESSION] : null + if (value instanceof Boolean && value) { + // Bind sessions for all datasources if withSession = true + datasourcesToBind.addAll(sessionFactories.keySet()) + return true + } else if (value instanceof List) { + // Bind sessions for specific datasources + datasourcesToBind.addAll(value as List) + return true + } + + return false + } + + private void configureDatasources(WithSession annotation) { + if (annotation.datasources().length > 0) { + datasourcesToBind.addAll(annotation.datasources()) + } else { + // If no specific datasources specified, bind all + datasourcesToBind.addAll(sessionFactories.keySet()) + } + } + + /** + * Binds Hibernate sessions without transactions. + */ + void init() { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.initSynchronization() + } + + for (datasourceName in datasourcesToBind) { + SessionFactory sessionFactory = sessionFactories[datasourceName] + if (!sessionFactory) { + LOG.warn("SessionFactory not found for datasource: $datasourceName") + continue + } + + if (TransactionSynchronizationManager.hasResource(sessionFactory)) { + LOG.debug("Session already bound for datasource: $datasourceName") + continue + } + + Session session = sessionFactory.openSession() + // Set flush mode to MANUAL to match OISV behavior + session.hibernateFlushMode = FlushMode.MANUAL + + SessionHolder holder = new SessionHolder(session) + TransactionSynchronizationManager.bindResource(sessionFactory, holder) + sessionHolders[datasourceName] = holder + + LOG.debug("Bound Hibernate session for datasource: $datasourceName") + } + } + + /** + * Unbinds and closes the sessions. + */ + void destroy() { + for (Map.Entry entry : sessionHolders.entrySet()) { + String datasourceName = entry.key + SessionHolder holder = entry.value + SessionFactory sessionFactory = sessionFactories[datasourceName] + + if (sessionFactory && TransactionSynchronizationManager.hasResource(sessionFactory)) { + TransactionSynchronizationManager.unbindResource(sessionFactory) + + try { + Session session = holder.session + if (session?.isOpen()) { + session.close() + } + LOG.debug("Closed Hibernate session for datasource: $datasourceName") + } catch (Exception e) { + LOG.error("Error closing Hibernate session for datasource: $datasourceName", e) + } + } + } + + sessionHolders.clear() + datasourcesToBind.clear() + + if (TransactionSynchronizationManager.isSynchronizationActive() && + TransactionSynchronizationManager.resourceMap.isEmpty()) { + TransactionSynchronizationManager.clearSynchronization() + } + } +} \ No newline at end of file diff --git a/grails-test-examples/hibernate-integration-tests/src/integration-test/groovy/functional/tests/SessionBindingComparisonSpec.groovy b/grails-test-examples/hibernate-integration-tests/src/integration-test/groovy/functional/tests/SessionBindingComparisonSpec.groovy new file mode 100644 index 00000000000..e9ad91670c1 --- /dev/null +++ b/grails-test-examples/hibernate-integration-tests/src/integration-test/groovy/functional/tests/SessionBindingComparisonSpec.groovy @@ -0,0 +1,120 @@ +/* + * 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 functional.tests + +import grails.testing.mixin.integration.Integration +import grails.testing.mixin.integration.WithSession +import grails.gorm.transactions.Rollback +import org.hibernate.SessionFactory +import spock.lang.Specification +import spock.lang.Ignore +import org.springframework.beans.factory.annotation.Autowired + +/** + * Comparison tests showing different session/transaction binding behaviors. + * This demonstrates the problem described in the issue and how @WithSession solves it. + */ +@Integration +class SessionBindingComparisonSpec extends Specification { + + @Autowired + SessionFactory sessionFactory + + // Test 1: Without @Rollback and without @WithSession + // This test will fail with "No HibernateSession bound to thread" + @Ignore("Expected to fail - demonstrates the problem") + void "test without rollback and without session binding fails"() { + given: + static transactional = false + + when: "Try to perform a SELECT operation" + TestComparisonDomain.count() + + then: "Fails with No HibernateSession bound to thread" + thrown(IllegalStateException) + } + + // Test 2: With @Rollback - provides both session and transaction + @Rollback + void "test with rollback provides session and transaction"() { + when: "Perform operations" + def count = TestComparisonDomain.count() + def domain = new TestComparisonDomain(name: "With Rollback") + domain.save(flush: true) // This works because transaction is active + + then: "Both operations succeed" + count >= 0 + domain.id != null + sessionFactory.currentSession != null + sessionFactory.currentSession.transaction.active + } + + // Test 3: With @WithSession - provides session only, no transaction + @WithSession + void "test with session annotation provides session without transaction"() { + given: + static transactional = false + + when: "Perform SELECT operation" + def count = TestComparisonDomain.count() + + then: "SELECT works with session only" + count >= 0 + sessionFactory.currentSession != null + !sessionFactory.currentSession.transaction.active + + when: "Try save without flush" + def domain = new TestComparisonDomain(name: "Session Only") + domain.save() + + then: "Save without flush works" + domain.id != null + + when: "Try save with flush" + domain.save(flush: true) + + then: "Save with flush fails without transaction" + thrown(Exception) + } + + // Test 4: Method-level @WithSession annotation + void "test method level session annotation"() { + given: + static transactional = false + + expect: "This specific test method has session bound" + withSessionMethod() + } + + @WithSession + private boolean withSessionMethod() { + TestComparisonDomain.count() >= 0 + sessionFactory.currentSession != null + !sessionFactory.currentSession.transaction.active + } +} + +// Test domain class for comparison tests +class TestComparisonDomain { + String name + + static constraints = { + name nullable: false + } +} \ No newline at end of file diff --git a/grails-test-examples/hibernate-integration-tests/src/integration-test/groovy/functional/tests/WithSessionIntegrationSpec.groovy b/grails-test-examples/hibernate-integration-tests/src/integration-test/groovy/functional/tests/WithSessionIntegrationSpec.groovy new file mode 100644 index 00000000000..6d206ea7822 --- /dev/null +++ b/grails-test-examples/hibernate-integration-tests/src/integration-test/groovy/functional/tests/WithSessionIntegrationSpec.groovy @@ -0,0 +1,126 @@ +/* + * 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 functional.tests + +import grails.testing.mixin.integration.Integration +import grails.testing.mixin.integration.WithSession +import grails.gorm.transactions.Transactional +import org.hibernate.SessionFactory +import spock.lang.Specification +import org.springframework.beans.factory.annotation.Autowired + +/** + * Test demonstrating the @WithSession annotation functionality. + * This test class shows how to have a Hibernate session bound without a transaction, + * matching the runtime OISV behavior. + */ +@Integration +@WithSession +class WithSessionIntegrationSpec extends Specification { + + @Autowired + SessionFactory sessionFactory + + @Autowired + TestService testService + + // Set transactional to false to prevent automatic transaction wrapping + static transactional = false + + void "test that session is bound without transaction"() { + given: "A test domain object" + def testDomain = new TestDomain(name: "Test Session Binding") + + when: "We perform a SELECT operation (which requires session but not transaction)" + def count = TestDomain.count() + + then: "The operation succeeds because a session is bound" + count >= 0 + sessionFactory.currentSession != null + !sessionFactory.currentSession.transaction.active + } + + void "test that save without flush works with session only"() { + given: "A domain object" + def testDomain = new TestDomain(name: "Session Only Save") + + when: "We save without flush (no transaction required)" + testDomain.save() + + then: "Save succeeds because session is available" + testDomain.id != null + !sessionFactory.currentSession.transaction.active + } + + void "test that save with flush fails without transaction"() { + given: "A domain object" + def testDomain = new TestDomain(name: "Flush Test") + + when: "We try to save with flush (requires transaction)" + testDomain.save(flush: true) + + then: "An exception is thrown because no transaction is active" + thrown(Exception) + } + + void "test service method without @Transactional behaves correctly"() { + when: "Calling a non-transactional service method that does SELECT" + def result = testService.performNonTransactionalRead() + + then: "The method succeeds because session is bound" + result != null + !sessionFactory.currentSession.transaction.active + } + + void "test service method with @Transactional creates transaction"() { + when: "Calling a transactional service method" + def result = testService.performTransactionalOperation() + + then: "The method runs in a transaction" + result != null + // Transaction will be committed after method completes + } +} + +// Test domain class +class TestDomain { + String name + + static constraints = { + name nullable: false + } +} + +// Test service class +@grails.gorm.services.Service(TestDomain) +abstract class TestService { + + // Non-transactional method - relies on session from OISV + def performNonTransactionalRead() { + return TestDomain.count() + } + + // Transactional method - creates its own transaction + @Transactional + def performTransactionalOperation() { + def domain = new TestDomain(name: "Transactional Save") + domain.save(flush: true) + return domain + } +} \ No newline at end of file diff --git a/grails-testing-support-core/src/main/groovy/grails/testing/mixin/integration/WithSession.groovy b/grails-testing-support-core/src/main/groovy/grails/testing/mixin/integration/WithSession.groovy new file mode 100644 index 00000000000..e594c7a6ac3 --- /dev/null +++ b/grails-testing-support-core/src/main/groovy/grails/testing/mixin/integration/WithSession.groovy @@ -0,0 +1,48 @@ +/* + * 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 grails.testing.mixin.integration + +import org.codehaus.groovy.transform.GroovyASTTransformationClass +import java.lang.annotation.ElementType +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import java.lang.annotation.Target + +/** + * Annotation to bind a Hibernate session to the test thread without starting a transaction. + * This mimics the application runtime behavior where OISV (Open Session In View) provides + * a session but not a transaction. + * + * Use this annotation when you want to test service methods that rely on having a session + * but are not transactional themselves. This ensures test behavior matches runtime behavior. + * + * @author Grails Team + * @since 7.1 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target([ElementType.TYPE, ElementType.METHOD]) +@GroovyASTTransformationClass("org.grails.compiler.injection.testing.WithSessionTransformation") +public @interface WithSession { + + /** + * The datasource names to bind sessions for. + * If empty, sessions will be bound for all configured datasources. + */ + String[] datasources() default [] +} \ No newline at end of file diff --git a/grails-testing-support-core/src/main/groovy/org/grails/compiler/injection/testing/WithSessionTransformation.groovy b/grails-testing-support-core/src/main/groovy/org/grails/compiler/injection/testing/WithSessionTransformation.groovy new file mode 100644 index 00000000000..18e600fe8f9 --- /dev/null +++ b/grails-testing-support-core/src/main/groovy/org/grails/compiler/injection/testing/WithSessionTransformation.groovy @@ -0,0 +1,129 @@ +/* + * 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.compiler.injection.testing + +import grails.testing.mixin.integration.WithSession +import groovy.transform.CompileStatic +import org.codehaus.groovy.ast.* +import org.codehaus.groovy.ast.expr.* +import org.codehaus.groovy.control.CompilePhase +import org.codehaus.groovy.control.SourceUnit +import org.codehaus.groovy.transform.ASTTransformation +import org.codehaus.groovy.transform.GroovyASTTransformation +import org.grails.test.spock.WithSessionSpecExtension + +/** + * AST transformation for @WithSession annotation. + * Registers the Spock extension for handling session binding. + * + * @author Grails Team + * @since 7.1 + */ +@CompileStatic +@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION) +class WithSessionTransformation implements ASTTransformation { + + static final ClassNode MY_TYPE = new ClassNode(WithSession) + private static final String SPEC_CLASS = "spock.lang.Specification" + + @Override + void visit(ASTNode[] nodes, SourceUnit source) { + if (!(nodes[0] instanceof AnnotationNode) || !(nodes[1] instanceof AnnotatedNode)) { + throw new RuntimeException("Internal error: wrong types: ${nodes[0].getClass()} / ${nodes[1].getClass()}") + } + + AnnotatedNode parent = (AnnotatedNode) nodes[1] + AnnotationNode annotationNode = (AnnotationNode) nodes[0] + + if (MY_TYPE != annotationNode.classNode) { + return + } + + if (parent instanceof ClassNode) { + ClassNode classNode = (ClassNode) parent + + // For Spock specifications, the extension will handle it + if (isSubclassOf(classNode, SPEC_CLASS)) { + // Extension is registered via META-INF/services + return + } + + // For JUnit tests, add a property to signal session binding + addWithSessionProperty(classNode, annotationNode) + } else if (parent instanceof MethodNode) { + // For method-level annotations, we need to handle differently + // This would require more complex transformation + // For now, method-level annotations are handled by the Spock extension + } + } + + private void addWithSessionProperty(ClassNode classNode, AnnotationNode annotationNode) { + // Extract datasources from annotation + Expression datasourcesExpr = annotationNode.getMember("datasources") + + if (datasourcesExpr instanceof ListExpression) { + ListExpression listExpr = (ListExpression) datasourcesExpr + List datasources = [] + + for (Expression expr : listExpr.expressions) { + if (expr instanceof ConstantExpression) { + datasources << expr.value.toString() + } + } + + // Add a static property for the datasources + if (datasources) { + classNode.addProperty( + "withSession", + Modifier.STATIC | Modifier.PUBLIC, + ClassHelper.make(List), + new ListExpression(datasources.collect { new ConstantExpression(it) }) + ) + } else { + // Empty list means all datasources + classNode.addProperty( + "withSession", + Modifier.STATIC | Modifier.PUBLIC, + ClassHelper.BOOLEAN_TYPE, + new ConstantExpression(true) + ) + } + } else { + // No datasources specified means all datasources + classNode.addProperty( + "withSession", + Modifier.STATIC | Modifier.PUBLIC, + ClassHelper.BOOLEAN_TYPE, + new ConstantExpression(true) + ) + } + } + + private boolean isSubclassOf(ClassNode classNode, String superClassName) { + if (classNode == null) { + return false + } + + if (classNode.name == superClassName) { + return true + } + + return isSubclassOf(classNode.superClass, superClassName) + } +} \ No newline at end of file From a4f0e49ef5079a22ab32930bd6ddc369d78232b7 Mon Sep 17 00:00:00 2001 From: RAJ-debug858 Date: Fri, 22 Aug 2025 07:26:08 -0700 Subject: [PATCH 2/5] Fix Hibernate session integration test dependencies and file moves --- grails-test-core/build.gradle | 1 + grails-testing-support-core/build.gradle | 3 ++- .../spock/WithSessionSpecExtension.groovy | 10 +++++----- .../GrailsTestSessionInterceptor.groovy | 19 ++++++++++++++----- .../junit4/GrailsTestConfiguration.java | 4 ++-- ...amework.runtime.extension.IGlobalExtension | 2 +- 6 files changed, 25 insertions(+), 14 deletions(-) rename {grails-test-core => grails-testing-support-core}/src/main/groovy/org/grails/test/spock/WithSessionSpecExtension.groovy (90%) rename {grails-test-core => grails-testing-support-core}/src/main/groovy/org/grails/test/support/GrailsTestSessionInterceptor.groovy (90%) diff --git a/grails-test-core/build.gradle b/grails-test-core/build.gradle index 14b625c5e31..1e73ddc597f 100644 --- a/grails-test-core/build.gradle +++ b/grails-test-core/build.gradle @@ -31,6 +31,7 @@ dependencies { implementation platform(project(':grails-bom')) api 'org.springframework:spring-tx' + api 'org.springframework.boot:spring-boot-test' // Testing diff --git a/grails-testing-support-core/build.gradle b/grails-testing-support-core/build.gradle index e5089181875..e4829c0879b 100644 --- a/grails-testing-support-core/build.gradle +++ b/grails-testing-support-core/build.gradle @@ -47,7 +47,8 @@ dependencies { api project(':grails-databinding') api project(':grails-datamapping-core') - api project(':grails-test-core') + api project(':grails-core') + api('org.spockframework:spock-core') { transitive = false } api 'org.springframework.boot:spring-boot-test' api('org.spockframework:spock-spring') { transitive = false } api 'org.junit.jupiter:junit-jupiter-api' diff --git a/grails-test-core/src/main/groovy/org/grails/test/spock/WithSessionSpecExtension.groovy b/grails-testing-support-core/src/main/groovy/org/grails/test/spock/WithSessionSpecExtension.groovy similarity index 90% rename from grails-test-core/src/main/groovy/org/grails/test/spock/WithSessionSpecExtension.groovy rename to grails-testing-support-core/src/main/groovy/org/grails/test/spock/WithSessionSpecExtension.groovy index 88cb22a60ac..cc9901e6931 100644 --- a/grails-test-core/src/main/groovy/org/grails/test/spock/WithSessionSpecExtension.groovy +++ b/grails-testing-support-core/src/main/groovy/org/grails/test/spock/WithSessionSpecExtension.groovy @@ -72,13 +72,13 @@ class WithSessionSpecExtension implements IAnnotationDrivenExtension 0) { - // Create a mock test object with datasources property - def testWrapper = new Expando() - testWrapper.withSession = annotation.datasources() as List - sessionInterceptor.shouldBindSessions(testWrapper) + // Create a simple map with datasources list + def testConfig = [withSession: annotation.datasources() as List] + sessionInterceptor.shouldBindSessions(testConfig) } else { // Bind all datasources - sessionInterceptor.shouldBindSessions([withSession: true]) + def testConfig = [withSession: true] + sessionInterceptor.shouldBindSessions(testConfig) } try { diff --git a/grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestSessionInterceptor.groovy b/grails-testing-support-core/src/main/groovy/org/grails/test/support/GrailsTestSessionInterceptor.groovy similarity index 90% rename from grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestSessionInterceptor.groovy rename to grails-testing-support-core/src/main/groovy/org/grails/test/support/GrailsTestSessionInterceptor.groovy index ce50c4da4f2..a7a14bbb453 100644 --- a/grails-test-core/src/main/groovy/org/grails/test/support/GrailsTestSessionInterceptor.groovy +++ b/grails-testing-support-core/src/main/groovy/org/grails/test/support/GrailsTestSessionInterceptor.groovy @@ -57,9 +57,12 @@ class GrailsTestSessionInterceptor { datasourceNames << ConnectionSource.DEFAULT } - for (name in applicationContext.grailsApplication.config.keySet()) { - if (name.startsWith('dataSource_')) { - datasourceNames << name - 'dataSource_' + // Check for additional datasources by looking for sessionFactory beans + String[] beanNames = applicationContext.getBeanNamesForType(SessionFactory) + for (String beanName : beanNames) { + if (beanName.startsWith('sessionFactory_')) { + String datasourceName = beanName.substring('sessionFactory_'.length()) + datasourceNames << datasourceName } } @@ -88,7 +91,13 @@ class GrailsTestSessionInterceptor { } // Check for property-based configuration - def value = test.hasProperty(WITH_SESSION) ? test[WITH_SESSION] : null + def value = null + if (test instanceof Map) { + value = test[WITH_SESSION] + } else if (test.metaClass.hasProperty(test, WITH_SESSION)) { + value = test[WITH_SESSION] + } + if (value instanceof Boolean && value) { // Bind sessions for all datasources if withSession = true datasourcesToBind.addAll(sessionFactories.keySet()) @@ -133,7 +142,7 @@ class GrailsTestSessionInterceptor { Session session = sessionFactory.openSession() // Set flush mode to MANUAL to match OISV behavior - session.hibernateFlushMode = FlushMode.MANUAL + session.flushMode = FlushMode.MANUAL SessionHolder holder = new SessionHolder(session) TransactionSynchronizationManager.bindResource(sessionFactory, holder) diff --git a/grails-testing-support-core/src/main/groovy/org/grails/testing/context/junit4/GrailsTestConfiguration.java b/grails-testing-support-core/src/main/groovy/org/grails/testing/context/junit4/GrailsTestConfiguration.java index 35030542e73..9c633336de1 100644 --- a/grails-testing-support-core/src/main/groovy/org/grails/testing/context/junit4/GrailsTestConfiguration.java +++ b/grails-testing-support-core/src/main/groovy/org/grails/testing/context/junit4/GrailsTestConfiguration.java @@ -18,7 +18,7 @@ */ package org.grails.testing.context.junit4; -import grails.boot.test.GrailsApplicationContextLoader; +import org.springframework.boot.test.context.SpringBootContextLoader; import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.test.context.ContextConfiguration; @@ -29,7 +29,7 @@ * @author Graeme Rocher * @since 3.0 */ -@ContextConfiguration(loader = GrailsApplicationContextLoader.class) +@ContextConfiguration(loader = SpringBootContextLoader.class) @Documented @Inherited @Retention(RetentionPolicy.RUNTIME) diff --git a/grails-testing-support-core/src/main/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension b/grails-testing-support-core/src/main/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension index 41355544117..0b928bb6d3b 100644 --- a/grails-testing-support-core/src/main/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension +++ b/grails-testing-support-core/src/main/resources/META-INF/services/org.spockframework.runtime.extension.IGlobalExtension @@ -1 +1 @@ -org.grails.testing.spock.TestingSupportExtension +org.grails.test.spock.WithSessionSpecExtension \ No newline at end of file From 96837fc4fd53f3a5d829ecd68600d04ae0577cb0 Mon Sep 17 00:00:00 2001 From: RAJ-debug858 Date: Fri, 22 Aug 2025 07:38:11 -0700 Subject: [PATCH 3/5] Restore grails-test-core dependency in grails-testing-support-core --- grails-testing-support-core/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grails-testing-support-core/build.gradle b/grails-testing-support-core/build.gradle index e4829c0879b..c07e0cc728f 100644 --- a/grails-testing-support-core/build.gradle +++ b/grails-testing-support-core/build.gradle @@ -47,7 +47,7 @@ dependencies { api project(':grails-databinding') api project(':grails-datamapping-core') - api project(':grails-core') + api project(':grails-test-core') api('org.spockframework:spock-core') { transitive = false } api 'org.springframework.boot:spring-boot-test' api('org.spockframework:spock-spring') { transitive = false } From f0adc354f002687ca0babf6cadabd2e5de52763b Mon Sep 17 00:00:00 2001 From: RAJ-debug858 Date: Fri, 22 Aug 2025 07:41:50 -0700 Subject: [PATCH 4/5] Clean up build.gradle formatting --- grails-test-core/build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/grails-test-core/build.gradle b/grails-test-core/build.gradle index 1e73ddc597f..14b625c5e31 100644 --- a/grails-test-core/build.gradle +++ b/grails-test-core/build.gradle @@ -31,7 +31,6 @@ dependencies { implementation platform(project(':grails-bom')) api 'org.springframework:spring-tx' - api 'org.springframework.boot:spring-boot-test' // Testing From 52ed702d24a24780c96b7195cd3564aafd3fcfdd Mon Sep 17 00:00:00 2001 From: RAJ-debug858 Date: Fri, 22 Aug 2025 07:56:44 -0700 Subject: [PATCH 5/5] use GrailsApplicationContextLoader instead of SpringBootContextLoader --- .../testing/context/junit4/GrailsTestConfiguration.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/grails-testing-support-core/src/main/groovy/org/grails/testing/context/junit4/GrailsTestConfiguration.java b/grails-testing-support-core/src/main/groovy/org/grails/testing/context/junit4/GrailsTestConfiguration.java index 9c633336de1..35030542e73 100644 --- a/grails-testing-support-core/src/main/groovy/org/grails/testing/context/junit4/GrailsTestConfiguration.java +++ b/grails-testing-support-core/src/main/groovy/org/grails/testing/context/junit4/GrailsTestConfiguration.java @@ -18,7 +18,7 @@ */ package org.grails.testing.context.junit4; -import org.springframework.boot.test.context.SpringBootContextLoader; +import grails.boot.test.GrailsApplicationContextLoader; import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.test.context.ContextConfiguration; @@ -29,7 +29,7 @@ * @author Graeme Rocher * @since 3.0 */ -@ContextConfiguration(loader = SpringBootContextLoader.class) +@ContextConfiguration(loader = GrailsApplicationContextLoader.class) @Documented @Inherited @Retention(RetentionPolicy.RUNTIME)