This is an automated email from the ASF dual-hosted git repository. matrei pushed a commit to branch database-cleanup-feature-feedback in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 588eab185a2439216bef146e70719cd088afa0eb Author: Mattias Reichel <[email protected]> AuthorDate: Wed Feb 25 15:14:30 2026 +0100 style: groovy cleanup --- .../cleanup/core/ApplicationContextResolver.groovy | 1 - .../testing/cleanup/core/DatabaseCleaner.groovy | 35 ++++- .../testing/cleanup/core/DatabaseCleanup.groovy | 3 +- .../cleanup/core/DatabaseCleanupContext.groovy | 149 +++++++++++++-------- .../cleanup/core/DatabaseCleanupExtension.groovy | 82 ++++++------ .../cleanup/core/DatabaseCleanupInterceptor.groovy | 73 ++++++---- .../cleanup/core/DatabaseCleanupStats.groovy | 16 +-- .../cleanup/core/DatasourceCleanupMapping.groovy | 68 +++++----- .../core/DefaultApplicationContextResolver.groovy | 11 +- .../cleanup/core/TestContextHolderListener.groovy | 1 - .../testing/cleanup/h2/H2DatabaseCleaner.groovy | 60 +++------ .../cleanup/h2/H2DatabaseCleanupHelper.groovy | 97 +++++--------- .../postgresql/PostgresDatabaseCleaner.groovy | 90 ++++--------- .../PostgresDatabaseCleanupHelper.groovy | 74 ++++------ 14 files changed, 363 insertions(+), 397 deletions(-) diff --git a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/ApplicationContextResolver.groovy b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/ApplicationContextResolver.groovy index d367da4e2c..45fff9d4a0 100644 --- a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/ApplicationContextResolver.groovy +++ b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/ApplicationContextResolver.groovy @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.core import groovy.transform.CompileStatic diff --git a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleaner.groovy b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleaner.groovy index 3b35214b24..5329039153 100644 --- a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleaner.groovy +++ b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleaner.groovy @@ -16,14 +16,15 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.core import javax.sql.DataSource import groovy.transform.CompileStatic +import org.codehaus.groovy.runtime.InvokerHelper import org.springframework.context.ApplicationContext +import org.springframework.util.ClassUtils /** * SPI interface for database-specific cleanup implementations. @@ -37,16 +38,16 @@ import org.springframework.context.ApplicationContext * {@code META-INF/services/org.apache.grails.testing.cleanup.core.DatabaseCleaner}.</p> */ @CompileStatic -interface DatabaseCleaner { +trait DatabaseCleaner { /** * Returns the database type that this cleaner supports (e.g., {@code "h2"}, {@code "mysql"}, - * {@code "postgresql"}). This value is used to match cleaners to datasources at runtime and + * {@code "postgresql"}). This value is used to match cleaners to data sources at runtime and * must be unique across all {@link DatabaseCleaner} implementations on the classpath. * * @return a non-null, non-empty string identifying the database type */ - String databaseType() + abstract String databaseType() /** * Returns {@code true} if this cleaner supports the given {@link DataSource}. @@ -55,7 +56,7 @@ interface DatabaseCleaner { * @param dataSource the datasource to check * @return {@code true} if this cleaner can handle the given datasource */ - boolean supports(DataSource dataSource) + abstract boolean supports(DataSource dataSource) /** * Performs the cleanup (truncation) of all tables in the given datasource. @@ -64,5 +65,27 @@ interface DatabaseCleaner { * @param dataSource the datasource to clean * @return statistics about what was cleaned */ - DatabaseCleanupStats cleanup(ApplicationContext applicationContext, DataSource dataSource) + abstract DatabaseCleanupStats cleanup(ApplicationContext applicationContext, DataSource dataSource) + + /** + * Optional callback to evict the Hibernate second-level cache after database cleanup. + * This is necessary for tests that rely on Hibernate's caching behavior and want to + * ensure a clean state after truncation. Implementations can check for the presence + * of Hibernate and evict the cache if applicable. + * + * @param applicationContext the application context + */ + void cleanupCacheLayer(ApplicationContext applicationContext) { + // Clear the 2nd layer cache if it exists + if (ClassUtils.isPresent('org.hibernate.SessionFactory', this.getClass().classLoader)) { + def sessionFactory = applicationContext.getBean( + 'sessionFactory', + Class.forName('org.hibernate.SessionFactory') + ) + def cache = InvokerHelper.getProperty(sessionFactory, 'cache') + if (cache) { + InvokerHelper.invokeMethod(cache, 'evictAllRegions', null) + } + } + } } diff --git a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanup.groovy b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanup.groovy index ee59e42d11..3163daac31 100644 --- a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanup.groovy +++ b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanup.groovy @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.core import java.lang.annotation.ElementType @@ -85,7 +84,7 @@ import java.lang.annotation.Target /** * The datasource entries to clean up. Each entry can be a plain datasource bean name * (e.g., {@code "dataSource"}) or a datasource-to-type mapping - * (e.g., {@code "dataSource:h2"}). If empty (the default), all datasources found + * (e.g., {@code "dataSource:h2"}). If empty (the default), all data sources found * in the application context will be cleaned using auto-discovery. * * @return an array of datasource entries diff --git a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupContext.groovy b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupContext.groovy index 13aa4933e6..10fdba73dd 100644 --- a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupContext.groovy +++ b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupContext.groovy @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.core import javax.sql.DataSource @@ -28,7 +27,7 @@ import org.springframework.context.ApplicationContext /** * Context that holds the discovered {@link DatabaseCleaner} implementations and - * provides access to the application's datasources for cleanup operations. + * provides access to the application's data sources for cleanup operations. * * <p>Multiple cleaners can be registered (one per database type). When performing cleanup, * the context matches each datasource to an appropriate cleaner using either an explicit @@ -41,31 +40,39 @@ import org.springframework.context.ApplicationContext class DatabaseCleanupContext { private ApplicationContext applicationContext - final Map<String, DatabaseCleaner> cleanersByType + private final Map<String, DatabaseCleaner> cleanersByType DatabaseCleanupContext(List<DatabaseCleaner> cleaners) { cleanersByType = createCleanersMap(cleaners) } private static Map<String, DatabaseCleaner> createCleanersMap(List<DatabaseCleaner> cleaners) { - Map<String, DatabaseCleaner> typeMap = [:] - for (DatabaseCleaner cleaner : cleaners) { - def type = cleaner.databaseType() - if (!type || type.trim().isEmpty()) { + def typeMap = [:] as Map<String, DatabaseCleaner> + cleaners.each { + def type = it.databaseType()?.trim() + if (!type) { throw new IllegalStateException( - "DatabaseCleaner implementation ${cleaner.class.name} returned a null or empty databaseType()" as String) + "DatabaseCleaner implementation $it.class.name " + + 'returned a null or empty databaseType()' + ) } - - DatabaseCleaner existing = typeMap.get(type) + def existing = typeMap[type] if (existing) { throw new IllegalStateException( - "Duplicate databaseType '${type}' declared by both ${existing.class.name} and ${cleaner.class.name}. Each DatabaseCleaner must declare a unique databaseType." as String) + "Duplicate databaseType '$type' declared by both " + + "$existing.class.name and $it.class.name. " + + 'Each DatabaseCleaner must declare a unique databaseType.' + ) } - typeMap.put(type, cleaner) - log.debug('Discovered DatabaseCleaner implementation: {} (type: {})', cleaner.class.name, type) + typeMap[type] = it + log.debug( + 'Discovered DatabaseCleaner implementation: {} (type: {})', + it.class.name, + type + ) } - Collections.unmodifiableMap(typeMap) + typeMap.asImmutable() } /** @@ -84,14 +91,14 @@ class DatabaseCleanupContext { } /** - * Performs cleanup on datasources found in the application context using the + * Performs cleanup on data sources found in the application context using the * provided mapping from the {@link DatabaseCleanup} annotation. * - * <p>If the mapping specifies explicit database types for datasources, those types + * <p>If the mapping specifies explicit database types for data sources, those types * are used to look up the cleaner directly. Otherwise, auto-discovery via * {@link DatabaseCleaner#supports(DataSource)} is used.</p> * - * @param mapping the parsed annotation value describing which datasources to clean + * @param mapping the parsed annotation value describing which data sources to clean * and optionally which cleaner types to use * @return a list of {@link DatabaseCleanupStats}, one per datasource cleaned * @throws IllegalStateException if a datasource marked for cleanup has no @@ -101,63 +108,100 @@ class DatabaseCleanupContext { */ List<DatabaseCleanupStats> performCleanup(DatasourceCleanupMapping mapping) { if (!applicationContext) { - throw new IllegalStateException('Cannot perform database cleanup: ApplicationContext is not available') + throw new IllegalStateException( + 'Cannot perform database cleanup: ApplicationContext is not available' + ) } if (!cleanersByType) { - throw new IllegalStateException('Cannot perform database cleanup: no DatabaseCleaner implementations found') + throw new IllegalStateException( + 'Cannot perform database cleanup: no DatabaseCleaner implementations found' + ) } - Map<String, DataSource> allDataSources = applicationContext.getBeansOfType(DataSource) - List<DatabaseCleanupStats> allStats = [] + def allDataSources = applicationContext.getBeansOfType(DataSource) + def allStats = [] as List<DatabaseCleanupStats> if (mapping.cleanAll) { - // Clean all datasources using auto-discovery - allDataSources.each { String beanName, DataSource dataSource -> - DatabaseCleaner cleaner = findCleanerFor(dataSource) + // Clean all data sources using auto-discovery + allDataSources.each { beanName, dataSource -> + def cleaner = findCleanerFor(dataSource) if (!cleaner) { throw new IllegalStateException( - "No DatabaseCleaner implementation found that supports datasource '${beanName}'. Ensure that a database-specific cleanup library (e.g., grails-testing-support-cleanup-h2) is on the classpath for each database type used in your tests. Available cleaners: ${cleanersByType.values().collect { it.databaseType() }}" as String) + 'No DatabaseCleaner implementation found that supports ' + + "datasource '$beanName'. Ensure that a database-specific " + + 'cleanup library (e.g., grails-testing-support-cleanup-h2) ' + + 'is on the classpath for each database type used in your tests. ' + + "Available cleaners: ${cleanersByType.values()*.databaseType()}" + ) } - log.debug('Cleaning up datasource: {} (using {} cleaner)', beanName, cleaner.databaseType()) - DatabaseCleanupStats stats = cleaner.cleanup(applicationContext, dataSource) + log.debug( + 'Cleaning up datasource: {} (using {} cleaner)', + beanName, + cleaner.databaseType() + ) + def stats = cleaner.cleanup(applicationContext, dataSource) stats.datasourceName = beanName if (stats.tableRowCounts) { - log.debug('Cleaned {} tables from datasource {}', stats.tableRowCounts.size(), beanName) + log.debug( + 'Cleaned {} tables from datasource {}', + stats.tableRowCounts.size(), + beanName + ) } - allStats.add(stats) + allStats << stats } } else { - // Clean specific datasources per the mapping entries - for (DatasourceCleanupMapping.Entry entry : mapping.entries) { - DataSource dataSource = allDataSources.get(entry.datasourceName) + // Clean specific data sources per the mapping entries + mapping.entries.each { + def dsName = it.datasourceName + def dataSource = allDataSources[dsName] if (!dataSource) { throw new IllegalStateException( - "Datasource '${entry.datasourceName}' specified in @DatabaseCleanup was not found in the application context. Available datasources: ${allDataSources.keySet()}" as String) + "Datasource '$dsName' specified in @DatabaseCleanup " + + 'was not found in the application context. ' + + "Available datasources: ${allDataSources.keySet()}" + ) } - DatabaseCleaner cleaner - if (entry.hasExplicitType()) { - cleaner = cleanersByType.get(entry.databaseType) - if (!cleaner) { - throw new IllegalStateException( - "No DatabaseCleaner found for database type '${entry.databaseType}' specified in @DatabaseCleanup for datasource '${entry.datasourceName}'. Available cleaner types: ${cleanersByType.keySet()}" as String) - } - } else { - cleaner = findCleanerFor(dataSource) - if (!cleaner) { + def cleaner = it.hasExplicitType() ? + cleanersByType[it.databaseType] : + findCleanerFor(dataSource) + + if (!cleaner) { + if (it.hasExplicitType()) { throw new IllegalStateException( - "No DatabaseCleaner implementation found that supports datasource '${entry.datasourceName}'. Ensure that a database-specific cleanup library (e.g., grails-testing-support-cleanup-h2) is on the classpath, or specify the database type explicitly: '${entry.datasourceName}:type'. Available cleaners: ${cleanersByType.values().collect { it.databaseType() }}" as String) + "No DatabaseCleaner found for database type '$it.databaseType' " + + "specified in @DatabaseCleanup for datasource '$dsName'. " + + "Available cleaner types: ${cleanersByType.keySet()}" + ) } + throw new IllegalStateException( + "No DatabaseCleaner implementation found that supports datasource '$dsName'. " + + 'Ensure that a database-specific cleanup library ' + + '(e.g., grails-testing-support-cleanup-h2) is on the classpath, ' + + "or specify the database type explicitly: '$dsName:type'. " + + "Available cleaners: ${cleanersByType.values()*.databaseType()}" + ) } - log.debug('Cleaning up datasource: {} (using {} cleaner)', entry.datasourceName, cleaner.databaseType()) - DatabaseCleanupStats stats = cleaner.cleanup(applicationContext, dataSource) - stats.datasourceName = entry.datasourceName + log.debug( + 'Cleaning up datasource: {} (using {} cleaner)', + dsName, + cleaner.databaseType() + ) + def stats = cleaner.cleanup(applicationContext, dataSource) + stats.datasourceName = dsName + if (stats.tableRowCounts) { - log.debug('Cleaned {} tables from datasource {}', stats.tableRowCounts.size(), entry.datasourceName) + log.debug( + 'Cleaned {} tables from datasource {}', + stats.tableRowCounts.size(), + dsName + ) } - allStats.add(stats) + + allStats << stats } } @@ -171,11 +215,6 @@ class DatabaseCleanupContext { * @return the matching cleaner, or {@code null} if none supports it */ private DatabaseCleaner findCleanerFor(DataSource dataSource) { - for (DatabaseCleaner cleaner : cleanersByType.values()) { - if (cleaner.supports(dataSource)) { - return cleaner - } - } - null + cleanersByType.values().find { it.supports(dataSource) } } } diff --git a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupExtension.groovy b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupExtension.groovy index 375709dacb..b50524f8d9 100644 --- a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupExtension.groovy +++ b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupExtension.groovy @@ -16,20 +16,16 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.core -import java.lang.reflect.Method - import groovy.transform.CompileStatic import groovy.util.logging.Slf4j -import org.springframework.boot.test.context.SpringBootTest - import org.spockframework.runtime.extension.IGlobalExtension -import org.spockframework.runtime.model.FeatureInfo import org.spockframework.runtime.model.SpecInfo +import org.springframework.boot.test.context.SpringBootTest + /** * Spock global extension that detects the {@link DatabaseCleanup} annotation on test classes * and methods, and registers the {@link DatabaseCleanupInterceptor} to perform database @@ -74,8 +70,8 @@ class DatabaseCleanupExtension implements IGlobalExtension { @Override void start() { - ServiceLoader<DatabaseCleaner> cleanerLoader = ServiceLoader.load(DatabaseCleaner) - List<DatabaseCleaner> cleaners = cleanerLoader.toList() + def cleanerLoader = ServiceLoader.load(DatabaseCleaner) + def cleaners = cleanerLoader.toList() if (cleaners.isEmpty()) { log.debug('No DatabaseCleaner implementations found on classpath') @@ -92,21 +88,28 @@ class DatabaseCleanupExtension implements IGlobalExtension { } // Without an application context, there can be no database - boolean integrationEnvironment = spec.isAnnotationPresent(SpringBootTest) && spec.getAnnotation(SpringBootTest).webEnvironment() in [SpringBootTest.WebEnvironment.DEFINED_PORT, SpringBootTest.WebEnvironment.RANDOM_PORT] + boolean integrationEnvironment = + spec.isAnnotationPresent(SpringBootTest) + && spec.getAnnotation(SpringBootTest).webEnvironment() in [ + SpringBootTest.WebEnvironment.DEFINED_PORT, + SpringBootTest.WebEnvironment.RANDOM_PORT + ] if (!integrationEnvironment) { if (hasDatabaseCleanupAnnotation(spec)) { throw new IllegalStateException( - "@DatabaseCleanup requires an environment with an ApplicationContext. Add @Integration or define the web environment on @SpringBootTest. Spec: ${spec.name}" as String) + '@DatabaseCleanup requires an environment with an ApplicationContext. ' + + 'Add @Integration or define the web environment on @SpringBootTest. ' + + "Spec: $spec.name") } return } boolean classAnnotated = spec.isAnnotationPresent(DatabaseCleanup) if (classAnnotated) { - DatabaseCleanup annotation = spec.getAnnotation(DatabaseCleanup) - DatasourceCleanupMapping mapping = DatasourceCleanupMapping.parse(annotation.value()) - ApplicationContextResolver resolver = createResolver(annotation.resolver()) - DatabaseCleanupInterceptor interceptor = new DatabaseCleanupInterceptor(context, true, mapping, resolver) + def annotation = spec.getAnnotation(DatabaseCleanup) + def mapping = DatasourceCleanupMapping.parse(annotation.value()) + def resolver = createResolver(annotation.resolver()) + def interceptor = new DatabaseCleanupInterceptor(context, true, mapping, resolver) spec.addSetupInterceptor(interceptor) spec.addCleanupInterceptor(interceptor) log.debug('Registered DatabaseCleanupInterceptor for spec: {} (class-level)', spec.name) @@ -120,21 +123,17 @@ class DatabaseCleanupExtension implements IGlobalExtension { } // Check for method-level annotations on feature methods - boolean hasMethodAnnotation = false - for (FeatureInfo feature : spec.features) { - if (feature.featureMethod.isAnnotationPresent(DatabaseCleanup)) { - hasMethodAnnotation = true - break - } + boolean hasMethodAnnotation = spec.features.any { + it.featureMethod.isAnnotationPresent(DatabaseCleanup) } if (hasMethodAnnotation) { // For method-level, pass a clean-all mapping as default; the interceptor reads // each method's own annotation at runtime. // Use the default resolver; the interceptor will read the method-level resolver at runtime. - DatasourceCleanupMapping defaultMapping = DatasourceCleanupMapping.parse(new String[0]) - ApplicationContextResolver defaultResolver = new DefaultApplicationContextResolver() - DatabaseCleanupInterceptor interceptor = new DatabaseCleanupInterceptor(context, false, defaultMapping, defaultResolver) + def defaultMapping = DatasourceCleanupMapping.parse(new String[0]) + def defaultResolver = new DefaultApplicationContextResolver() + def interceptor = new DatabaseCleanupInterceptor(context, false, defaultMapping, defaultResolver) spec.addSetupInterceptor(interceptor) spec.addCleanupInterceptor(interceptor) log.debug('Registered DatabaseCleanupInterceptor for spec: {} (method-level)', spec.name) @@ -153,7 +152,10 @@ class DatabaseCleanupExtension implements IGlobalExtension { } catch (Exception e) { throw new IllegalStateException( - "Failed to instantiate ApplicationContextResolver: ${resolverClass.name}. Ensure it has a no-arg constructor." as String, e) + 'Failed to instantiate ApplicationContextResolver: ' + + "$resolverClass.name. Ensure it has a no-arg constructor.", + e + ) } } @@ -171,19 +173,22 @@ class DatabaseCleanupExtension implements IGlobalExtension { */ private static void validateNoAnnotationOnNonFeatureMethods(SpecInfo spec) { // Collect all feature method names for comparison - Set<String> featureMethodNames = [] as Set - for (FeatureInfo feature : spec.features) { - featureMethodNames.add(feature.featureMethod.name) - } + def featureMethodNames = spec.features*.name as Set<String> // Check all declared methods in the spec class for misplaced annotations // This necessarily uses spec.reflection since we need to scan raw Java methods // that are not exposed as Spock features - for (Method method : spec.reflection.declaredMethods) { - if (method.isAnnotationPresent(DatabaseCleanup) && !featureMethodNames.contains(method.name)) { - throw new IllegalStateException( - "@DatabaseCleanup annotation on method '${method.name}' in ${spec.reflection.name} is not valid. @DatabaseCleanup can only be applied to Spock feature methods (test methods) or at the class level. It cannot be applied to setup(), cleanup(), setupSpec(), or cleanupSpec() methods." as String) - } + def invalid = spec.reflection.declaredMethods.find { + it.isAnnotationPresent(DatabaseCleanup) && !featureMethodNames.contains(it.name) + } + if (invalid) { + throw new IllegalStateException( + "@DatabaseCleanup annotation on method '$invalid.name' " + + "in $spec.reflection.name is not valid. @DatabaseCleanup " + + 'can only be applied to Spock feature methods (test methods) ' + + 'or at the class level. It cannot be applied to setup(), ' + + 'cleanup(), setupSpec(), or cleanupSpec() methods.' + ) } } @@ -192,14 +197,7 @@ class DatabaseCleanupExtension implements IGlobalExtension { * class level or on any feature method. */ private static boolean hasDatabaseCleanupAnnotation(SpecInfo spec) { - if (spec.isAnnotationPresent(DatabaseCleanup)) { - return true - } - for (FeatureInfo feature : spec.features) { - if (feature.featureMethod.isAnnotationPresent(DatabaseCleanup)) { - return true - } - } - false + spec.isAnnotationPresent(DatabaseCleanup) || + spec.features.any { it.featureMethod.isAnnotationPresent(DatabaseCleanup) } } } diff --git a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupInterceptor.groovy b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupInterceptor.groovy index e726b60fc7..d1e5921665 100644 --- a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupInterceptor.groovy +++ b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupInterceptor.groovy @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.core import groovy.transform.CompileStatic @@ -27,8 +26,6 @@ import org.spockframework.runtime.extension.IMethodInvocation import org.springframework.context.ApplicationContext -import java.text.SimpleDateFormat - /** * Spock method interceptor that performs database cleanup after tests annotated * with {@link DatabaseCleanup}. Supports both class-level and method-level annotations. @@ -38,8 +35,8 @@ import java.text.SimpleDateFormat * {@link DatabaseCleanupContext}. After cleanup completes, the ThreadLocal is cleared.</p> * * <p>When datasource entries are specified in the annotation (with optional database type - * mappings), only those datasources are cleaned using the specified or auto-discovered - * cleaners. Otherwise, all datasources are cleaned.</p> + * mappings), only those data sources are cleaned using the specified or auto-discovered + * cleaners. Otherwise, all datas ources are cleaned.</p> */ @Slf4j @CompileStatic @@ -58,8 +55,12 @@ class DatabaseCleanupInterceptor extends AbstractMethodInterceptor { * for method-level cleanup, the method's own annotation values are parsed at runtime * @param resolver the strategy for resolving the ApplicationContext from test instances */ - DatabaseCleanupInterceptor(DatabaseCleanupContext context, boolean classLevelCleanup, - DatasourceCleanupMapping mapping, ApplicationContextResolver resolver) { + DatabaseCleanupInterceptor( + DatabaseCleanupContext context, + boolean classLevelCleanup, + DatasourceCleanupMapping mapping, + ApplicationContextResolver resolver + ) { this.context = context this.classLevelCleanup = classLevelCleanup this.mapping = mapping @@ -83,15 +84,22 @@ class DatabaseCleanupInterceptor extends AbstractMethodInterceptor { } finally { try { - def methodMapping = invocation.feature?.featureMethod?.isAnnotationPresent(DatabaseCleanup) + def methodMapping = invocation. + feature?. + featureMethod?. + isAnnotationPresent(DatabaseCleanup) if (!classLevelCleanup && !methodMapping) { return } - - log.debug('Performing database cleanup after test method: {}', invocation.feature?.name ?: 'unknown') - DatasourceCleanupMapping selectedMapping = methodMapping ? getMethodMapping(invocation) : mapping + log.debug( + 'Performing database cleanup after test method: {}', + invocation.feature?.name ?: 'unknown' + ) + def selectedMapping = methodMapping ? + getMethodMapping(invocation) + : mapping long startTime = System.currentTimeMillis() - List<DatabaseCleanupStats> stats = context.performCleanup(selectedMapping) + def stats = context.performCleanup(selectedMapping) logStats(stats, startTime) } finally { @@ -104,8 +112,13 @@ class DatabaseCleanupInterceptor extends AbstractMethodInterceptor { * Gets the parsed mapping from the method-level @DatabaseCleanup annotation. */ private static DatasourceCleanupMapping getMethodMapping(IMethodInvocation invocation) { - DatabaseCleanup annotation = invocation.feature?.featureMethod?.getAnnotation(DatabaseCleanup) - annotation ? DatasourceCleanupMapping.parse(annotation.value()) : DatasourceCleanupMapping.parse(new String[0]) + def annotation = invocation. + feature?. + featureMethod?. + getAnnotation(DatabaseCleanup) + annotation ? + DatasourceCleanupMapping.parse(annotation.value()) : + DatasourceCleanupMapping.parse(new String[0]) } /** @@ -119,20 +132,22 @@ class DatabaseCleanupInterceptor extends AbstractMethodInterceptor { return } - ApplicationContext appCtx = resolver.resolve(invocation) + def appCtx = resolver.resolve(invocation) if (appCtx) { context.applicationContext = appCtx } else { throw new IllegalStateException( - 'Could not resolve ApplicationContext from test instance. Ensure the spec is annotated with @Integration.') + 'Could not resolve ApplicationContext from test instance. ' + + 'Ensure the spec is annotated with @Integration.' + ) } } /** * Logs cleanup statistics and overall timing information for the cleanup operation. * - * @param statsList the list of cleanup statistics from individual datasources + * @param statsList the list of cleanup statistics from individual data sources * @param overallStartTime the overall start time of the cleanup operation */ private static void logStats(List<DatabaseCleanupStats> statsList, long overallStartTime) { @@ -140,20 +155,20 @@ class DatabaseCleanupInterceptor extends AbstractMethodInterceptor { long overallEndTime = System.currentTimeMillis() long overallDuration = overallEndTime - overallStartTime - String separator = '==========================================================' - String startTimeFormatted = DatabaseCleanupStats.formatTime(overallStartTime) - String endTimeFormatted = DatabaseCleanupStats.formatTime(overallEndTime) + def separator = '==========================================================' + def startTimeFormatted = DatabaseCleanupStats.formatTime(overallStartTime) + def endTimeFormatted = DatabaseCleanupStats.formatTime(overallEndTime) - System.out.println(separator) - System.out.println('Overall Cleanup Timing') - System.out.println("Start Time: ${startTimeFormatted}") - System.out.println("End Time: ${endTimeFormatted}") - System.out.println("Duration: ${overallDuration} ms") - System.out.println(separator) + println(separator) + println('Overall Cleanup Timing') + println("Start Time: $startTimeFormatted") + println("End Time: $endTimeFormatted") + println("Duration: $overallDuration ms") + println(separator) - for (DatabaseCleanupStats stats : statsList) { - if (stats.tableRowCounts) { - System.out.println(stats.toFormattedReport()) + statsList.each { + if (it.tableRowCounts) { + println(it.toFormattedReport()) } } } diff --git a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupStats.groovy b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupStats.groovy index 68c2eed9d3..c7d3e2d0bc 100644 --- a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupStats.groovy +++ b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatabaseCleanupStats.groovy @@ -16,12 +16,12 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.core -import groovy.transform.CompileStatic import java.text.SimpleDateFormat +import groovy.transform.CompileStatic + /** * Captures statistics from a single datasource cleanup operation, including which * tables were truncated and how many rows were in each table before truncation. @@ -147,10 +147,10 @@ class DatabaseCleanupStats { String separator = '==========================================================' String divider = '----------------------------------------------------------' - StringBuilder sb = new StringBuilder() + def sb = new StringBuilder() sb.append(separator).append('\n') if (datasourceName) { - sb.append("Database Cleanup Stats (datasource: ${datasourceName})").append('\n') + sb.append("Database Cleanup Stats (datasource: $datasourceName)").append('\n') } else { sb.append('Database Cleanup Stats').append('\n') @@ -165,7 +165,7 @@ class DatabaseCleanupStats { sb.append("End Time: ${formatTime(endTimeMillis)}").append('\n') } if (startTimeMillis > 0L && endTimeMillis > 0L) { - sb.append("Duration: ${durationMillis} ms").append('\n') + sb.append("Duration: $durationMillis ms").append('\n') } } @@ -193,8 +193,8 @@ class DatabaseCleanupStats { * @return the formatted time string */ static String formatTime(long timeMillis) { - SimpleDateFormat sdf = new SimpleDateFormat('yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'') - sdf.setTimeZone(TimeZone.getTimeZone('UTC')) - sdf.format(new Date(timeMillis)) + new SimpleDateFormat(/yyyy-MM-dd'T'HH:mm:ss.SSS'Z'/).tap { + timeZone = TimeZone.getTimeZone('UTC') + }.format(new Date(timeMillis)) } } diff --git a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatasourceCleanupMapping.groovy b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatasourceCleanupMapping.groovy index b628bfb82a..e1f724edbb 100644 --- a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatasourceCleanupMapping.groovy +++ b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DatasourceCleanupMapping.groovy @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.core import groovy.transform.CompileStatic @@ -33,18 +32,18 @@ import groovy.transform.CompileStatic * cleaner that declares the specified {@link DatabaseCleaner#databaseType()}</li> * </ul> * - * <p>If the annotation value is empty (the default), all datasources in the application + * <p>If the annotation value is empty (the default), all data sources in the application * context are cleaned using auto-discovery.</p> * * <p>Examples:</p> * <pre> - * // Clean all datasources (auto-discover cleaners) + * // Clean all data sources (auto-discover cleaners) * @DatabaseCleanup * - * // Clean specific datasources (auto-discover cleaners) + * // Clean specific data sources (auto-discover cleaners) * @DatabaseCleanup(['dataSource', 'dataSource_secondary']) * - * // Clean specific datasources with explicit cleaner types + * // Clean specific data sources with explicit cleaner types * @DatabaseCleanup(['dataSource:h2', 'dataSource_pg:postgresql']) * * // Mixed: some explicit, some auto-discovered @@ -71,12 +70,14 @@ class DatasourceCleanupMapping { * @return {@code true} if this entry has an explicit database type mapping */ boolean hasExplicitType() { - databaseType as boolean + databaseType } @Override String toString() { - hasExplicitType() ? "${datasourceName}:${databaseType}" : datasourceName + hasExplicitType() ? + "$datasourceName:$databaseType" : + datasourceName } } @@ -84,7 +85,7 @@ class DatasourceCleanupMapping { private final boolean cleanAll private DatasourceCleanupMapping(List<Entry> entries, boolean cleanAll) { - this.entries = Collections.unmodifiableList(new ArrayList<>(entries)) + this.entries = entries.asImmutable() this.cleanAll = cleanAll } @@ -96,8 +97,8 @@ class DatasourceCleanupMapping { } /** - * @return {@code true} if no specific datasources were specified, meaning all - * datasources in the application context should be cleaned + * @return {@code true} if no specific data sources were specified, meaning all + * datas ources in the application context should be cleaned */ boolean isCleanAll() { cleanAll @@ -114,37 +115,40 @@ class DatasourceCleanupMapping { * @throws IllegalArgumentException if an entry has an empty datasource name or database type */ static DatasourceCleanupMapping parse(String[] annotationValues) { - if (!annotationValues || annotationValues.length == 0) { + if (!annotationValues) { return new DatasourceCleanupMapping([], true) } - List<Entry> entries = [] - for (String value : annotationValues) { - if (!value || value.trim().isEmpty()) { + def entries = annotationValues.collect { + def v = it?.trim() + if (!v) { throw new IllegalArgumentException( - '@DatabaseCleanup contains a null or empty entry') + '@DatabaseCleanup contains a null or empty entry' + ) } - int colonIdx = value.indexOf(':') - if (colonIdx < 0) { + def parts = v.split(':', 2)*.trim() + if (parts.size() == 1) { // No colon — datasource name only, auto-discover cleaner - entries.add(new Entry(value.trim(), null)) + return new Entry(parts[0], null) + } + + def name = parts[0] + def type = parts[1] + if (!name) { + throw new IllegalArgumentException( + "Invalid @DatabaseCleanup entry '$it': " + + 'datasource name cannot be empty' + ) } - else { - String name = value.substring(0, colonIdx).trim() - String type = value.substring(colonIdx + 1).trim() - - if (name.isEmpty()) { - throw new IllegalArgumentException( - "Invalid @DatabaseCleanup entry '${value}': datasource name cannot be empty") - } - if (type.isEmpty()) { - throw new IllegalArgumentException( - "Invalid @DatabaseCleanup entry '${value}': database type cannot be empty after ':'") - } - - entries.add(new Entry(name, type)) + if (!type) { + throw new IllegalArgumentException( + "Invalid @DatabaseCleanup entry '$it': " + + "database type cannot be empty after ':'" + ) } + + new Entry(name, type) } new DatasourceCleanupMapping(entries, false) diff --git a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DefaultApplicationContextResolver.groovy b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DefaultApplicationContextResolver.groovy index 617b38afda..ba0368f132 100644 --- a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DefaultApplicationContextResolver.groovy +++ b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/DefaultApplicationContextResolver.groovy @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.core import groovy.transform.CompileStatic @@ -49,9 +48,9 @@ class DefaultApplicationContextResolver implements ApplicationContextResolver { @Override ApplicationContext resolve(IMethodInvocation invocation) { - TestContext testContext = TestContextHolderListener.CURRENT.get() + def testContext = TestContextHolderListener.CURRENT.get() if (testContext) { - ApplicationContext ctx = testContext.applicationContext + def ctx = testContext.applicationContext if (ctx) { log.debug('Resolved ApplicationContext via TestContextHolderListener') return ctx @@ -59,6 +58,10 @@ class DefaultApplicationContextResolver implements ApplicationContextResolver { } throw new IllegalStateException( - 'Could not resolve ApplicationContext. Ensure the spec is annotated with @Integration and that TestContextHolderListener is registered as a TestExecutionListener.') + 'Could not resolve ApplicationContext. ' + + 'Ensure the spec is annotated with @Integration ' + + 'and that TestContextHolderListener is registered ' + + 'as a TestExecutionListener.' + ) } } diff --git a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/TestContextHolderListener.groovy b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/TestContextHolderListener.groovy index 73304209ca..5241dec615 100644 --- a/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/TestContextHolderListener.groovy +++ b/grails-testing-support-cleanup-core/src/main/groovy/org/apache/grails/testing/cleanup/core/TestContextHolderListener.groovy @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.core import groovy.transform.CompileStatic diff --git a/grails-testing-support-cleanup-h2/src/main/groovy/org/apache/grails/testing/cleanup/h2/H2DatabaseCleaner.groovy b/grails-testing-support-cleanup-h2/src/main/groovy/org/apache/grails/testing/cleanup/h2/H2DatabaseCleaner.groovy index b1a71a0f1a..c3cce96176 100644 --- a/grails-testing-support-cleanup-h2/src/main/groovy/org/apache/grails/testing/cleanup/h2/H2DatabaseCleaner.groovy +++ b/grails-testing-support-cleanup-h2/src/main/groovy/org/apache/grails/testing/cleanup/h2/H2DatabaseCleaner.groovy @@ -16,22 +16,15 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.h2 -import java.sql.Connection -import java.sql.DatabaseMetaData - import javax.sql.DataSource -import groovy.sql.GroovyResultSet import groovy.sql.Sql -import groovy.transform.CompileDynamic import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import org.springframework.context.ApplicationContext -import org.springframework.util.ClassUtils import org.apache.grails.testing.cleanup.core.DatabaseCleaner import org.apache.grails.testing.cleanup.core.DatabaseCleanupStats @@ -49,6 +42,11 @@ import org.apache.grails.testing.cleanup.core.DatabaseCleanupStats class H2DatabaseCleaner implements DatabaseCleaner { private static final String DATABASE_TYPE = 'h2' + private static final String QUERY = ''' + SELECT table_name, row_count_estimate + FROM information_schema.tables + WHERE table_schema = ? AND row_count_estimate > 0 + ''' @Override String databaseType() { @@ -57,53 +55,36 @@ class H2DatabaseCleaner implements DatabaseCleaner { @Override boolean supports(DataSource dataSource) { - Connection connection = null - try { - connection = dataSource.getConnection() - DatabaseMetaData metaData = connection.getMetaData() - String url = metaData.getURL() - return url && url.startsWith('jdbc:h2:') - } - catch (Exception e) { + try (def con = dataSource.connection) { + return con.metaData?.URL?.startsWith('jdbc:h2:') + } catch (Exception e) { log.debug('Could not determine if datasource is H2', e) return false } - finally { - if (connection) { - try { - connection.close() - } - catch (Exception ignored) { - // ignore - } - } - } } - @SuppressWarnings('SqlNoDataSourceInspection') @Override + @SuppressWarnings('SqlNoDataSourceInspection') DatabaseCleanupStats cleanup(ApplicationContext applicationContext, DataSource dataSource) { - DatabaseCleanupStats stats = new DatabaseCleanupStats() + def stats = new DatabaseCleanupStats() stats.start() - String schemaName = H2DatabaseCleanupHelper.resolveSchemaName(dataSource) + def schemaName = H2DatabaseCleanupHelper.resolveSchemaName(dataSource) if (!schemaName) { log.warn('Could not resolve schema name for datasource, skipping cleanup') stats.stop() return stats } - Sql sql = new Sql(dataSource) + def sql = new Sql(dataSource) try { sql.execute('SET REFERENTIAL_INTEGRITY FALSE') - String query = "SELECT table_name, row_count_estimate FROM information_schema.tables WHERE table_schema = '${schemaName}' AND row_count_estimate > 0" as String - sql.eachRow(query) { GroovyResultSet row -> - String tableName = row['table_name'] as String - stats.addTableRowCount(tableName, row['row_count_estimate'] as Long) + sql.eachRow(QUERY, [schemaName] as List<Object>) { row -> + def tableName = row.getString('table_name') + stats.addTableRowCount(tableName, row.getLong('row_count_estimate')) log.debug('Truncating table: {}', tableName) - sql.executeUpdate("TRUNCATE TABLE \"${tableName}\"" as String) + sql.executeUpdate(/TRUNCATE TABLE "$tableName"/) } - cleanupCacheLayer(applicationContext) } finally { @@ -119,13 +100,4 @@ class H2DatabaseCleaner implements DatabaseCleaner { stats } - - @CompileDynamic - private void cleanupCacheLayer(ApplicationContext applicationContext) { - // Clear the 2nd layer cache if it exists - if (ClassUtils.isPresent('org.hibernate.SessionFactory', this.class.classLoader)) { - def sessionFactory = applicationContext.getBean('sessionFactory', Class.forName('org.hibernate.SessionFactory')) - sessionFactory?.cache?.evictAllRegions() - } - } } diff --git a/grails-testing-support-cleanup-h2/src/main/groovy/org/apache/grails/testing/cleanup/h2/H2DatabaseCleanupHelper.groovy b/grails-testing-support-cleanup-h2/src/main/groovy/org/apache/grails/testing/cleanup/h2/H2DatabaseCleanupHelper.groovy index 6811b0df06..8b8480c19f 100644 --- a/grails-testing-support-cleanup-h2/src/main/groovy/org/apache/grails/testing/cleanup/h2/H2DatabaseCleanupHelper.groovy +++ b/grails-testing-support-cleanup-h2/src/main/groovy/org/apache/grails/testing/cleanup/h2/H2DatabaseCleanupHelper.groovy @@ -16,12 +16,8 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.h2 -import java.sql.Connection -import java.sql.DatabaseMetaData - import javax.sql.DataSource import groovy.transform.CompileStatic @@ -45,37 +41,21 @@ class H2DatabaseCleanupHelper { * @return the schema name, or {@code null} if it cannot be determined */ static String resolveSchemaName(DataSource dataSource) { - Connection connection = null - try { - connection = dataSource.getConnection() - String schema = connection.getSchema() + try (def con = dataSource.getConnection()) { + def schema = con.getSchema() if (schema) { log.debug('Resolved schema name from connection: {}', schema) return schema } - - // Fallback: try to get the schema from the database metadata URL - DatabaseMetaData metaData = connection.getMetaData() - String url = metaData.getURL() + def url = con.metaData.URL if (url) { - schema = extractSchemaFromUrl(url) - log.debug('Resolved schema name from URL {}: {}', url, schema) - return schema + def extracted = extractSchemaFromUrl(url) + log.debug('Resolved schema name from URL {}: {}', url, extracted) + return extracted } - } - catch (Exception e) { + } catch (Exception e) { log.warn('Failed to resolve schema name from datasource', e) } - finally { - if (connection) { - try { - connection.close() - } - catch (Exception ignored) { - // ignore - } - } - } null } @@ -94,53 +74,48 @@ class H2DatabaseCleanupHelper { * @return the uppercase schema name, or {@code null} if the URL format is not recognized */ static String extractSchemaFromUrl(String url) { - if (!url) { + if (!url?.startsWith('jdbc:h2:')) { return null } // Handle H2 in-memory URLs: jdbc:h2:mem:dbName or jdbc:h2:mem:dbName;params if (url.startsWith('jdbc:h2:mem:')) { - String remainder = url.substring('jdbc:h2:mem:'.length()) - // Remove any trailing parameters after ';' - int semicolonIdx = remainder.indexOf(';') - if (semicolonIdx >= 0) { - remainder = remainder.substring(0, semicolonIdx) - } - return remainder ? remainder.toUpperCase() : null + def name = stripParams(url.substring('jdbc:h2:mem:'.length())) + return toSchema(name) } - // Handle H2 file-based URLs: jdbc:h2:./dbName, jdbc:h2:file:./path/dbName, jdbc:h2:tcp://host/dbName - if (url.startsWith('jdbc:h2:')) { - String remainder = url.substring('jdbc:h2:'.length()) - - // Remove protocol prefixes (tcp://, ssl://) - if (remainder.startsWith('tcp://') || remainder.startsWith('ssl://')) { - int slashIdx = remainder.indexOf('/', remainder.indexOf('//') + 2) - if (slashIdx >= 0) { - remainder = remainder.substring(slashIdx + 1) - } - } + def remainder = url.substring('jdbc:h2:'.length()) - // Remove 'file:' prefix - if (remainder.startsWith('file:')) { - remainder = remainder.substring('file:'.length()) + // Remove protocol prefixes (tcp://, ssl://) + if (remainder.startsWith('tcp://') || remainder.startsWith('ssl://')) { + int slashIdx = remainder.indexOf('/', remainder.indexOf('//') + 2) + if (slashIdx >= 0) { + remainder = remainder.substring(slashIdx + 1) } + } - // Remove any trailing parameters after ';' - int semicolonIdx = remainder.indexOf(';') - if (semicolonIdx >= 0) { - remainder = remainder.substring(0, semicolonIdx) - } + // Remove 'file:' prefix + if (remainder.startsWith('file:')) { + remainder = remainder.substring('file:'.length()) + } - // Get the last path segment as the database name - int lastSlash = remainder.lastIndexOf('/') - if (lastSlash >= 0) { - remainder = remainder.substring(lastSlash + 1) - } + remainder = stripParams(remainder) - return remainder ? remainder.toUpperCase() : null + // last path segment + int lastSlash = remainder.lastIndexOf('/') + if (lastSlash >= 0) { + remainder = remainder.substring(lastSlash + 1) } - null + return toSchema(remainder) + } + + private static String stripParams(String s) { + int idx = s.indexOf(';') + idx >= 0 ? s.substring(0, idx) : s + } + + private static String toSchema(String s) { + s ? s.toUpperCase() : null } } diff --git a/grails-testing-support-cleanup-postgresql/src/main/groovy/org/apache/grails/testing/cleanup/postgresql/PostgresDatabaseCleaner.groovy b/grails-testing-support-cleanup-postgresql/src/main/groovy/org/apache/grails/testing/cleanup/postgresql/PostgresDatabaseCleaner.groovy index 5ed1756602..089bdafbaa 100644 --- a/grails-testing-support-cleanup-postgresql/src/main/groovy/org/apache/grails/testing/cleanup/postgresql/PostgresDatabaseCleaner.groovy +++ b/grails-testing-support-cleanup-postgresql/src/main/groovy/org/apache/grails/testing/cleanup/postgresql/PostgresDatabaseCleaner.groovy @@ -16,22 +16,15 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.postgresql -import java.sql.Connection -import java.sql.DatabaseMetaData - import javax.sql.DataSource -import groovy.sql.GroovyResultSet import groovy.sql.Sql -import groovy.transform.CompileDynamic import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import org.springframework.context.ApplicationContext -import org.springframework.util.ClassUtils import org.apache.grails.testing.cleanup.core.DatabaseCleaner import org.apache.grails.testing.cleanup.core.DatabaseCleanupStats @@ -57,6 +50,16 @@ import org.apache.grails.testing.cleanup.core.DatabaseCleanupStats class PostgresDatabaseCleaner implements DatabaseCleaner { private static final String DATABASE_TYPE = 'postgresql' + private static final String QUERY = ''' + SELECT n.nspname AS schemaname, + c.relname AS table_name, + c.reltuples::bigint AS row_count_estimate + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' + AND n.nspname = ? + ORDER BY row_count_estimate DESC; + ''' @Override String databaseType() { @@ -65,45 +68,27 @@ class PostgresDatabaseCleaner implements DatabaseCleaner { @Override boolean supports(DataSource dataSource) { - Connection connection = null - try { - connection = dataSource.getConnection() - DatabaseMetaData metaData = connection.getMetaData() - String url = metaData.getURL() - return url && url.startsWith('jdbc:postgresql:') - } - catch (Exception e) { + try (def con = dataSource.connection) { + return con.metaData?.URL?.startsWith('jdbc:postgresql:') + } catch (Exception e) { log.debug('Could not determine if datasource is PostgreSQL', e) return false } - finally { - if (connection) { - try { - connection.close() - } - catch (Exception ignored) { - // ignore - } - } - } } - @SuppressWarnings('SqlNoDataSourceInspection') @Override + @SuppressWarnings('SqlNoDataSourceInspection') DatabaseCleanupStats cleanup(ApplicationContext applicationContext, DataSource dataSource) { - DatabaseCleanupStats stats = new DatabaseCleanupStats() - stats.start() - Sql sql = new Sql(dataSource) + def stats = new DatabaseCleanupStats().tap { start() } + def sql = new Sql(dataSource) try { // Disable all triggers and referential integrity checks for this session // This is more efficient than using CASCADE on each truncate sql.execute('SET session_replication_role = replica') - - String currentSchema = PostgresDatabaseCleanupHelper.resolveCurrentSchema(dataSource) + def currentSchema = PostgresDatabaseCleanupHelper.resolveCurrentSchema(dataSource) log.debug('Cleaning schema: {}', currentSchema) cleanupSchema(sql, currentSchema, stats) - cleanupCacheLayer(applicationContext) } finally { @@ -117,32 +102,19 @@ class PostgresDatabaseCleaner implements DatabaseCleaner { } stats.stop() } - stats } @SuppressWarnings('SqlNoDataSourceInspection') - private void cleanupSchema(Sql sql, String schemaName, DatabaseCleanupStats stats) { - String query = """ - SELECT n.nspname AS schemaname, - c.relname AS table_name, - c.reltuples::bigint AS row_count_estimate - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind = 'r' - AND n.nspname = '${schemaName}' - ORDER BY row_count_estimate DESC; - """ as String - - List<String> tablesInSchema = [] - sql.eachRow(query) { GroovyResultSet row -> - String tableName = row['table_name'] as String + private static void cleanupSchema(Sql sql, String schemaName, DatabaseCleanupStats stats) { + def tablesInSchema = [] as List<String> + sql.eachRow(QUERY, [schemaName] as List<Object>) { row -> + def tableName = row.getString('table_name') tablesInSchema << tableName - - def rowCount = row['row_count_estimate'] as Long + def rowCount = row.getLong('row_count_estimate') if (stats.detailedStatCollection) { if (rowCount == -1) { - String countQuery = "SELECT COUNT(*) AS cnt FROM ${tableName}" as String + def countQuery = "SELECT COUNT(*) AS cnt FROM $tableName" as String rowCount = sql.firstRow(countQuery)?.cnt as Long ?: 0L } } @@ -150,17 +122,11 @@ class PostgresDatabaseCleaner implements DatabaseCleaner { } if (tablesInSchema) { - log.debug('Truncating tables: {}', tablesInSchema.join(',')) - sql.executeUpdate("TRUNCATE TABLE ${tablesInSchema.collect { "\"$it\"" }.join(',')} RESTART IDENTITY CASCADE" as String) - } - } - - @CompileDynamic - private void cleanupCacheLayer(ApplicationContext applicationContext) { - // Clear the 2nd layer cache if it exists - if (ClassUtils.isPresent('org.hibernate.SessionFactory', this.class.classLoader)) { - def sessionFactory = applicationContext.getBean('sessionFactory', Class.forName('org.hibernate.SessionFactory')) - sessionFactory?.cache?.evictAllRegions() + log.debug('Truncating tables: {}', tablesInSchema.join(', ')) + def tables = tablesInSchema.collect { "\"$it\"" }.join(',') + sql.executeUpdate( + "TRUNCATE TABLE $tables RESTART IDENTITY CASCADE" as String + ) } } } diff --git a/grails-testing-support-cleanup-postgresql/src/main/groovy/org/apache/grails/testing/cleanup/postgresql/PostgresDatabaseCleanupHelper.groovy b/grails-testing-support-cleanup-postgresql/src/main/groovy/org/apache/grails/testing/cleanup/postgresql/PostgresDatabaseCleanupHelper.groovy index a79e65d60a..a6816b13db 100644 --- a/grails-testing-support-cleanup-postgresql/src/main/groovy/org/apache/grails/testing/cleanup/postgresql/PostgresDatabaseCleanupHelper.groovy +++ b/grails-testing-support-cleanup-postgresql/src/main/groovy/org/apache/grails/testing/cleanup/postgresql/PostgresDatabaseCleanupHelper.groovy @@ -16,67 +16,52 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.grails.testing.cleanup.postgresql -import java.sql.Connection -import java.sql.DatabaseMetaData - import javax.sql.DataSource import groovy.transform.CompileStatic import groovy.util.logging.Slf4j /** - * Helper utility for PostgreSQL database cleanup operations. Provides PostgreSQL-specific logic such as - * resolving the current schema from a {@link DataSource} by inspecting JDBC connection metadata - * and parsing PostgreSQL JDBC URLs. + * Helper utility for PostgreSQL database cleanup operations. + * Provides PostgreSQL-specific logic such as resolving the + * current schema from a {@link DataSource} by inspecting + * JDBC connection metadata and parsing PostgreSQL JDBC URLs. */ @Slf4j @CompileStatic class PostgresDatabaseCleanupHelper { /** - * Resolves the current schema for the given PostgreSQL datasource by inspecting the JDBC connection metadata. - * If the JDBC URL contains a `currentSchema` parameter, returns that schema. + * Resolves the current schema for the given PostgreSQL datasource + * by inspecting the JDBC connection metadata. If the JDBC URL + * contains a `currentSchema` parameter, returns that schema. * Otherwise, returns the connection's current schema. * * @param dataSource the datasource to resolve the schema for * @return the schema name, or {@code null} if it cannot be determined */ static String resolveCurrentSchema(DataSource dataSource) { - Connection connection = null - try { - connection = dataSource.getConnection() - String schema = connection.getSchema() + try (def con = dataSource.connection) { + def schema = con.getSchema() if (schema) { log.debug('Resolved current schema from connection: {}', schema) return schema } - // Fallback: try to get the schema from the database metadata URL - DatabaseMetaData metaData = connection.getMetaData() - String url = metaData.getURL() + def url = con.metaData.URL if (url) { schema = extractCurrentSchemaFromUrl(url) - if (schema) { - log.debug('Resolved current schema from URL {}: {}', url, schema) - return schema - } - } - } - finally { - if (connection) { - try { - connection.close() - } - catch (Exception ignored) { - // ignore - } + log.debug('Resolved current schema from URL {}: {}', url, schema) + return schema } } - - throw new IllegalStateException("Because postgres defaults to the search_path when currentSchema isn't defined, a schema should always be found") + throw new IllegalStateException( + 'Because postgres defaults to the search_path ' + + 'when currentSchema isn\'t defined, a schema ' + + 'should always be found' + ) } /** @@ -98,29 +83,18 @@ class PostgresDatabaseCleanupHelper { return null } - // Look for currentSchema parameter in query string - int questionIdx = url.indexOf('?') - if (questionIdx < 0) { + int q = url.indexOf('?') + if (q < 0) { return null } - String queryString = url.substring(questionIdx + 1) - String[] params = queryString.split('&') - for (String param : params) { + def query = url.substring(q + 1) + for (def param : query.split('&')) { if (param.startsWith('currentSchema=')) { - String schema = param.substring('currentSchema='.length()) - // URL decode if needed (handle common cases) - if (schema) { - // Remove any trailing parameters if present - int ampIdx = schema.indexOf('&') - if (ampIdx >= 0) { - schema = schema.substring(0, ampIdx) - } - return schema ?: null - } + def schema = param.substring('currentSchema='.length()) + return schema ?: null } } - - null + return null } }
