From a3370d0176b991fa711898e052be1c14adbaf83b Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 9 Jul 2026 19:12:24 +0200 Subject: [PATCH 1/4] feat(core)!: instance-scoped integration points with lazily bound transaction scopes Replace JVM-global ServiceLoader winner-takes-all resolution of ConnectionProvider and TransactionTemplateProvider with instance-scoped strategies on a new ORMTemplate.builder(), mirrored in the java21 and kotlin facades. ServiceLoader remains the fallback for templates built without explicit strategies; provider enablement is re-evaluated per resolution and ambiguous resolution fails fast naming the candidates. Add two new template-scoped SPIs: ExceptionMapper (maps execution failures to the thrown exception, enabling platform hierarchies such as Spring's DataAccessException) and QueryObserver (observes query executions for metrics and tracing bindings). Introduce TransactionScope: transaction { } blocks now open a provider-agnostic pending scope that is materialized by the first template that executes inside it, through that template's transaction provider. Scopes nest, join by provider instance identity, fail fast on cross-provider mixing, enforce timeouts from block entry, and propagate rollback-only marks including the joined-scope distinction that surfaces UnexpectedRollbackException. The TransactionTemplate SPI is reshaped from callback-wrapping execute() to open()/complete() handles to support the lazy binding. Remove all reflective Spring probes from storm-core and storm-kotlin: templates never silently enlist in Spring transactions based on classpath presence. The storm-core test suite now runs on the classpath with test-scoped Spring-aware providers registered via META-INF/services, preserving transaction-per-test isolation under @DataJpaTest. --- storm-core/pom.xml | 14 +- .../repository/impl/EntityRepositoryImpl.java | 17 +- .../spi/DefaultConnectionProviderImpl.java | 56 +-- ...efaultTransactionTemplateProviderImpl.java | 268 +---------- .../st/orm/core/spi/ExceptionContext.java | 61 +++ .../java/st/orm/core/spi/ExceptionMapper.java | 61 +++ .../main/java/st/orm/core/spi/Providers.java | 131 ++++-- .../java/st/orm/core/spi/QueryContext.java | 90 ++++ .../java/st/orm/core/spi/QueryFactory.java | 13 + .../java/st/orm/core/spi/QueryObserver.java | 93 ++++ .../main/java/st/orm/core/spi/RefFactory.java | 14 + .../java/st/orm/core/spi/RefFactoryImpl.java | 19 +- .../st/orm/core/spi/TransactionScope.java | 423 ++++++++++++++++++ .../st/orm/core/spi/TransactionTemplate.java | 60 ++- .../st/orm/core/template/ORMTemplate.java | 171 +++++++ .../st/orm/core/template/QueryTemplate.java | 13 + .../core/template/impl/ExceptionHelper.java | 92 +++- .../template/impl/ObjectMapperFactory.java | 6 +- .../core/template/impl/PreparedQueryImpl.java | 10 +- .../impl/PreparedStatementTemplateImpl.java | 165 +++++-- .../st/orm/core/template/impl/QueryImpl.java | 243 ++++++---- .../core/template/impl/QueryTemplateImpl.java | 11 + .../orm/core/template/impl/FetchSizeTest.java | 14 +- .../testsupport/ProviderResolutionTest.java | 25 ++ .../TestSpringConnectionProvider.java | 52 +++ ...TestSpringTransactionTemplateProvider.java | 158 +++++++ .../st.orm.core.spi.ConnectionProvider | 1 + ...t.orm.core.spi.TransactionTemplateProvider | 1 + .../java/st/orm/template/ORMTemplate.java | 123 +++++ .../kotlin/st/orm/template/ORMTemplate.kt | 73 +++ .../kotlin/st/orm/template/Transactions.kt | 391 ++++++++-------- .../CoroutineAwareConnectionProviderImpl.kt | 82 +--- .../template/impl/JdbcTransactionContext.kt | 59 ++- .../impl/TransactionTemplateProviderImpl.kt | 29 +- 34 files changed, 2194 insertions(+), 845 deletions(-) create mode 100644 storm-core/src/main/java/st/orm/core/spi/ExceptionContext.java create mode 100644 storm-core/src/main/java/st/orm/core/spi/ExceptionMapper.java create mode 100644 storm-core/src/main/java/st/orm/core/spi/QueryContext.java create mode 100644 storm-core/src/main/java/st/orm/core/spi/QueryObserver.java create mode 100644 storm-core/src/main/java/st/orm/core/spi/TransactionScope.java create mode 100644 storm-core/src/test/java/st/orm/core/testsupport/ProviderResolutionTest.java create mode 100644 storm-core/src/test/java/st/orm/core/testsupport/TestSpringConnectionProvider.java create mode 100644 storm-core/src/test/java/st/orm/core/testsupport/TestSpringTransactionTemplateProvider.java create mode 100644 storm-core/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider create mode 100644 storm-core/src/test/resources/META-INF/services/st.orm.core.spi.TransactionTemplateProvider diff --git a/storm-core/pom.xml b/storm-core/pom.xml index 9328342dc..07e2ed477 100644 --- a/storm-core/pom.xml +++ b/storm-core/pom.xml @@ -36,18 +36,14 @@ org.apache.maven.plugins maven-surefire-plugin + + false @{argLine} --add-opens java.base/java.lang=ALL-UNNAMED - --add-opens storm.core/st.orm.core=ALL-UNNAMED - --add-opens storm.core/st.orm.core.repository=ALL-UNNAMED - --add-opens storm.core/st.orm.core.repository.spring=ALL-UNNAMED - --add-opens storm.core/st.orm.core.model=ALL-UNNAMED - - --add-modules jakarta.persistence -Dstorm.ansi_escaping=true diff --git a/storm-core/src/main/java/st/orm/core/repository/impl/EntityRepositoryImpl.java b/storm-core/src/main/java/st/orm/core/repository/impl/EntityRepositoryImpl.java index 6d7dd1268..10092069a 100644 --- a/storm-core/src/main/java/st/orm/core/repository/impl/EntityRepositoryImpl.java +++ b/storm-core/src/main/java/st/orm/core/repository/impl/EntityRepositoryImpl.java @@ -49,9 +49,8 @@ import st.orm.core.spi.CacheRetention; import st.orm.core.spi.EntityCache; import st.orm.core.spi.EntityCacheMetrics; -import st.orm.core.spi.Providers; import st.orm.core.spi.TransactionContext; -import st.orm.core.spi.TransactionTemplate; +import st.orm.core.spi.TransactionScope; import st.orm.core.template.Column; import st.orm.core.template.Model; import st.orm.core.template.ORMTemplate; @@ -82,8 +81,6 @@ public class EntityRepositoryImpl, ID> */ private static final ThreadLocal CALLBACK_ACTIVE = ThreadLocal.withInitial(() -> Boolean.FALSE); - private final TransactionTemplate TRANSACTION_TEMPLATE = Providers.getTransactionTemplate(); - protected final int defaultBatchSize; protected final List primaryKeyColumns; protected final GenerationStrategy generationStrategy; @@ -672,10 +669,18 @@ public E insertAndFetch(@Nonnull E entity) { */ protected Optional> entityCache() { //noinspection unchecked - return TRANSACTION_TEMPLATE.currentContext() + return currentTransactionContext() .map(ctx -> (EntityCache) ctx.entityCache(model().type(), cacheRetention)); } + /** + * Returns the transaction context that is active for this repository's template, or empty when no transaction is + * active. This is an observing lookup: it never starts a transaction. + */ + private Optional currentTransactionContext() { + return Optional.ofNullable(TransactionScope.peekContext(ormTemplate.transactionTemplateProvider())); + } + /** * Returns true if the transaction isolation level is {@code REPEATABLE_READ} or higher. * @@ -686,7 +691,7 @@ protected Optional> entityCache() { * @since 1.8 */ protected boolean isRepeatableRead() { - return TRANSACTION_TEMPLATE.currentContext() + return currentTransactionContext() .map(TransactionContext::isRepeatableRead) .orElse(false); } diff --git a/storm-core/src/main/java/st/orm/core/spi/DefaultConnectionProviderImpl.java b/storm-core/src/main/java/st/orm/core/spi/DefaultConnectionProviderImpl.java index def752263..014362641 100644 --- a/storm-core/src/main/java/st/orm/core/spi/DefaultConnectionProviderImpl.java +++ b/storm-core/src/main/java/st/orm/core/spi/DefaultConnectionProviderImpl.java @@ -17,65 +17,37 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.sql.Connection; +import java.sql.SQLException; import javax.sql.DataSource; import st.orm.PersistenceException; import st.orm.core.spi.Orderable.AfterAny; +/** + * Default connection provider that acquires and closes connections directly on the data source. + * + *

This provider is platform-neutral: it never participates in externally managed transactions. Integrations that + * bind connections to a transaction subsystem supply their own {@link ConnectionProvider} via the template + * builder.

+ */ @AfterAny public class DefaultConnectionProviderImpl implements ConnectionProvider { - private static final Method GET_CONNECTION_METHOD; - private static final Method RELEASE_CONNECTION_METHOD; - - static { - Method getConnection; - Method releaseConnection; - try { - Class utilsClass = Class.forName("org.springframework.jdbc.datasource.DataSourceUtils"); - getConnection = utilsClass.getMethod("getConnection", DataSource.class); - releaseConnection = utilsClass.getMethod("releaseConnection", Connection.class, DataSource.class); - } catch (ClassNotFoundException | NoSuchMethodException e) { - getConnection = null; - releaseConnection = null; - } - GET_CONNECTION_METHOD = getConnection; - RELEASE_CONNECTION_METHOD = releaseConnection; - } - @Override public Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { try { - if (GET_CONNECTION_METHOD != null) { - try { - return (Connection) GET_CONNECTION_METHOD.invoke(null, dataSource); - } catch (InvocationTargetException e) { - throw e.getTargetException(); - } - } else { - return dataSource.getConnection(); - } - } catch (Throwable t) { - throw new PersistenceException("Failed to get connection from DataSource.", t); + return dataSource.getConnection(); + } catch (SQLException e) { + throw new PersistenceException("Failed to get connection from DataSource.", e); } } @Override public void releaseConnection(@Nonnull Connection connection, @Nonnull DataSource dataSource, @Nullable TransactionContext context) { try { - if (RELEASE_CONNECTION_METHOD != null) { - try { - RELEASE_CONNECTION_METHOD.invoke(null, connection, dataSource); - } catch (InvocationTargetException e) { - throw e.getTargetException(); - } - } else { - connection.close(); - } - } catch (Throwable t) { - throw new PersistenceException("Failed to release connection.", t); + connection.close(); + } catch (SQLException e) { + throw new PersistenceException("Failed to release connection.", e); } } } diff --git a/storm-core/src/main/java/st/orm/core/spi/DefaultTransactionTemplateProviderImpl.java b/storm-core/src/main/java/st/orm/core/spi/DefaultTransactionTemplateProviderImpl.java index fa0a8e636..7609fa6ea 100644 --- a/storm-core/src/main/java/st/orm/core/spi/DefaultTransactionTemplateProviderImpl.java +++ b/storm-core/src/main/java/st/orm/core/spi/DefaultTransactionTemplateProviderImpl.java @@ -17,24 +17,22 @@ import static java.util.Optional.empty; -import jakarta.annotation.Nonnull; -import java.lang.reflect.Method; -import java.lang.reflect.Proxy; -import java.sql.Connection; -import java.util.HashMap; -import java.util.Map; +import jakarta.annotation.Nullable; import java.util.Optional; -import st.orm.Entity; import st.orm.PersistenceException; import st.orm.core.spi.Orderable.AfterAny; +/** + * Default transaction template provider that does not support transactions. + * + *

Storm's programmatic transaction support is provided by integration modules, such as storm-kotlin's JDBC + * transaction provider or the Spring transaction bridge; those providers are discovered via {@code ServiceLoader} + * or configured explicitly via the template builder. This default reports that no transaction is active and fails + * fast when asked to open one.

+ */ @AfterAny public class DefaultTransactionTemplateProviderImpl implements TransactionTemplateProvider { - // Key under which we store the TransactionContext in Spring's TransactionSynchronizationManager resources. - private static final Object SPRING_CTX_RESOURCE_KEY = - DefaultTransactionTemplateProviderImpl.class.getName() + ".SPRING_TX_CONTEXT"; - @Override public TransactionTemplate getTransactionTemplate() { return new TransactionTemplate() { @@ -59,262 +57,20 @@ public TransactionTemplate timeout(int timeoutSeconds) { } @Override - public TransactionContext newContext(boolean suspendMode) throws PersistenceException { + public TransactionHandle open(@Nullable TransactionContext existing, boolean suspendMode) + throws PersistenceException { throw new UnsupportedOperationException("Transaction template not supported."); } @Override public Optional currentContext() { - final SpringReflection springReflection = SpringReflection.tryLoad(); - if (springReflection == null) { - return empty(); - } - // Only expose a context when Spring says we are inside an actual transaction. - if (!springReflection.isActualTransactionActive()) { - return empty(); - } - Object existing = springReflection.getResource(SPRING_CTX_RESOURCE_KEY); - if (existing instanceof TransactionContext) { - return Optional.of((TransactionContext) existing); - } - TransactionContext created = new SpringLinkedTransactionContext(springReflection); - // Bind once per transaction and ensure cleanup at tx completion. - try { - springReflection.bindResource(SPRING_CTX_RESOURCE_KEY, created); - springReflection.registerCleanupOnTxCompletion(SPRING_CTX_RESOURCE_KEY); - return Optional.of(created); - } catch (Throwable bindFailure) { - // If already bound concurrently (unlikely; resources are thread-bound), return the bound one. - Object winner = springReflection.getResource(SPRING_CTX_RESOURCE_KEY); - if (winner instanceof TransactionContext) { - return Optional.of((TransactionContext) winner); - } - // If bind failed and nothing is bound, do not silently return an unbound context (it would not be tx-scoped). - throw new PersistenceException("Failed to bind Spring transaction context resource.", bindFailure); - } + return empty(); } @Override public ThreadLocal contextHolder() { throw new UnsupportedOperationException("Transaction template not supported."); } - - @Override - public R execute(@Nonnull TransactionCallback action, @Nonnull TransactionContext context) - throws PersistenceException { - throw new UnsupportedOperationException("Transaction template not supported."); - } }; } - - /** - * TransactionContext bound to Spring's TransactionSynchronizationManager resources. - */ - private static final class SpringLinkedTransactionContext implements TransactionContext { - private final SpringReflection springReflection; - private final Map>, EntityCache, ?>> caches = new HashMap<>(); - private final Decorator noopDecorator = resource -> resource; - - private SpringLinkedTransactionContext(SpringReflection springReflection) { - this.springReflection = springReflection; - } - - @Override - public boolean isRepeatableRead() { - // Check if isolation level is REPEATABLE_READ or higher. - Integer isolationLevel = springReflection.getCurrentTransactionIsolationLevel(); - // Spring returns null when no explicit isolation level is set (database default). - // Most databases default to READ_COMMITTED, so we return false to ensure fresh - // data is fetched on each read. Users who want cached instances should explicitly - // set REPEATABLE_READ or higher. - if (isolationLevel == null || isolationLevel < 0) { - return false; - } - return isolationLevel >= Connection.TRANSACTION_REPEATABLE_READ; - } - - @Override - public EntityCache, ?> entityCache(@Nonnull Class> entityType, - @Nonnull CacheRetention retention) { - // Cache is used for dirty checking and/or identity preservation. - // Whether cached instances are returned during reads is controlled by isRepeatableRead(). - // - // We use computeIfAbsent so the "get or create" is a single operation. - // - // Why: - // - This TransactionContext is bound once per physical Spring transaction via TransactionSynchronizationManager. - // That already gives correct cache scoping for REQUIRED and REQUIRES_NEW. We do not implement propagation - // rules here. - // - This class intentionally does not try to clear or split caches for NESTED savepoints. Spring does not - // expose reliable hooks here for "rolled back to savepoint", only for transaction completion. - // - computeIfAbsent avoids duplicate allocations and keeps the method simpler and harder to get wrong. - return caches.computeIfAbsent(entityType, k -> new EntityCacheImpl<>(retention)); - } - - @Override - public EntityCache, ?> getEntityCache(@Nonnull Class> entityType) { - var cache = caches.get(entityType); - if (cache == null) { - throw new IllegalStateException("No entity cache exists for " + entityType.getName() + "."); - } - return cache; - } - - @Override - public EntityCache, ?> findEntityCache(@Nonnull Class> entityType) { - return caches.get(entityType); - } - - @Override - public void clearAllEntityCaches() { - for (EntityCache, ?> cache : caches.values()) { - cache.clear(); - } - } - - @Override - @SuppressWarnings("unchecked") - public Decorator getDecorator(@Nonnull Class resourceType) { - // noop decorator as requested - return (Decorator) noopDecorator; - } - } - - /** - * Reflection wrapper for org.springframework.transaction.support.TransactionSynchronizationManager - * to keep Spring as an optional dependency. - */ - private static final class SpringReflection { - private static final String TSM_FQCN = - "org.springframework.transaction.support.TransactionSynchronizationManager"; - private static final String TS_FQCN = - "org.springframework.transaction.support.TransactionSynchronization"; - - private final Method isActualTransactionActive; - private final Method getCurrentTransactionIsolationLevel; - private final Method getResource; - private final Method bindResource; - private final Method registerSynchronization; - private final Method unbindResourceIfPossible; - private final Class transactionSynchronizationType; - - private SpringReflection( - Method isActualTransactionActive, - Method getCurrentTransactionIsolationLevel, - Method getResource, - Method bindResource, - Method registerSynchronization, - Method unbindResourceIfPossible, - Class transactionSynchronizationType - ) { - this.isActualTransactionActive = isActualTransactionActive; - this.getCurrentTransactionIsolationLevel = getCurrentTransactionIsolationLevel; - this.getResource = getResource; - this.bindResource = bindResource; - this.registerSynchronization = registerSynchronization; - this.unbindResourceIfPossible = unbindResourceIfPossible; - this.transactionSynchronizationType = transactionSynchronizationType; - } - - static SpringReflection tryLoad() { - try { - ClassLoader classLoader = DefaultTransactionTemplateProviderImpl.class.getClassLoader(); - Class tsm = Class.forName(TSM_FQCN, false, classLoader); - Method isActualTransactionActive = tsm.getMethod("isActualTransactionActive"); - Method getCurrentTransactionIsolationLevel = tsm.getMethod("getCurrentTransactionIsolationLevel"); - Method getResource = tsm.getMethod("getResource", Object.class); - Method bindResource = tsm.getMethod("bindResource", Object.class, Object.class); - // Cleanup hooks (may not exist in very old Spring). - Method registerSynchronization = null; - Method unbindResourceIfPossible = null; - Class ts = null; - try { - ts = Class.forName(TS_FQCN, false, classLoader); - registerSynchronization = tsm.getMethod("registerSynchronization", ts); - unbindResourceIfPossible = tsm.getMethod("unbindResourceIfPossible", Object.class); - } catch (Throwable ignored) { - // No cleanup support available via reflection; we'll run without it. - } - return new SpringReflection( - isActualTransactionActive, - getCurrentTransactionIsolationLevel, - getResource, - bindResource, - registerSynchronization, - unbindResourceIfPossible, - ts - ); - } catch (Throwable ignored) { - return null; - } - } - - boolean isActualTransactionActive() { - try { - return (boolean) isActualTransactionActive.invoke(null); - } catch (Throwable t) { - return false; - } - } - - Integer getCurrentTransactionIsolationLevel() { - try { - return (Integer) getCurrentTransactionIsolationLevel.invoke(null); - } catch (Throwable t) { - return null; - } - } - - Object getResource(Object key) { - try { - return getResource.invoke(null, key); - } catch (Throwable t) { - return null; - } - } - - void bindResource(Object key, Object value) { - try { - bindResource.invoke(null, key, value); - } catch (RuntimeException re) { - throw re; - } catch (Throwable t) { - throw new RuntimeException(t); - } - } - - void registerCleanupOnTxCompletion(Object key) { - // If we could not reflect the synchronization APIs, we cannot auto-clean. - if (registerSynchronization == null || unbindResourceIfPossible == null || transactionSynchronizationType == null) { - return; - } - try { - Object sync = Proxy.newProxyInstance( - transactionSynchronizationType.getClassLoader(), - new Class[]{transactionSynchronizationType}, - (proxy, method, args) -> { - String name = method.getName(); - if ("afterCompletion".equals(name)) { - // afterCompletion(int status) - try { - unbindResourceIfPossible.invoke(null, key); - } catch (Throwable ignored) { - // best effort - } - return null; - } - // Default return values for other methods. - Class rt = method.getReturnType(); - if (rt == boolean.class) return false; - if (rt == int.class) return 0; - if (rt == void.class) return null; - return null; - } - ); - registerSynchronization.invoke(null, sync); - } catch (Throwable ignored) { - // Best effort cleanup registration; if this fails, we at least will not break tx execution. - } - } - } } diff --git a/storm-core/src/main/java/st/orm/core/spi/ExceptionContext.java b/storm-core/src/main/java/st/orm/core/spi/ExceptionContext.java new file mode 100644 index 000000000..09d1d70b8 --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/spi/ExceptionContext.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.core.spi; + +import java.util.Optional; +import st.orm.Data; +import st.orm.core.template.SqlOperation; + +/** + * Describes the execution context in which a failure occurred. + * + *

An instance of this interface is passed to the {@link ExceptionMapper} configured on the ORM template, allowing + * the mapper to base its translation on the SQL operation, the statement text and the affected data type.

+ * + * @see ExceptionMapper + * @since 1.13 + */ +public interface ExceptionContext { + + /** + * Classifies the kind of SQL statement that failed. + * + * @return the SQL operation; {@link SqlOperation#UNDEFINED} when the operation is unknown. + */ + SqlOperation operation(); + + /** + * Returns the SQL statement that failed, with all parameters replaced by placeholders. + * + * @return the SQL statement, or empty when no statement is associated with the failure. + */ + Optional statement(); + + /** + * Returns the entity or projection type primarily affected by the failed operation. + * + * @return the affected data type, or empty when the operation is not associated with a specific type. + */ + Optional> dataType(); + + /** + * Returns a description of the transaction in whose scope the failure occurred. + * + * @return the transaction description, or empty when no transaction is active or the transaction subsystem does + * not provide a description. + */ + Optional transactionDescription(); +} diff --git a/storm-core/src/main/java/st/orm/core/spi/ExceptionMapper.java b/storm-core/src/main/java/st/orm/core/spi/ExceptionMapper.java new file mode 100644 index 000000000..4eb202f4a --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/spi/ExceptionMapper.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.core.spi; + +import jakarta.annotation.Nonnull; +import st.orm.PersistenceException; + +/** + * Maps failures raised during query execution to the runtime exception thrown to the caller. + * + *

The default mapper wraps failures in {@link PersistenceException}. Integrations may translate to platform + * exception hierarchies instead, such as Spring's {@code DataAccessException}.

+ * + *

Exception mappers are configured per ORM template via the template builder; they are deliberately not + * discovered through the {@code ServiceLoader} mechanism. The framework enriches the failure with SQL diagnostics + * (as a suppressed exception on the cause) before invoking the mapper, so the mapper only decides the exception + * type that is thrown.

+ * + * @see ExceptionContext + * @since 1.13 + */ +@FunctionalInterface +public interface ExceptionMapper { + + /** + * Maps the given failure to the runtime exception that is thrown to the caller. + * + * @param cause the failure; typically a {@code SQLException} or {@link PersistenceException}. The framework has + * already attached SQL diagnostics as a suppressed exception where available. + * @param context the execution context of the failure; never {@code null}. + * @return the exception to throw; never {@code null}. + */ + RuntimeException map(@Nonnull Throwable cause, @Nonnull ExceptionContext context); + + /** + * Returns the default exception mapper. + * + *

The default mapper passes {@link PersistenceException} instances through unchanged and wraps any other + * failure in a new {@link PersistenceException}.

+ * + * @return the default exception mapper. + */ + static ExceptionMapper defaultMapper() { + return (cause, context) -> cause instanceof PersistenceException persistenceException + ? persistenceException + : new PersistenceException(cause); + } +} diff --git a/storm-core/src/main/java/st/orm/core/spi/Providers.java b/storm-core/src/main/java/st/orm/core/spi/Providers.java index d513ba756..33fa96349 100644 --- a/storm-core/src/main/java/st/orm/core/spi/Providers.java +++ b/storm-core/src/main/java/st/orm/core/spi/Providers.java @@ -21,6 +21,7 @@ import static java.util.Optional.ofNullable; import static java.util.ServiceLoader.load; import static java.util.stream.Collectors.collectingAndThen; +import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.toList; import static java.util.stream.StreamSupport.stream; @@ -38,6 +39,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; import java.util.function.Supplier; +import java.util.stream.Stream; import javax.sql.DataSource; import st.orm.Data; import st.orm.Entity; @@ -103,16 +105,30 @@ private static Supplier> createProviders(Class p /** * Returns a list of all services that are loaded by the specified {@code loader}. * + *

Note that {@link Provider#isEnabled()} is deliberately not evaluated here: the returned list is cached for + * the lifetime of the class loader, whereas enablement may depend on runtime state. Enablement is re-evaluated + * at each resolution via {@link #enabled}.

+ * * @param loader loader of services. * @param service type. * @return a list of all services loaded by the specified {@code loader}. */ private static List toUnmodifiableList(@Nonnull ServiceLoader loader) { return stream(loader.spliterator(), false) - .filter(Provider::isEnabled) .collect(collectingAndThen(toList(), Collections::unmodifiableList)); } + /** + * Returns a stream of the currently enabled providers from the given cached provider list. + * + * @param providers the cached provider list supplier. + * @param provider type. + * @return a stream of enabled providers. + */ + private static Stream enabled(@Nonnull Supplier> providers) { + return providers.get().stream().filter(Provider::isEnabled); + } + private static final AtomicReference ORM_REFLECTION = new AtomicReference<>(); /** @@ -126,7 +142,7 @@ record FieldKey(Class declaringType, String name) { private static final Map> ORM_CONVERTERS = new ConcurrentHashMap<>(); public static ORMReflection getORMReflection() { - return ORM_REFLECTION.updateAndGet(value -> requireNonNullElseGet(value, () -> Orderable.sort(ORM_REFLECTION_PROVIDERS.get().stream()) + return ORM_REFLECTION.updateAndGet(value -> requireNonNullElseGet(value, () -> Orderable.sort(enabled(ORM_REFLECTION_PROVIDERS)) .map(ORMReflectionProvider::getReflection) .findFirst() .orElseThrow())); @@ -134,7 +150,7 @@ public static ORMReflection getORMReflection() { public static Optional getORMConverter(@Nonnull RecordField field) { return ORM_CONVERTERS.computeIfAbsent(new FieldKey(field), ignore -> - Orderable.sort(ORM_CONVERTER_PROVIDERS.get().stream()) + Orderable.sort(enabled(ORM_CONVERTER_PROVIDERS)) .map(p -> p.getConverter(field)) .filter(Optional::isPresent) .map(Optional::get) @@ -145,7 +161,7 @@ public static > EntityRepository getEntityReposi @Nonnull ORMTemplate ormTemplate, @Nonnull Model model, @Nonnull Predicate filter) { - return Orderable.sort(ENTITY_REPOSITORY_PROVIDERS.get().stream()) + return Orderable.sort(enabled(ENTITY_REPOSITORY_PROVIDERS)) .filter(filter) .map(provider -> provider.getEntityRepository(ormTemplate, model)) .findFirst() @@ -156,7 +172,7 @@ public static > ProjectionRepository getProj @Nonnull ORMTemplate ormTemplate, @Nonnull Model model, @Nonnull Predicate filter) { - return Orderable.sort(PROJECTION_REPOSITORY_PROVIDERS.get().stream()) + return Orderable.sort(enabled(PROJECTION_REPOSITORY_PROVIDERS)) .filter(filter) .map(provider -> provider.getProjectionRepository(ormTemplate, model)) .findFirst() @@ -170,7 +186,7 @@ public static QueryBuilder selectFrom( @Nonnull TemplateString template, boolean subquery, @Nonnull Supplier> modelSupplier) { - return Orderable.sort(QUERY_BUILDER_REPOSITORY_PROVIDERS.get().stream()) + return Orderable.sort(enabled(QUERY_BUILDER_REPOSITORY_PROVIDERS)) .map(provider -> provider.selectFrom(queryTemplate, fromType, selectType, template, subquery, modelSupplier)) .findFirst() .orElseThrow(); @@ -182,7 +198,7 @@ public static QueryBuilder, ID> s @Nonnull Class refType, @Nonnull Class pkType, @Nonnull Supplier> modelSupplier) { - return Orderable.sort(QUERY_BUILDER_REPOSITORY_PROVIDERS.get().stream()) + return Orderable.sort(enabled(QUERY_BUILDER_REPOSITORY_PROVIDERS)) .map(provider -> provider.selectRefFrom(queryTemplate, fromType, refType, pkType, modelSupplier)) .findFirst() .orElseThrow(); @@ -192,7 +208,7 @@ public static QueryBuilder, ID> s @Nonnull QueryTemplate queryTemplate, @Nonnull Class fromType, @Nonnull Supplier> modelSupplier) { - return Orderable.sort(QUERY_BUILDER_REPOSITORY_PROVIDERS.get().stream()) + return Orderable.sort(enabled(QUERY_BUILDER_REPOSITORY_PROVIDERS)) .map(provider -> provider.deleteFrom(queryTemplate, fromType, modelSupplier)) .findFirst() .orElseThrow(); @@ -203,7 +219,7 @@ public static SqlDialect getSqlDialect() { } public static SqlDialect getSqlDialect(@Nonnull StormConfig config) { - return Orderable.sort(SQL_DIALECT_PROVIDERS.get().stream()) + return Orderable.sort(enabled(SQL_DIALECT_PROVIDERS)) .map(p -> p.getSqlDialect(config)) .findFirst() .orElseThrow(); @@ -215,7 +231,7 @@ public static SqlDialect getSqlDialect(@Nonnull Predicate filter, @Nonnull StormConfig config) { - return Orderable.sort(SQL_DIALECT_PROVIDERS.get().stream()) + return Orderable.sort(enabled(SQL_DIALECT_PROVIDERS)) .filter(filter) .map(p -> p.getSqlDialect(config)) .findFirst() @@ -265,7 +281,7 @@ public static String getDatabaseProductName(@Nonnull Connection connection) { * @since 1.11 */ public static @Nullable SqlDialectProvider getSqlDialectProvider(@Nonnull String databaseProductName) { - return Orderable.sort(SQL_DIALECT_PROVIDERS.get().stream()) + return Orderable.sort(enabled(SQL_DIALECT_PROVIDERS)) .filter(p -> p.supports(databaseProductName)) .findFirst() .orElse(null); @@ -282,7 +298,7 @@ public static String getDatabaseProductName(@Nonnull Connection connection) { */ public static SqlDialect getSqlDialect(@Nonnull DataSource dataSource, @Nonnull StormConfig config) { String productName = getDatabaseProductName(dataSource); - return Orderable.sort(SQL_DIALECT_PROVIDERS.get().stream()) + return Orderable.sort(enabled(SQL_DIALECT_PROVIDERS)) .filter(p -> p.supports(productName)) .map(p -> p.getSqlDialect(config)) .findFirst() @@ -300,33 +316,90 @@ public static SqlDialect getSqlDialect(@Nonnull DataSource dataSource, @Nonnull */ public static SqlDialect getSqlDialect(@Nonnull Connection connection, @Nonnull StormConfig config) { String productName = getDatabaseProductName(connection); - return Orderable.sort(SQL_DIALECT_PROVIDERS.get().stream()) + return Orderable.sort(enabled(SQL_DIALECT_PROVIDERS)) .filter(p -> p.supports(productName)) .map(p -> p.getSqlDialect(config)) .findFirst() .orElseThrow(); } - private static final AtomicReference CONNECTION_PROVIDER = new AtomicReference<>(); + /** + * Resolves the fallback connection provider via {@code ServiceLoader} discovery. + * + *

This is the provider used by templates that have not been configured with an explicit + * {@link ConnectionProvider} via the template builder. Enablement is re-evaluated on every resolution, and an + * ambiguous resolution fails fast.

+ * + * @return the fallback connection provider. + * @throws PersistenceException if no provider is found or the resolution is ambiguous. + * @since 1.13 + */ + public static ConnectionProvider getConnectionProvider() { + return selectUnique(CONNECTION_PROVIDERS, "connection provider", + "ORMTemplate.builder(dataSource).connectionProvider(...)"); + } - public static Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { - return CONNECTION_PROVIDER.updateAndGet(value -> requireNonNullElseGet(value, () -> Orderable.sort(CONNECTION_PROVIDERS.get().stream()) - .findFirst() - .orElseThrow()) - ).getConnection(dataSource, context); + /** + * Resolves the fallback transaction template provider via {@code ServiceLoader} discovery. + * + *

This is the provider used by templates that have not been configured with an explicit + * {@link TransactionTemplateProvider} via the template builder. Enablement is re-evaluated on every resolution, + * and an ambiguous resolution fails fast.

+ * + * @return the fallback transaction template provider. + * @throws PersistenceException if no provider is found or the resolution is ambiguous. + * @since 1.13 + */ + public static TransactionTemplateProvider getTransactionTemplateProvider() { + return selectUnique(TRANSACTION_TEMPLATE_PROVIDERS, "transaction template provider", + "ORMTemplate.builder(dataSource).transactionTemplateProvider(...)"); } - public static void releaseConnection(@Nonnull Connection connection, @Nonnull DataSource dataSource, @Nullable TransactionContext context) { - CONNECTION_PROVIDER.updateAndGet(value -> requireNonNullElseGet(value, () -> Orderable.sort(CONNECTION_PROVIDERS.get().stream()) - .findFirst() - .orElseThrow()) - ).releaseConnection(connection, dataSource, context); + /** + * Selects the single winning provider from the given cached provider list. + * + *

Fails fast when the two top-ranked candidates are unordered peers: silently picking one of several equally + * eligible providers would make behavior dependent on classpath order.

+ */ + private static S selectUnique(@Nonnull Supplier> providers, + @Nonnull String description, + @Nonnull String remedy) { + var sorted = Orderable.sort(enabled(providers)).toList(); + if (sorted.isEmpty()) { + throw new PersistenceException("No %s found on the classpath.".formatted(description)); + } + if (sorted.size() > 1 && !isOrderedBefore(sorted.get(0).getClass(), sorted.get(1).getClass())) { + throw new PersistenceException(("Multiple candidates found for %s without a defined order: %s. " + + "Configure the desired implementation explicitly via %s.") + .formatted(description, + sorted.stream().map(provider -> provider.getClass().getName()).collect(joining(", ")), + remedy)); + } + return sorted.getFirst(); } - public static TransactionTemplate getTransactionTemplate() { - return Orderable.sort(TRANSACTION_TEMPLATE_PROVIDERS.get().stream()) - .map(TransactionTemplateProvider::getTransactionTemplate) - .findFirst() - .orElseThrow(); + /** + * Returns whether {@code first} is explicitly ordered before {@code second} by the {@link Orderable} annotations. + */ + private static boolean isOrderedBefore(@Nonnull Class first, @Nonnull Class second) { + boolean firstBeforeAny = first.isAnnotationPresent(Orderable.BeforeAny.class); + boolean secondBeforeAny = second.isAnnotationPresent(Orderable.BeforeAny.class); + if (firstBeforeAny && !secondBeforeAny) { + return true; + } + boolean firstAfterAny = first.isAnnotationPresent(Orderable.AfterAny.class); + boolean secondAfterAny = second.isAnnotationPresent(Orderable.AfterAny.class); + if (secondAfterAny && !firstAfterAny) { + return true; + } + var before = first.getAnnotation(Orderable.Before.class); + if (before != null && asList(before.value()).contains(second)) { + return true; + } + var after = second.getAnnotation(Orderable.After.class); + if (after != null && asList(after.value()).contains(first)) { + return true; + } + return false; } } diff --git a/storm-core/src/main/java/st/orm/core/spi/QueryContext.java b/storm-core/src/main/java/st/orm/core/spi/QueryContext.java new file mode 100644 index 000000000..b94a8a3e5 --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/spi/QueryContext.java @@ -0,0 +1,90 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.core.spi; + +import java.util.Optional; +import java.util.OptionalInt; +import st.orm.Data; +import st.orm.core.template.SqlOperation; + +/** + * Describes a single statement execution observed by a {@link QueryObserver}. + * + *

The {@link #operation()}, {@link #dataType()} and {@link #kind()} properties are low-cardinality and suitable + * as metric tags. The {@link #statement()} property is high-cardinality and is intended for trace attributes only; + * it must never be used as a metric tag.

+ * + * @see QueryObserver + * @since 1.13 + */ +public interface QueryContext { + + /** + * Classifies the kind of SQL statement being executed. + * + * @return the SQL operation; {@link SqlOperation#UNDEFINED} when the operation is unknown. + */ + SqlOperation operation(); + + /** + * Returns the entity or projection type primarily targeted by the statement. + * + * @return the data type, or empty when the statement is not associated with a specific type. + */ + Optional> dataType(); + + /** + * Returns how the statement is executed. + * + * @return the execution kind. + */ + ExecutionKind kind(); + + /** + * Returns the number of statements in the batch. + * + * @return the batch size; present only for {@link ExecutionKind#BATCH} executions when the size is known at + * execution time. + */ + OptionalInt batchSize(); + + /** + * Returns the SQL statement being executed, with all parameters replaced by placeholders. + * + *

Note: this value is high-cardinality; use it for trace attributes only, never as a metric + * tag.

+ * + * @return the SQL statement, or empty when no statement text is available. + */ + Optional statement(); + + /** + * Classifies how a statement is executed. + * + * @since 1.13 + */ + enum ExecutionKind { + + /** The statement produces a result set. */ + QUERY, + + /** The statement is executed as a single update. */ + UPDATE, + + /** The statement is executed as a batch of updates. */ + BATCH + } +} diff --git a/storm-core/src/main/java/st/orm/core/spi/QueryFactory.java b/storm-core/src/main/java/st/orm/core/spi/QueryFactory.java index aabc57c78..6c6ef0f33 100644 --- a/storm-core/src/main/java/st/orm/core/spi/QueryFactory.java +++ b/storm-core/src/main/java/st/orm/core/spi/QueryFactory.java @@ -68,4 +68,17 @@ public interface QueryFactory { default @Nullable DataSource dataSource() { return null; } + + /** + * Returns the transaction template provider used by this factory. + * + *

The default implementation resolves the fallback provider via {@code ServiceLoader} discovery; + * implementations that carry instance-scoped integration strategies return their configured provider.

+ * + * @return the transaction template provider. + * @since 1.13 + */ + default TransactionTemplateProvider transactionTemplateProvider() { + return Providers.getTransactionTemplateProvider(); + } } diff --git a/storm-core/src/main/java/st/orm/core/spi/QueryObserver.java b/storm-core/src/main/java/st/orm/core/spi/QueryObserver.java new file mode 100644 index 000000000..59fe99277 --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/spi/QueryObserver.java @@ -0,0 +1,93 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.core.spi; + +import jakarta.annotation.Nonnull; + +/** + * Observes query executions performed by an ORM template. + * + *

Query observers are designed to back metrics and tracing bindings, such as Micrometer Observations. They are + * configured per ORM template via the template builder; they are deliberately not discovered through the + * {@code ServiceLoader} mechanism.

+ * + *

Observer failures are contained by the framework and never affect query execution: an exception thrown by an + * observer is logged and discarded, and the query result or failure is delivered to the caller unchanged.

+ * + * @see QueryContext + * @since 1.13 + */ +public interface QueryObserver { + + /** + * Called when a statement execution starts. + * + *

The returned observation is closed exactly once: for updates and batches when execution completes; for + * result streams when the stream is closed.

+ * + * @param context describes the statement execution; never {@code null}. + * @return the observation tracking this execution; never {@code null}. + */ + Observation onExecute(@Nonnull QueryContext context); + + /** + * Tracks a single observed statement execution. + * + * @since 1.13 + */ + interface Observation { + + /** + * An observation that ignores all signals. + */ + Observation NOOP = new Observation() { + @Override + public void error(@Nonnull Throwable throwable) { + // Ignore. + } + + @Override + public void close() { + // Ignore. + } + }; + + /** + * Signals that the observed execution failed. + * + *

Invoked at most once, before {@link #close()}.

+ * + * @param throwable the failure. + */ + void error(@Nonnull Throwable throwable); + + /** + * Signals that the observed execution has completed. + * + *

Invoked exactly once per observation.

+ */ + void close(); + } + + /** + * Returns an observer that ignores all executions. + * + * @return the no-op query observer. + */ + static QueryObserver noop() { + return context -> Observation.NOOP; + } +} diff --git a/storm-core/src/main/java/st/orm/core/spi/RefFactory.java b/storm-core/src/main/java/st/orm/core/spi/RefFactory.java index 84b688f37..61fd3c817 100644 --- a/storm-core/src/main/java/st/orm/core/spi/RefFactory.java +++ b/storm-core/src/main/java/st/orm/core/spi/RefFactory.java @@ -16,6 +16,7 @@ package st.orm.core.spi; import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import st.orm.Data; import st.orm.Ref; @@ -26,6 +27,19 @@ */ public interface RefFactory { + /** + * Returns the transaction context that is active for the template backing this factory, or {@code null} when no + * transaction is active. + * + *

This is an observing lookup: it never starts a transaction.

+ * + * @return the active transaction context, or {@code null}. + * @since 1.13 + */ + default @Nullable TransactionContext transactionContext() { + return null; + } + /** * Creates a ref instance for the specified record {@code type} and {@code pk}. This method can be used to generate * ref instances for entities, projections and regular records. diff --git a/storm-core/src/main/java/st/orm/core/spi/RefFactoryImpl.java b/storm-core/src/main/java/st/orm/core/spi/RefFactoryImpl.java index 47ae4cd0d..858114f84 100644 --- a/storm-core/src/main/java/st/orm/core/spi/RefFactoryImpl.java +++ b/storm-core/src/main/java/st/orm/core/spi/RefFactoryImpl.java @@ -16,7 +16,6 @@ package st.orm.core.spi; import static java.util.Objects.requireNonNull; -import static st.orm.core.spi.Providers.getTransactionTemplate; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; @@ -48,6 +47,18 @@ public RefFactoryImpl(@Nonnull QueryTemplate template) { this.template = requireNonNull(template, "template"); } + /** + * Returns the transaction context that is active for the template backing this factory, or {@code null} when no + * transaction is active. + * + * @return the active transaction context, or {@code null}. + * @since 1.13 + */ + @Override + public @Nullable TransactionContext transactionContext() { + return TransactionScope.peekContext(template.transactionTemplateProvider()); + } + /** * Creates a ref instance for the specified record {@code type} and {@code pk}. This method can be used to generate * ref instances for entities, projections and regular records. @@ -66,9 +77,9 @@ public Ref create(@Nonnull Class type, @Nonnull ID pk var supplier = new LazySupplier<>(() -> { // Cache-first lookup for entities. if (Entity.class.isAssignableFrom(type)) { - var context = getTransactionTemplate().currentContext(); - if (context.isPresent()) { - var cache = (EntityCache) context.get() + var context = transactionContext(); + if (context != null) { + var cache = (EntityCache) context .findEntityCache((Class>) type); if (cache != null) { var cached = cache.get(pk); diff --git a/storm-core/src/main/java/st/orm/core/spi/TransactionScope.java b/storm-core/src/main/java/st/orm/core/spi/TransactionScope.java new file mode 100644 index 000000000..9fdae7e34 --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/spi/TransactionScope.java @@ -0,0 +1,423 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.core.spi; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import st.orm.PersistenceException; +import st.orm.core.spi.TransactionTemplate.TransactionHandle; + +/** + * A pending transaction scope that binds to the first ORM template that executes inside it. + * + *

A scope is opened by the transaction API before the transactional block runs, carrying only the requested + * transaction options. No transaction subsystem is selected at that point. The scope is materialized by the + * first template that acquires a connection inside the block: that template's + * {@link TransactionTemplateProvider} opens the actual transaction with the scope's options. Subsequent executions + * by templates configured with the same provider instance join the materialized transaction; executions by + * templates configured with a different provider fail fast, since a single commit cannot span two transaction + * subsystems.

+ * + *

Scopes nest: a scope opened while another scope is active joins or suspends the outer transaction according to + * its propagation, evaluated by the owning provider at materialization time. A scope that is never materialized + * completes as a no-op, propagating a buffered rollback-only mark to its parent when its propagation joins the outer + * transaction.

+ * + *

The current scope is tracked per thread via {@link #holder()}; coroutine or executor integrations propagate it + * across threads by installing the scope in the holder for the duration of a task.

+ * + * @see TransactionTemplateProvider + * @see TransactionTemplate + * @since 1.13 + */ +public final class TransactionScope { + + /** + * The transaction options requested for a scope. + * + * @param propagation the propagation behavior, such as {@code REQUIRED} or {@code REQUIRES_NEW}; + * {@code null} for the provider default ({@code REQUIRED}). + * @param isolation the isolation level as defined by {@link java.sql.Connection}, or {@code null} for the + * provider default. + * @param timeoutSeconds the transaction timeout in seconds, or {@code null} for no timeout. + * @param readOnly whether the transaction is read-only, or {@code null} for the provider default. + * @param suspendMode whether the transaction is created to be used in suspend mode. + * @since 1.13 + */ + public record Options(@Nullable String propagation, + @Nullable Integer isolation, + @Nullable Integer timeoutSeconds, + @Nullable Boolean readOnly, + boolean suspendMode) { + } + + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + /** + * Returns the thread local that holds the current transaction scope. + * + *

Intended for integrations that propagate the scope across threads, such as coroutine context elements. + * Application code should not modify the holder directly.

+ * + * @return the thread local holding the current transaction scope. + */ + public static ThreadLocal holder() { + return CURRENT; + } + + /** + * Returns the transaction scope that is active on the current thread, if any. + * + * @return the current transaction scope, or {@code null} when no scope is active. + */ + public static @Nullable TransactionScope current() { + return CURRENT.get(); + } + + /** + * Opens a new transaction scope on the current thread. + * + *

The new scope's parent is the scope currently active on this thread. The scope is installed as the current + * scope; callers must invoke {@link #close()} on the same thread when the transactional block completes.

+ * + * @param options the requested transaction options. + * @return the newly opened scope. + */ + public static TransactionScope open(@Nonnull Options options) { + var scope = new TransactionScope(options, CURRENT.get()); + CURRENT.set(scope); + return scope; + } + + /** + * Creates a new transaction scope without installing it on the current thread. + * + *

Intended for integrations that manage scope propagation themselves, such as coroutine context elements. + * {@link #close()} must not be called on scopes created through this method.

+ * + * @param options the requested transaction options. + * @param parent the parent scope, or {@code null} when this is an outermost scope. + * @return the new scope. + */ + public static TransactionScope create(@Nonnull Options options, @Nullable TransactionScope parent) { + return new TransactionScope(options, parent); + } + + /** + * Returns the transaction context for the given provider, materializing the current scope if needed. + * + *

This is the connection-acquisition entry point used by ORM templates. When no scope is active, the + * provider's own current context is returned, which covers externally managed transactions.

+ * + * @param provider the transaction template provider of the executing template. + * @return the transaction context to execute under, or {@code null} when no transaction is active. + * @throws PersistenceException if the active scope is owned by a different provider. + */ + public static @Nullable TransactionContext resolveContext(@Nonnull TransactionTemplateProvider provider) { + var scope = CURRENT.get(); + if (scope != null) { + return scope.getOrMaterializeContext(provider); + } + return provider.getTransactionTemplate().currentContext().orElse(null); + } + + /** + * Returns the transaction context for the given provider without materializing any scope. + * + *

Intended for diagnostic and cache lookups that must observe, but never start, a transaction. Returns the + * context of the nearest materialized scope when it is owned by the given provider, or the provider's own + * current context when no scope is active.

+ * + * @param provider the transaction template provider of the executing template. + * @return the active transaction context, or {@code null} when none is active for this provider. + */ + public static @Nullable TransactionContext peekContext(@Nonnull TransactionTemplateProvider provider) { + var scope = CURRENT.get(); + if (scope != null) { + for (var candidate = scope; candidate != null; candidate = candidate.parent) { + var candidateHandle = candidate.handle; + if (candidateHandle != null) { + return candidate.owner == provider ? candidateHandle.context() : null; + } + } + return null; + } + return provider.getTransactionTemplate().currentContext().orElse(null); + } + + private final Options options; + private final @Nullable TransactionScope parent; + private final @Nullable Long deadlineNanos; + + private volatile @Nullable TransactionTemplateProvider owner; + private volatile @Nullable TransactionHandle handle; + private boolean rollbackOnly; // Buffered until materialization; guarded by this. + private boolean rollbackInherited; // Set when the mark came from a joined inner scope; guarded by this. + + private TransactionScope(@Nonnull Options options, @Nullable TransactionScope parent) { + this.options = options; + this.parent = parent; + this.deadlineNanos = options.timeoutSeconds() != null + ? System.nanoTime() + options.timeoutSeconds() * 1_000_000_000L + : null; + } + + /** + * Returns the requested transaction options. + * + * @return the transaction options. + */ + public Options options() { + return options; + } + + /** + * Returns the parent scope. + * + * @return the parent scope, or {@code null} when this is an outermost scope. + */ + public @Nullable TransactionScope parent() { + return parent; + } + + /** + * Returns whether this scope has been materialized by a provider. + * + * @return {@code true} if a transaction has been opened for this scope. + */ + public boolean isMaterialized() { + return handle != null; + } + + /** + * Returns the provider that materialized this scope. + * + * @return the owning provider, or {@code null} when the scope has not been materialized. + */ + public @Nullable TransactionTemplateProvider owner() { + return owner; + } + + /** + * Returns the transaction context of this scope, without materializing it. + * + *

Intended for diagnostics, such as describing the transaction's characteristics in error messages.

+ * + * @return the materialized transaction context, or {@code null} when the scope has not been materialized. + */ + public @Nullable TransactionContext materializedContext() { + var currentHandle = handle; + return currentHandle != null ? currentHandle.context() : null; + } + + /** + * Returns the transaction context of this scope, materializing it through the given provider if needed. + * + * @param provider the transaction template provider of the executing template. + * @return the transaction context of this scope. + * @throws PersistenceException if this scope, or an outer scope it must join, is owned by a different provider. + */ + public synchronized TransactionContext getOrMaterializeContext(@Nonnull TransactionTemplateProvider provider) { + var existingHandle = handle; + if (existingHandle != null) { + if (owner != provider) { + throw providerMismatch(provider, owner); + } + return existingHandle.context(); + } + // Materialize enclosing scopes first, outermost-down, so every enclosing transaction block has its frame in + // place before this scope opens. This preserves the semantics of eagerly entered transaction blocks: joining + // propagations share the outer transaction, MANDATORY finds it, NEVER detects it, and REQUIRES_NEW suspends + // it. The provider-identity check happens naturally in the ancestor's own materialization. + TransactionContext outerContext = parent != null ? parent.getOrMaterializeContext(provider) : null; + var template = provider.getTransactionTemplate(); + if (options.propagation() != null) { + template = template.propagation(options.propagation()); + } + if (options.isolation() != null) { + template = template.isolation(options.isolation()); + } + if (deadlineNanos != null) { + // The timeout counts from the moment the scope was opened, not from materialization. + template = template.timeout(remainingSeconds()); + } + if (options.readOnly() != null) { + template = template.readOnly(options.readOnly()); + } + var newHandle = template.open(outerContext, options.suspendMode()); + if (rollbackOnly) { + newHandle.status().setRollbackOnly(); + } + this.owner = provider; + this.handle = newHandle; + return newHandle.context(); + } + + /** + * Returns the number of seconds remaining until the scope's deadline, rounded up; {@code 0} when the deadline + * has already passed. + */ + private int remainingSeconds() { + assert deadlineNanos != null; + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + return 0; + } + return (int) Math.min(Integer.MAX_VALUE, (remainingNanos + 999_999_999L) / 1_000_000_000L); + } + + /** + * Returns whether this scope's deadline has passed. + * + *

Always {@code false} for scopes without a timeout. Callers use this to enforce timeouts on scopes that were + * never materialized; materialized scopes enforce their deadline through the owning provider.

+ * + * @return {@code true} if the scope has a timeout and it has expired. + */ + public boolean isDeadlineExpired() { + return deadlineNanos != null && System.nanoTime() >= deadlineNanos; + } + + /** + * Marks this scope so that the only possible outcome of the transaction is a rollback. + * + *

Before materialization the mark is buffered and applied when the transaction is opened; when this scope's + * propagation joins an outer transaction, the mark is propagated to the parent scope immediately.

+ */ + public synchronized void setRollbackOnly() { + var currentHandle = handle; + if (currentHandle != null) { + currentHandle.status().setRollbackOnly(); + return; + } + rollbackOnly = true; + if (isJoining() && parent != null) { + parent.markRollbackInherited(); + } + } + + /** + * Marks this scope rollback-only on behalf of a joined inner scope. + * + *

The inherited mark distinguishes "a joined scope doomed this transaction" from "this scope marked itself"; + * transaction APIs use it to raise an unexpected-rollback error when the outer block attempts to commit.

+ */ + private synchronized void markRollbackInherited() { + rollbackOnly = true; + rollbackInherited = true; + var currentHandle = handle; + if (currentHandle != null) { + currentHandle.status().setRollbackOnly(); + } + if (isJoining() && parent != null) { + parent.markRollbackInherited(); + } + } + + /** + * Returns whether this scope was marked rollback-only by a joined inner scope. + * + * @return {@code true} if the rollback-only mark was inherited from a joined inner scope. + */ + public synchronized boolean isRollbackInherited() { + return rollbackInherited; + } + + /** + * Returns whether this scope has been marked rollback-only. + * + * @return {@code true} if the transaction can only be rolled back. + */ + public synchronized boolean isRollbackOnly() { + var currentHandle = handle; + if (currentHandle != null) { + return currentHandle.status().isRollbackOnly(); + } + if (rollbackOnly) { + return true; + } + return isJoining() && parent != null && parent.isRollbackOnly(); + } + + /** + * Completes this scope. + * + *

When the scope has been materialized, the owning provider completes the transaction: it rolls back when + * {@code rollback} is {@code true} or the transaction has been marked rollback-only, and commits otherwise. + * When the scope was never materialized this is a no-op, except that a rollback outcome is propagated to the + * parent scope when this scope's propagation joins the outer transaction.

+ * + *

This method does not uninstall the scope from the current thread; callers opened via {@link #open(Options)} + * must additionally call {@link #close()}.

+ * + * @param rollback whether the transactional block failed and the transaction must be rolled back. + * @throws PersistenceException if the transaction subsystem raised an issue while completing. + */ + public synchronized void complete(boolean rollback) { + var currentHandle = handle; + if (currentHandle != null) { + currentHandle.complete(rollback); + return; + } + if ((rollback || rollbackOnly) && isJoining() && parent != null) { + parent.markRollbackInherited(); + } + } + + /** + * Uninstalls this scope from the current thread, restoring its parent as the current scope. + * + *

Must be called on the thread that opened the scope via {@link #open(Options)}, after {@link #complete}.

+ * + * @throws IllegalStateException if this scope is not the current scope of this thread. + */ + public void close() { + if (CURRENT.get() != this) { + throw new IllegalStateException("Transaction scope closed out of order or on a different thread."); + } + if (parent == null) { + CURRENT.remove(); + } else { + CURRENT.set(parent); + } + } + + /** + * Returns whether this scope's propagation joins an outer transaction rather than starting an independent one. + */ + private boolean isJoining() { + var propagation = options.propagation(); + return propagation == null + || propagation.equals("REQUIRED") + || propagation.equals("SUPPORTS") + || propagation.equals("MANDATORY"); + } + + private static PersistenceException providerMismatch(@Nonnull TransactionTemplateProvider provider, + @Nullable TransactionTemplateProvider owner) { + return new PersistenceException(("Transaction was started by %s, but the executing template is configured " + + "with %s. A transaction cannot span templates that use different transaction template providers; " + + "use templates that share the same provider instance within one transaction block.") + .formatted(describeProvider(owner), describeProvider(provider))); + } + + private static String describeProvider(@Nullable TransactionTemplateProvider provider) { + if (provider == null) { + return ""; + } + return "%s@%s".formatted(provider.getClass().getName(), Integer.toHexString(System.identityHashCode(provider))); + } +} diff --git a/storm-core/src/main/java/st/orm/core/spi/TransactionTemplate.java b/storm-core/src/main/java/st/orm/core/spi/TransactionTemplate.java index 3564691cb..10c19378e 100644 --- a/storm-core/src/main/java/st/orm/core/spi/TransactionTemplate.java +++ b/storm-core/src/main/java/st/orm/core/spi/TransactionTemplate.java @@ -17,13 +17,17 @@ import static java.util.Optional.ofNullable; -import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import java.util.Optional; import st.orm.PersistenceException; /** - * The transaction template is a functional interface that allows callers to let logic be executed in the scope of a - * new transaction. + * The transaction template allows a transaction to be opened for the template's configuration and completed once the + * transactional work has finished. + * + *

Transactions are opened and completed in two separate steps rather than around a callback. This enables lazy + * binding: a {@link TransactionScope} is materialized by the first template that executes inside it, at which point + * the transactional block is already running.

* * @since 1.5 */ @@ -78,12 +82,21 @@ public interface TransactionTemplate { TransactionTemplate timeout(int timeoutSeconds); /** - * Creates a new transaction context based on the current configuration of this transaction template. + * Opens a transaction based on the current configuration of this transaction template. + * + *

When {@code existing} is {@code null}, a new transaction context is created. When an existing context is + * given, the opened transaction joins, nests in, or suspends the existing transaction according to the configured + * propagation; the returned handle exposes the same context instance.

* + *

The physical transaction may start lazily, when the first connection is bound to the context.

+ * + * @param existing the transaction context to join, or {@code null} to create a new context. * @param suspendMode whether the transaction is created to be used in suspend mode. + * @return a handle used to observe and complete the opened transaction. * @throws PersistenceException if the transaction subsystem raised an issue, such as an invalid configuration. + * @since 1.13 */ - TransactionContext newContext(boolean suspendMode) throws PersistenceException; + TransactionHandle open(@Nullable TransactionContext existing, boolean suspendMode) throws PersistenceException; /** * Returns the current transaction context if any. @@ -102,13 +115,36 @@ default Optional currentContext() { ThreadLocal contextHolder(); /** - * Executes the specified action in the scope of a transaction. Exceptions raised by the action will be relayed - * and will mark the transaction as rollback only. + * Handle for a transaction opened via {@link #open}. * - * @param action action to preform in the scope of a transaction. - * @return result object. - * @param result object type. - * @throws PersistenceException if the transaction subsystem raised an issue. + * @since 1.13 */ - R execute(@Nonnull TransactionCallback action, @Nonnull TransactionContext context) throws PersistenceException; + interface TransactionHandle { + + /** + * Returns the transaction context of the opened transaction. + * + * @return the transaction context; never {@code null}. + */ + TransactionContext context(); + + /** + * Returns the status of the opened transaction. + * + * @return the transaction status; never {@code null}. + */ + TransactionStatus status(); + + /** + * Completes the opened transaction. + * + *

The transaction rolls back when {@code rollback} is {@code true} or the transaction has been marked + * rollback-only, and commits otherwise. Must be invoked exactly once.

+ * + * @param rollback whether the transactional work failed and the transaction must be rolled back. + * @throws PersistenceException if the transaction subsystem raised an issue while completing, or to signal an + * unexpected rollback or timeout. + */ + void complete(boolean rollback) throws PersistenceException; + } } diff --git a/storm-core/src/main/java/st/orm/core/template/ORMTemplate.java b/storm-core/src/main/java/st/orm/core/template/ORMTemplate.java index bbb2a22ab..d180dc1ec 100644 --- a/storm-core/src/main/java/st/orm/core/template/ORMTemplate.java +++ b/storm-core/src/main/java/st/orm/core/template/ORMTemplate.java @@ -15,7 +15,10 @@ */ package st.orm.core.template; +import static java.util.Objects.requireNonNull; + import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import java.sql.Connection; import java.util.List; import java.util.function.Predicate; @@ -28,6 +31,10 @@ import st.orm.core.repository.EntityRepository; import st.orm.core.repository.ProjectionRepository; import st.orm.core.repository.RepositoryLookup; +import st.orm.core.spi.ConnectionProvider; +import st.orm.core.spi.ExceptionMapper; +import st.orm.core.spi.QueryObserver; +import st.orm.core.spi.TransactionTemplateProvider; import st.orm.core.template.impl.PreparedStatementTemplateImpl; import st.orm.mapping.TemplateDecorator; @@ -370,4 +377,168 @@ static ORMTemplate of(@Nonnull Connection connection, @Nonnull StormConfig confi } return ((PreparedStatementTemplateImpl) decorated).toORM(); } + + /** + * Returns a builder for constructing an {@link ORMTemplate} with instance-scoped integration strategies. + * + *

The builder is the injection point for platform services: integrations hand their connection provider, + * transaction template provider, exception mapper and query observer to the template they construct, instead of + * relying on JVM-global discovery. Strategies that are not set fall back to {@code ServiceLoader} discovery for + * the connection and transaction template providers, and to the built-in defaults for the exception mapper and + * query observer.

+ * + *

Example usage: + *

{@code
+     * ORMTemplate orm = ORMTemplate.builder(dataSource)
+     *         .config(config)
+     *         .connectionProvider(connectionProvider)
+     *         .transactionTemplateProvider(transactionTemplateProvider)
+     *         .build();
+     * }
+ * + * @param dataSource the {@link DataSource} to use for database operations; must not be {@code null}. + * @return a builder for constructing the ORM template. + * @since 1.13 + */ + static Builder builder(@Nonnull DataSource dataSource) { + return new Builder(requireNonNull(dataSource, "dataSource"), null); + } + + /** + * Returns a builder for constructing an {@link ORMTemplate} backed by a single connection, with instance-scoped + * integration strategies. + * + *

Note: The caller is responsible for closing the connection after usage. Connection backed + * templates never acquire connections themselves, so no connection provider can be configured.

+ * + * @param connection the {@link Connection} to use for database operations; must not be {@code null}. + * @return a builder for constructing the ORM template. + * @since 1.13 + */ + static Builder builder(@Nonnull Connection connection) { + return new Builder(null, requireNonNull(connection, "connection")); + } + + /** + * Builder for constructing an {@link ORMTemplate} with instance-scoped integration strategies. + * + * @since 1.13 + */ + final class Builder { + private final @Nullable DataSource dataSource; + private final @Nullable Connection connection; + private StormConfig config = StormConfig.defaults(); + private @Nullable UnaryOperator decorator; + private @Nullable ConnectionProvider connectionProvider; + private @Nullable TransactionTemplateProvider transactionTemplateProvider; + private @Nullable ExceptionMapper exceptionMapper; + private @Nullable QueryObserver queryObserver; + + private Builder(@Nullable DataSource dataSource, @Nullable Connection connection) { + this.dataSource = dataSource; + this.connection = connection; + } + + /** + * Sets the Storm configuration to apply to the template instance. + * + * @param config the Storm configuration; must not be {@code null}. + * @return this builder. + */ + public Builder config(@Nonnull StormConfig config) { + this.config = requireNonNull(config, "config"); + return this; + } + + /** + * Sets a function that transforms the {@link TemplateDecorator} to customize template processing. + * + * @param decorator the decorator function; must not be {@code null}. + * @return this builder. + */ + public Builder decorator(@Nonnull UnaryOperator decorator) { + this.decorator = requireNonNull(decorator, "decorator"); + return this; + } + + /** + * Sets the connection provider used by the template to acquire and release connections. + * + *

Only valid for data source backed templates; {@link #build()} fails fast otherwise.

+ * + * @param connectionProvider the connection provider; must not be {@code null}. + * @return this builder. + */ + public Builder connectionProvider(@Nonnull ConnectionProvider connectionProvider) { + this.connectionProvider = requireNonNull(connectionProvider, "connectionProvider"); + return this; + } + + /** + * Sets the transaction template provider used by the template to participate in transactions. + * + *

Templates that should share transactions must be configured with the same provider instance.

+ * + * @param transactionTemplateProvider the transaction template provider; must not be {@code null}. + * @return this builder. + */ + public Builder transactionTemplateProvider(@Nonnull TransactionTemplateProvider transactionTemplateProvider) { + this.transactionTemplateProvider = requireNonNull(transactionTemplateProvider, "transactionTemplateProvider"); + return this; + } + + /** + * Sets the exception mapper that maps failures raised during query execution to the runtime exception thrown + * to the caller. + * + * @param exceptionMapper the exception mapper; must not be {@code null}. + * @return this builder. + */ + public Builder exceptionMapper(@Nonnull ExceptionMapper exceptionMapper) { + this.exceptionMapper = requireNonNull(exceptionMapper, "exceptionMapper"); + return this; + } + + /** + * Sets the query observer that is notified of query executions performed by the template. + * + * @param queryObserver the query observer; must not be {@code null}. + * @return this builder. + */ + public Builder queryObserver(@Nonnull QueryObserver queryObserver) { + this.queryObserver = requireNonNull(queryObserver, "queryObserver"); + return this; + } + + /** + * Builds the ORM template. + * + * @return the ORM template. + * @throws PersistenceException if the configuration is invalid, such as a connection provider configured for + * a connection backed template. + */ + public ORMTemplate build() { + PreparedStatementTemplateImpl template; + if (dataSource != null) { + template = new PreparedStatementTemplateImpl(dataSource, config, connectionProvider, + transactionTemplateProvider, exceptionMapper, queryObserver); + } else { + if (connectionProvider != null) { + throw new PersistenceException( + "A connection provider cannot be configured for a connection backed template."); + } + assert connection != null; + template = new PreparedStatementTemplateImpl(connection, config, + transactionTemplateProvider, exceptionMapper, queryObserver); + } + if (decorator != null) { + var decorated = decorator.apply(template); + if (!(decorated instanceof PreparedStatementTemplateImpl decoratedTemplate)) { + throw new PersistenceException("Decorator must return the same template type."); + } + template = decoratedTemplate; + } + return template.toORM(); + } + } } diff --git a/storm-core/src/main/java/st/orm/core/template/QueryTemplate.java b/storm-core/src/main/java/st/orm/core/template/QueryTemplate.java index 691438df2..bf2cea3e3 100644 --- a/storm-core/src/main/java/st/orm/core/template/QueryTemplate.java +++ b/storm-core/src/main/java/st/orm/core/template/QueryTemplate.java @@ -39,6 +39,19 @@ public interface QueryTemplate extends SubqueryTemplate { */ SqlDialect dialect(); + /** + * Returns the transaction template provider used by this template. + * + *

The default implementation resolves the fallback provider via {@code ServiceLoader} discovery; templates + * that carry instance-scoped integration strategies return their configured provider.

+ * + * @return the transaction template provider. + * @since 1.13 + */ + default st.orm.core.spi.TransactionTemplateProvider transactionTemplateProvider() { + return st.orm.core.spi.Providers.getTransactionTemplateProvider(); + } + /** * Create a new bind variables instance that can be used to add bind variables to a batch. * diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ExceptionHelper.java b/storm-core/src/main/java/st/orm/core/template/impl/ExceptionHelper.java index 9a2dbd4c0..227546d8e 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/ExceptionHelper.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/ExceptionHelper.java @@ -17,15 +17,21 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; +import java.util.Optional; import java.util.function.Function; +import st.orm.Data; import st.orm.PersistenceException; -import st.orm.core.spi.Providers; +import st.orm.core.spi.ExceptionContext; +import st.orm.core.spi.ExceptionMapper; import st.orm.core.spi.TransactionContext; +import st.orm.core.spi.TransactionScope; +import st.orm.core.spi.TransactionTemplateProvider; import st.orm.core.template.Sql; +import st.orm.core.template.SqlOperation; import st.orm.core.template.SqlTemplateException; /** - * Helper class for augmenting exceptions with SQL statements. + * Helper class for augmenting exceptions with SQL statements and mapping them via the template's exception mapper. * * @since 1.3 */ @@ -33,30 +39,55 @@ public final class ExceptionHelper { private ExceptionHelper() {} - public static Function getExceptionTransformer(@Nullable Sql sql) { + /** + * Returns a transformer that enriches failures with SQL diagnostics and maps them to the exception thrown to the + * caller via the given exception mapper. + * + *

The SQL statement and transaction description are attached to the original failure as a suppressed + * exception, so every mapper receives full diagnostics; the mapper only decides the thrown type. If the mapper + * itself fails or returns {@code null}, the failure is wrapped in a {@link PersistenceException} with the mapper + * failure suppressed; diagnostic mapping must never mask the original error.

+ * + * @param sql the SQL statement to attach, or {@code null} when no statement is associated. + * @param exceptionMapper the exception mapper configured on the template. + * @param transactionTemplateProvider the transaction template provider of the template, used to describe the + * active transaction. + * @return the exception transformer. + */ + public static Function getExceptionTransformer( + @Nullable Sql sql, + @Nonnull ExceptionMapper exceptionMapper, + @Nonnull TransactionTemplateProvider transactionTemplateProvider) { return e -> { + String transactionDescription = currentTransactionDescription(transactionTemplateProvider); + if (sql != null) { + e.addSuppressed(new SqlTemplateException(buildSqlDetail(sql, transactionDescription))); + } + var context = new ExceptionContextImpl( + sql != null ? sql.operation() : SqlOperation.UNDEFINED, + sql != null ? sql.statement() : null, + sql != null ? sql.affectedType().orElse(null) : null, + transactionDescription); try { - try { - throw e; - } catch (PersistenceException ex) { - throw ex; - } catch (Throwable t) { - throw new PersistenceException(t); + var mapped = exceptionMapper.map(e, context); + if (mapped != null) { + return mapped; } - } catch (PersistenceException ex) { - if (sql != null) { - e.addSuppressed(new SqlTemplateException(buildSqlDetail(sql))); - } - throw ex; + return new PersistenceException(e); + } catch (Throwable mapperFailure) { + var fallback = e instanceof PersistenceException persistenceException + ? persistenceException + : new PersistenceException(e); + fallback.addSuppressed(mapperFailure); + return fallback; } }; } - private static String buildSqlDetail(@Nonnull Sql sql) { + private static String buildSqlDetail(@Nonnull Sql sql, @Nullable String transactionDescription) { String detail = String.format("SQL:%n%s", sql.statement()); - String transaction = currentTransactionDescription(); - if (transaction != null) { - detail = detail + String.format("%nTransaction: %s", transaction); + if (transactionDescription != null) { + detail = detail + String.format("%nTransaction: %s", transactionDescription); } return detail; } @@ -65,9 +96,10 @@ private static String buildSqlDetail(@Nonnull Sql sql) { * Returns a description of the current transaction's characteristics (such as isolation level and timeout), or * {@code null} when no transaction is active or the description cannot be determined. */ - private static @Nullable String currentTransactionDescription() { + private static @Nullable String currentTransactionDescription( + @Nonnull TransactionTemplateProvider transactionTemplateProvider) { try { - return Providers.getTransactionTemplate().currentContext() + return Optional.ofNullable(TransactionScope.peekContext(transactionTemplateProvider)) .flatMap(TransactionContext::describe) .orElse(null); } catch (Throwable ignore) { @@ -75,4 +107,24 @@ private static String buildSqlDetail(@Nonnull Sql sql) { return null; } } + + private record ExceptionContextImpl(@Nonnull SqlOperation operation, + @Nullable String statementText, + @Nullable Class affectedType, + @Nullable String description) implements ExceptionContext { + @Override + public Optional statement() { + return Optional.ofNullable(statementText); + } + + @Override + public Optional> dataType() { + return Optional.ofNullable(affectedType); + } + + @Override + public Optional transactionDescription() { + return Optional.ofNullable(description); + } + } } diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java index a92d66483..4dc069e7a 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java @@ -36,7 +36,6 @@ import st.orm.core.spi.ORMReflection; import st.orm.core.spi.Providers; import st.orm.core.spi.RefFactory; -import st.orm.core.spi.TransactionTemplate; import st.orm.core.template.SqlTemplateException; /** @@ -44,7 +43,6 @@ */ public final class ObjectMapperFactory { - private static final TransactionTemplate TRANSACTION_TEMPLATE = Providers.getTransactionTemplate(); private static final ORMReflection REFLECTION = Providers.getORMReflection(); private ObjectMapperFactory() { @@ -72,11 +70,11 @@ public static Optional> getObjectMapper(int columnCount, } if (isSealedEntity(type)) { return RecordMapper.getSealedFactory(columnCount, type, refFactory, - TRANSACTION_TEMPLATE.currentContext().orElse(null)); + refFactory.transactionContext()); } if (isRecord(type)) { return RecordMapper.getFactory(columnCount, getRecordType(type), refFactory, - TRANSACTION_TEMPLATE.currentContext().orElse(null)); + refFactory.transactionContext()); } if (type.isEnum()) { return EnumMapper.getFactory(columnCount, type); diff --git a/storm-core/src/main/java/st/orm/core/template/impl/PreparedQueryImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/PreparedQueryImpl.java index e894b7127..b21b50ddc 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/PreparedQueryImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/PreparedQueryImpl.java @@ -22,7 +22,6 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.function.Function; import java.util.stream.Stream; import java.util.stream.StreamSupport; import st.orm.Data; @@ -39,7 +38,7 @@ final class PreparedQueryImpl extends QueryImpl implements PreparedQuery { private final PreparedStatement statement; private final BindVarsHandle bindVarsHandle; - public PreparedQueryImpl(@Nonnull RefFactory refFactory, + public PreparedQueryImpl(@Nonnull Environment environment, @Nonnull PreparedStatement statement, @Nullable BindVarsHandle bindVarsHandle, @Nullable Class affectedType, @@ -47,10 +46,9 @@ public PreparedQueryImpl(@Nonnull RefFactory refFactory, boolean managed, int defaultFetchSize, boolean streamOnlyFetchSize, - boolean streamingRequiresTransaction, - @Nonnull Function exceptionTransformer) { - super(refFactory, ignore -> statement, bindVarsHandle, affectedType, versionAware, managed, false, defaultFetchSize, streamOnlyFetchSize, streamingRequiresTransaction, exceptionTransformer); - this.refFactory = refFactory; + boolean streamingRequiresTransaction) { + super(environment, ignore -> statement, bindVarsHandle, affectedType, versionAware, managed, false, defaultFetchSize, streamOnlyFetchSize, streamingRequiresTransaction); + this.refFactory = environment.refFactory(); this.statement = statement; this.bindVarsHandle = bindVarsHandle; } diff --git a/storm-core/src/main/java/st/orm/core/template/impl/PreparedStatementTemplateImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/PreparedStatementTemplateImpl.java index 01f7a3fd6..93dcb3ad5 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/PreparedStatementTemplateImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/PreparedStatementTemplateImpl.java @@ -15,9 +15,8 @@ */ package st.orm.core.template.impl; -import static st.orm.core.spi.Providers.getConnection; +import static java.util.Objects.requireNonNullElseGet; import static st.orm.core.spi.Providers.getSqlDialect; -import static st.orm.core.spi.Providers.releaseConnection; import static st.orm.core.template.SqlTemplate.PS; import static st.orm.core.template.impl.ExceptionHelper.getExceptionTransformer; import static st.orm.core.template.impl.LazySupplier.lazy; @@ -47,21 +46,26 @@ import java.util.TimeZone; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; import java.util.function.Predicate; import java.util.function.Supplier; import javax.sql.DataSource; import st.orm.BindVars; import st.orm.PersistenceException; import st.orm.StormConfig; +import st.orm.core.spi.ConnectionProvider; +import st.orm.core.spi.ExceptionMapper; import st.orm.core.spi.JsonString; import st.orm.core.spi.Provider; import st.orm.core.spi.Providers; import st.orm.core.spi.QueryFactory; +import st.orm.core.spi.QueryObserver; import st.orm.core.spi.RefFactory; import st.orm.core.spi.RefFactoryImpl; import st.orm.core.spi.SqlDialectProvider; import st.orm.core.spi.TransactionContext; -import st.orm.core.spi.TransactionTemplate; +import st.orm.core.spi.TransactionScope; +import st.orm.core.spi.TransactionTemplateProvider; import st.orm.core.template.ORMTemplate; import st.orm.core.template.PreparedStatementTemplate; import st.orm.core.template.Query; @@ -87,13 +91,23 @@ PreparedStatement process(@Nonnull Sql sql, boolean unsafe) throws SQLException; } + /** + * The instance-scoped integration strategies of a template. The connection provider is {@code null} for + * templates that are backed by a single connection rather than a data source. + */ + record IntegrationStrategies(@Nullable ConnectionProvider connectionProvider, + @Nonnull TransactionTemplateProvider transactionTemplateProvider, + @Nonnull ExceptionMapper exceptionMapper, + @Nonnull QueryObserver queryObserver) { + } + private final TemplateProcessor templateProcessor; private final @Nullable DataSource dataSource; private final ModelBuilder modelBuilder; private final TableAliasResolver tableAliasResolver; private final Predicate providerFilter; private final RefFactory refFactory; - private final TransactionTemplate transactionTemplate; + private final IntegrationStrategies strategies; private final SqlTemplate sqlTemplate; private final StormConfig config; @@ -102,28 +116,48 @@ public PreparedStatementTemplateImpl(@Nonnull DataSource dataSource) { } public PreparedStatementTemplateImpl(@Nonnull DataSource dataSource, @Nonnull StormConfig config) { - this(Providers.getTransactionTemplate(), dataSource, config); + this(dataSource, config, null, null, null, null); } - // Note that this logic does not use Spring's DataSourceUtils, so it is not aware of Spring's transaction - // management. - private PreparedStatementTemplateImpl(@Nonnull TransactionTemplate transactionTemplate, + /** + * Creates a data source backed template with instance-scoped integration strategies. + * + *

Strategies that are {@code null} fall back to {@code ServiceLoader} discovery for the connection and + * transaction template providers, and to the built-in defaults for the exception mapper and query observer.

+ * + * @since 1.13 + */ + public PreparedStatementTemplateImpl(@Nonnull DataSource dataSource, + @Nonnull StormConfig config, + @Nullable ConnectionProvider connectionProvider, + @Nullable TransactionTemplateProvider transactionTemplateProvider, + @Nullable ExceptionMapper exceptionMapper, + @Nullable QueryObserver queryObserver) { + this(new IntegrationStrategies( + requireNonNullElseGet(connectionProvider, Providers::getConnectionProvider), + requireNonNullElseGet(transactionTemplateProvider, Providers::getTransactionTemplateProvider), + requireNonNullElseGet(exceptionMapper, ExceptionMapper::defaultMapper), + requireNonNullElseGet(queryObserver, QueryObserver::noop)), + dataSource, config); + } + + private PreparedStatementTemplateImpl(@Nonnull IntegrationStrategies strategies, @Nonnull DataSource dataSource, @Nonnull StormConfig config) { - this(transactionTemplate, dataSource, config, + this(strategies, dataSource, config, Providers.getSqlDialectProvider(Providers.getDatabaseProductName(dataSource))); } - private PreparedStatementTemplateImpl(@Nonnull TransactionTemplate transactionTemplate, + private PreparedStatementTemplateImpl(@Nonnull IntegrationStrategies strategies, @Nonnull DataSource dataSource, @Nonnull StormConfig config, @Nullable SqlDialectProvider matchedProvider) { - this(createDataSourceProcessor(dataSource, transactionTemplate, + this(createDataSourceProcessor(dataSource, strategies, matchedProvider != null ? matchedProvider.getSqlDialect(config) : getSqlDialect(config)), dataSource, ModelBuilder.newInstance(), TableAliasResolver.DEFAULT, matchedProvider != null ? matchedProvider.getProviderFilter() : null, - transactionTemplate, config); + strategies, config); } public PreparedStatementTemplateImpl(@Nonnull Connection connection) { @@ -131,26 +165,48 @@ public PreparedStatementTemplateImpl(@Nonnull Connection connection) { } public PreparedStatementTemplateImpl(@Nonnull Connection connection, @Nonnull StormConfig config) { - this(Providers.getTransactionTemplate(), connection, config); + this(connection, config, null, null, null); + } + + /** + * Creates a connection backed template with instance-scoped integration strategies. + * + *

Connection backed templates never acquire connections themselves, so no connection provider applies. + * Strategies that are {@code null} fall back to {@code ServiceLoader} discovery for the transaction template + * provider, and to the built-in defaults for the exception mapper and query observer.

+ * + * @since 1.13 + */ + public PreparedStatementTemplateImpl(@Nonnull Connection connection, + @Nonnull StormConfig config, + @Nullable TransactionTemplateProvider transactionTemplateProvider, + @Nullable ExceptionMapper exceptionMapper, + @Nullable QueryObserver queryObserver) { + this(new IntegrationStrategies( + null, + requireNonNullElseGet(transactionTemplateProvider, Providers::getTransactionTemplateProvider), + requireNonNullElseGet(exceptionMapper, ExceptionMapper::defaultMapper), + requireNonNullElseGet(queryObserver, QueryObserver::noop)), + connection, config); } - private PreparedStatementTemplateImpl(@Nonnull TransactionTemplate transactionTemplate, + private PreparedStatementTemplateImpl(@Nonnull IntegrationStrategies strategies, @Nonnull Connection connection, @Nonnull StormConfig config) { - this(transactionTemplate, connection, config, + this(strategies, connection, config, Providers.getSqlDialectProvider(Providers.getDatabaseProductName(connection))); } - private PreparedStatementTemplateImpl(@Nonnull TransactionTemplate transactionTemplate, + private PreparedStatementTemplateImpl(@Nonnull IntegrationStrategies strategies, @Nonnull Connection connection, @Nonnull StormConfig config, @Nullable SqlDialectProvider matchedProvider) { - this(createConnectionProcessor(connection, transactionTemplate, + this(createConnectionProcessor(connection, strategies, matchedProvider != null ? matchedProvider.getSqlDialect(config) : getSqlDialect(config)), null, ModelBuilder.newInstance(), TableAliasResolver.DEFAULT, matchedProvider != null ? matchedProvider.getProviderFilter() : null, - transactionTemplate, config); + strategies, config); } private PreparedStatementTemplateImpl(@Nonnull TemplateProcessor templateProcessor, @@ -158,7 +214,7 @@ private PreparedStatementTemplateImpl(@Nonnull TemplateProcessor templateProcess @Nonnull ModelBuilder modelBuilder, @Nonnull TableAliasResolver tableAliasResolver, @Nullable Predicate providerFilter, - @Nonnull TransactionTemplate transactionTemplate, + @Nonnull IntegrationStrategies strategies, @Nonnull StormConfig config) { validate(config); this.templateProcessor = templateProcessor; @@ -167,14 +223,17 @@ private PreparedStatementTemplateImpl(@Nonnull TemplateProcessor templateProcess this.tableAliasResolver = tableAliasResolver; this.providerFilter = providerFilter; this.refFactory = new RefFactoryImpl(this, modelBuilder, providerFilter); - this.transactionTemplate = transactionTemplate; + this.strategies = strategies; this.config = config; this.sqlTemplate = createSqlTemplate(); } private static TemplateProcessor createDataSourceProcessor(@Nonnull DataSource dataSource, - @Nonnull TransactionTemplate transactionTemplate, + @Nonnull IntegrationStrategies strategies, @Nonnull SqlDialect dialect) { + var connectionProvider = strategies.connectionProvider(); + assert connectionProvider != null; + var transactionTemplateProvider = strategies.transactionTemplateProvider(); return (sql, unsafe) -> { if (!unsafe) { sql.unsafeWarning().ifPresent(warning -> { @@ -185,8 +244,8 @@ private static TemplateProcessor createDataSourceProcessor(@Nonnull DataSource d var parameters = sql.parameters(); var bindVariables = sql.bindVariables().orElse(null); var generatedKeys = sql.generatedKeys(); - var transactionContext = transactionTemplate.currentContext().orElse(null); - Connection connection = getConnection(dataSource, transactionContext); + var transactionContext = TransactionScope.resolveContext(transactionTemplateProvider); + Connection connection = connectionProvider.getConnection(dataSource, transactionContext); PreparedStatement preparedStatement = null; boolean success = false; try { @@ -210,7 +269,8 @@ private static TemplateProcessor createDataSourceProcessor(@Nonnull DataSource d if (bindVariables == null) { setParameters(preparedStatement, parameters, dialect); } else { - bindVariables.setBatchListener(getBatchListener(preparedStatement, parameters, dialect)); + bindVariables.setBatchListener(getBatchListener(preparedStatement, parameters, dialect, + getExceptionTransformer(sql, strategies.exceptionMapper(), transactionTemplateProvider))); } success = true; } finally { @@ -220,16 +280,17 @@ private static TemplateProcessor createDataSourceProcessor(@Nonnull DataSource d preparedStatement.close(); } catch (SQLException ignore) {} } - releaseConnection(connection, dataSource, transactionContext); + connectionProvider.releaseConnection(connection, dataSource, transactionContext); } } - return createProxy(preparedStatement, connection, dataSource, transactionContext); + return createProxy(preparedStatement, connection, dataSource, transactionContext, connectionProvider); }; } private static TemplateProcessor createConnectionProcessor(@Nonnull Connection connection, - @Nonnull TransactionTemplate transactionTemplate, + @Nonnull IntegrationStrategies strategies, @Nonnull SqlDialect dialect) { + var transactionTemplateProvider = strategies.transactionTemplateProvider(); return (sql, unsafe) -> { if (!unsafe) { sql.unsafeWarning().ifPresent(warning -> { @@ -253,7 +314,9 @@ private static TemplateProcessor createConnectionProcessor(@Nonnull Connection c //noinspection SqlSourceToSinkFlow preparedStatement = connection.prepareStatement(statement); } - var transactionContext = transactionTemplate.currentContext().orElse(null); + // Connection backed templates never materialize a transaction scope; the caller manages the + // connection. The context is only observed for statement decoration, such as timeouts. + var transactionContext = TransactionScope.peekContext(transactionTemplateProvider); if (transactionContext != null) { preparedStatement = transactionContext.getDecorator(PreparedStatement.class) .decorate(preparedStatement); @@ -264,7 +327,8 @@ private static TemplateProcessor createConnectionProcessor(@Nonnull Connection c if (bindVariables == null) { setParameters(preparedStatement, parameters, dialect); } else { - bindVariables.setBatchListener(getBatchListener(preparedStatement, parameters, dialect)); + bindVariables.setBatchListener(getBatchListener(preparedStatement, parameters, dialect, + getExceptionTransformer(sql, strategies.exceptionMapper(), transactionTemplateProvider))); } success = true; return preparedStatement; @@ -298,7 +362,7 @@ private SqlTemplate createSqlTemplate() { */ @Override public PreparedStatementTemplateImpl withTableNameResolver(@Nullable TableNameResolver tableNameResolver) { - return new PreparedStatementTemplateImpl(templateProcessor, dataSource, modelBuilder.tableNameResolver(tableNameResolver), tableAliasResolver, providerFilter, transactionTemplate, config); + return new PreparedStatementTemplateImpl(templateProcessor, dataSource, modelBuilder.tableNameResolver(tableNameResolver), tableAliasResolver, providerFilter, strategies, config); } /** @@ -309,7 +373,7 @@ public PreparedStatementTemplateImpl withTableNameResolver(@Nullable TableNameRe */ @Override public PreparedStatementTemplateImpl withColumnNameResolver(@Nullable ColumnNameResolver columnNameResolver) { - return new PreparedStatementTemplateImpl(templateProcessor, dataSource, modelBuilder.columnNameResolver(columnNameResolver), tableAliasResolver, providerFilter, transactionTemplate, config); + return new PreparedStatementTemplateImpl(templateProcessor, dataSource, modelBuilder.columnNameResolver(columnNameResolver), tableAliasResolver, providerFilter, strategies, config); } /** @@ -320,7 +384,7 @@ public PreparedStatementTemplateImpl withColumnNameResolver(@Nullable ColumnName */ @Override public PreparedStatementTemplateImpl withForeignKeyResolver(@Nullable ForeignKeyResolver foreignKeyResolver) { - return new PreparedStatementTemplateImpl(templateProcessor, dataSource, modelBuilder.foreignKeyResolver(foreignKeyResolver), tableAliasResolver, providerFilter, transactionTemplate, config); + return new PreparedStatementTemplateImpl(templateProcessor, dataSource, modelBuilder.foreignKeyResolver(foreignKeyResolver), tableAliasResolver, providerFilter, strategies, config); } /** @@ -331,7 +395,7 @@ public PreparedStatementTemplateImpl withForeignKeyResolver(@Nullable ForeignKey */ @Override public PreparedStatementTemplate withTableAliasResolver(@Nonnull TableAliasResolver tableAliasResolver) { - return new PreparedStatementTemplateImpl(templateProcessor, dataSource, modelBuilder, tableAliasResolver, providerFilter, transactionTemplate, config); + return new PreparedStatementTemplateImpl(templateProcessor, dataSource, modelBuilder, tableAliasResolver, providerFilter, strategies, config); } /** @@ -342,7 +406,7 @@ public PreparedStatementTemplate withTableAliasResolver(@Nonnull TableAliasResol */ @Override public PreparedStatementTemplateImpl withProviderFilter(@Nullable Predicate providerFilter) { - return new PreparedStatementTemplateImpl(templateProcessor, dataSource, modelBuilder, tableAliasResolver, providerFilter, transactionTemplate, config); + return new PreparedStatementTemplateImpl(templateProcessor, dataSource, modelBuilder, tableAliasResolver, providerFilter, strategies, config); } /** @@ -357,7 +421,8 @@ public BindVars createBindVars() { private static BatchListener getBatchListener(@Nonnull PreparedStatement preparedStatement, @Nonnull List parameters, - @Nonnull SqlDialect dialect) { + @Nonnull SqlDialect dialect, + @Nonnull Function exceptionTransformer) { var calendarSupplier = lazy(() -> Calendar.getInstance(TimeZone.getTimeZone(ZoneOffset.UTC))); return batchParameters -> { try { @@ -365,7 +430,7 @@ private static BatchListener getBatchListener(@Nonnull PreparedStatement prepare setParameters(preparedStatement, batchParameters, calendarSupplier, dialect); preparedStatement.addBatch(); } catch (SQLException e) { - throw new PersistenceException(e); + throw exceptionTransformer.apply(e); } }; } @@ -447,6 +512,17 @@ private static void setObjectOr(PreparedStatement ps, return dataSource; } + /** + * Returns the transaction template provider used by this template. + * + * @return the transaction template provider. + * @since 1.13 + */ + @Override + public TransactionTemplateProvider transactionTemplateProvider() { + return strategies.transactionTemplateProvider(); + } + /** * Get the SQL template used by this factory. * @@ -489,7 +565,9 @@ public StormConfig config() { * @param connection the connection to close when the PreparedStatement is closed. * @return a proxy for the PreparedStatement that closes the connection when the PreparedStatement is closed. */ - private static PreparedStatement createProxy(@Nonnull PreparedStatement statement, @Nonnull Connection connection, @Nonnull DataSource dataSource, @Nullable TransactionContext context) { + private static PreparedStatement createProxy(@Nonnull PreparedStatement statement, @Nonnull Connection connection, + @Nonnull DataSource dataSource, @Nullable TransactionContext context, + @Nonnull ConnectionProvider connectionProvider) { return (PreparedStatement) Proxy.newProxyInstance( PreparedStatement.class.getClassLoader(), new Class[] { PreparedStatement.class }, @@ -499,7 +577,7 @@ private static PreparedStatement createProxy(@Nonnull PreparedStatement statemen try { statement.close(); } finally { - releaseConnection(connection, dataSource, context); + connectionProvider.releaseConnection(connection, dataSource, context); } return null; } @@ -528,13 +606,20 @@ public Query create(@Nonnull TemplateString template) { SqlDialect dialect = providerFilter != null ? getSqlDialect(providerFilter, config) : getSqlDialect(config); - return new QueryImpl(refFactory, unsafe -> { + var environment = new QueryImpl.Environment( + refFactory, + strategies.transactionTemplateProvider(), + strategies.queryObserver(), + getExceptionTransformer(sql, strategies.exceptionMapper(), strategies.transactionTemplateProvider()), + sql.operation(), + sql.statement()); + return new QueryImpl(environment, unsafe -> { try { return templateProcessor.process(sql, unsafe); } catch (SQLException e) { throw new PersistenceException(e); } - }, bindVariables == null ? null : bindVariables.getHandle(), sql.affectedType().orElse(null), sql.versionAware(), false, false, dialect.defaultFetchSize(), dialect.streamOnlyFetchSize(), dialect.streamingRequiresTransaction(), getExceptionTransformer(sql)); + }, bindVariables == null ? null : bindVariables.getHandle(), sql.affectedType().orElse(null), sql.versionAware(), false, false, dialect.defaultFetchSize(), dialect.streamOnlyFetchSize(), dialect.streamingRequiresTransaction()); } catch (SqlTemplateException e) { throw new PersistenceException(e); } diff --git a/storm-core/src/main/java/st/orm/core/template/impl/QueryImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/QueryImpl.java index e38958d14..2c9ffce89 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/QueryImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/QueryImpl.java @@ -17,6 +17,7 @@ import static java.lang.Integer.toHexString; import static java.lang.System.identityHashCode; +import static java.util.Optional.ofNullable; import static st.orm.core.template.impl.LazySupplier.lazy; import static st.orm.core.template.impl.ObjectMapperFactory.getObjectMapper; @@ -39,6 +40,7 @@ import java.util.Calendar; import java.util.List; import java.util.Optional; +import java.util.OptionalInt; import java.util.Spliterator; import java.util.Spliterators; import java.util.TimeZone; @@ -52,18 +54,34 @@ import st.orm.Entity; import st.orm.PersistenceException; import st.orm.Ref; -import st.orm.core.spi.Providers; +import st.orm.core.spi.QueryContext; +import st.orm.core.spi.QueryContext.ExecutionKind; +import st.orm.core.spi.QueryObserver; +import st.orm.core.spi.QueryObserver.Observation; import st.orm.core.spi.RefFactory; -import st.orm.core.spi.TransactionTemplate; +import st.orm.core.spi.TransactionScope; +import st.orm.core.spi.TransactionTemplateProvider; import st.orm.core.spi.WeakInterner; import st.orm.core.template.PreparedQuery; import st.orm.core.template.Query; +import st.orm.core.template.SqlOperation; import st.orm.core.template.SqlTemplateException; @SuppressWarnings("ALL") class QueryImpl implements Query { - private static final TransactionTemplate TRANSACTION_TEMPLATE = Providers.getTransactionTemplate(); + /** + * The template-scoped services and statement metadata shared by a query and the prepared queries derived from it. + */ + record Environment(@Nonnull RefFactory refFactory, + @Nonnull TransactionTemplateProvider transactionTemplateProvider, + @Nonnull QueryObserver queryObserver, + @Nonnull Function exceptionTransformer, + @Nonnull SqlOperation operation, + @Nullable String statementText) { + } + + private final Environment environment; private final RefFactory refFactory; private final Function statement; private final BindVarsHandle bindVarsHandle; @@ -74,47 +92,9 @@ class QueryImpl implements Query { private final int defaultFetchSize; private final boolean streamOnlyFetchSize; private final boolean streamingRequiresTransaction; - private final Function exceptionTransformer; - - QueryImpl(@Nonnull RefFactory refFactory, - @Nonnull Function statement, - @Nullable BindVarsHandle bindVarsHandle, - boolean versionAware, - @Nonnull Function exceptionTransformer) { - this(refFactory, statement, bindVarsHandle, null, versionAware, false, false, 0, false, false, exceptionTransformer); - } - - QueryImpl(@Nonnull RefFactory refFactory, - @Nonnull Function statement, - @Nullable BindVarsHandle bindVarsHandle, - boolean versionAware, - boolean unsafe, - @Nonnull Function exceptionTransformer) { - this(refFactory, statement, bindVarsHandle, null, versionAware, false, unsafe, 0, false, false, exceptionTransformer); - } + private final Function exceptionTransformer; - QueryImpl(@Nonnull RefFactory refFactory, - @Nonnull Function statement, - @Nullable BindVarsHandle bindVarsHandle, - @Nullable Class affectedType, - boolean versionAware, - boolean unsafe, - @Nonnull Function exceptionTransformer) { - this(refFactory, statement, bindVarsHandle, affectedType, versionAware, false, unsafe, 0, false, false, exceptionTransformer); - } - - QueryImpl(@Nonnull RefFactory refFactory, - @Nonnull Function statement, - @Nullable BindVarsHandle bindVarsHandle, - @Nullable Class affectedType, - boolean versionAware, - boolean managed, - boolean unsafe, - @Nonnull Function exceptionTransformer) { - this(refFactory, statement, bindVarsHandle, affectedType, versionAware, managed, unsafe, 0, false, false, exceptionTransformer); - } - - QueryImpl(@Nonnull RefFactory refFactory, + QueryImpl(@Nonnull Environment environment, @Nonnull Function statement, @Nullable BindVarsHandle bindVarsHandle, @Nullable Class affectedType, @@ -123,9 +103,9 @@ class QueryImpl implements Query { boolean unsafe, int defaultFetchSize, boolean streamOnlyFetchSize, - boolean streamingRequiresTransaction, - @Nonnull Function exceptionTransformer) { - this.refFactory = refFactory; + boolean streamingRequiresTransaction) { + this.environment = environment; + this.refFactory = environment.refFactory(); this.statement = statement; this.bindVarsHandle = bindVarsHandle; this.versionAware = versionAware; @@ -135,7 +115,7 @@ class QueryImpl implements Query { this.defaultFetchSize = defaultFetchSize; this.streamOnlyFetchSize = streamOnlyFetchSize; this.streamingRequiresTransaction = streamingRequiresTransaction; - this.exceptionTransformer = exceptionTransformer; + this.exceptionTransformer = environment.exceptionTransformer(); } /** @@ -152,7 +132,7 @@ class QueryImpl implements Query { */ @Override public PreparedQuery prepare() { - return MonitoredResource.wrap(new PreparedQueryImpl(refFactory, statement.apply(unsafe), bindVarsHandle, affectedType, versionAware, managed, defaultFetchSize, streamOnlyFetchSize, streamingRequiresTransaction, exceptionTransformer)); + return MonitoredResource.wrap(new PreparedQueryImpl(environment, statement.apply(unsafe), bindVarsHandle, affectedType, versionAware, managed, defaultFetchSize, streamOnlyFetchSize, streamingRequiresTransaction)); } /** @@ -163,7 +143,7 @@ public PreparedQuery prepare() { */ @Override public Query managed() { - return new QueryImpl(refFactory, statement, bindVarsHandle, affectedType, versionAware, true, unsafe, defaultFetchSize, streamOnlyFetchSize, streamingRequiresTransaction, exceptionTransformer); + return new QueryImpl(environment, statement, bindVarsHandle, affectedType, versionAware, true, unsafe, defaultFetchSize, streamOnlyFetchSize, streamingRequiresTransaction); } /** @@ -174,11 +154,11 @@ public Query managed() { */ @Override public Query unsafe() { - return new QueryImpl(refFactory, statement, bindVarsHandle, affectedType, versionAware, managed, true, defaultFetchSize, streamOnlyFetchSize, streamingRequiresTransaction, exceptionTransformer); + return new QueryImpl(environment, statement, bindVarsHandle, affectedType, versionAware, managed, true, defaultFetchSize, streamOnlyFetchSize, streamingRequiresTransaction); } private QueryImpl withoutFetchSize() { - return new QueryImpl(refFactory, statement, bindVarsHandle, affectedType, versionAware, managed, unsafe, 0, false, false, exceptionTransformer); + return new QueryImpl(environment, statement, bindVarsHandle, affectedType, versionAware, managed, unsafe, 0, false, false); } private PreparedStatement getStatement() { @@ -227,6 +207,34 @@ protected boolean closeStatement() { return true; } + /** + * Notifies the query observer of a starting execution. Observer failures never affect query execution. + */ + private Observation observe(@Nonnull ExecutionKind kind) { + try { + return environment.queryObserver().onExecute( + new QueryContextImpl(environment.operation(), affectedType, kind, environment.statementText())); + } catch (Throwable ignore) { + return Observation.NOOP; + } + } + + private static void observationError(@Nonnull Observation observation, @Nonnull Throwable throwable) { + try { + observation.error(throwable); + } catch (Throwable ignore) { + // Observer failures never affect query execution. + } + } + + private static void closeObservation(@Nonnull Observation observation) { + try { + observation.close(); + } catch (Throwable ignore) { + // Observer failures never affect query execution. + } + } + /** * Execute a SELECT query and return the resulting rows as a stream of row instances. * @@ -248,6 +256,8 @@ protected boolean closeStatement() { */ @Override public Stream getResultStream() { + var observation = observe(ExecutionKind.QUERY); + boolean handedOff = false; try { PreparedStatement statement = getStatement(); boolean close = true; @@ -258,9 +268,16 @@ public Stream getResultStream() { try { int columnCount = resultSet.getMetaData().getColumnCount(); close = false; + handedOff = true; return MonitoredResource.wrap( StreamSupport.stream(rawRowSpliterator(resultSet, columnCount), false) - .onClose(() -> close(resultSet, statement, streamingCleanup))); + .onClose(() -> { + try { + close(resultSet, statement, streamingCleanup); + } finally { + closeObservation(observation); + } + })); } finally { if (close) { resultSet.close(); @@ -272,6 +289,10 @@ public Stream getResultStream() { } } } catch (Exception e) { + if (!handedOff) { + observationError(observation, e); + closeObservation(observation); + } throw exceptionTransformer.apply(e); } } @@ -297,9 +318,12 @@ public Stream getResultStream() { */ @Override public Stream getResultStream(@Nonnull Class type) { - PreparedStatement statement = getStatement(); - boolean close = true; + var observation = observe(ExecutionKind.QUERY); + boolean handedOff = false; + PreparedStatement statement = null; try { + statement = getStatement(); + boolean close = true; try { applyFetchSize(statement); Runnable streamingCleanup = configureStreamingTransaction(statement); @@ -308,15 +332,27 @@ public Stream getResultStream(@Nonnull Class type) { var mapper = getObjectMapper(columnCount, type, refFactory) .orElseThrow(() -> new SqlTemplateException("No suitable constructor found for %s.".formatted(type.getName()))); close = false; + handedOff = true; + var closeableStatement = statement; return MonitoredResource.wrap( StreamSupport.stream(rowSpliterator(resultSet, columnCount, mapper), false) - .onClose(() -> close(resultSet, statement, streamingCleanup))); + .onClose(() -> { + try { + close(resultSet, closeableStatement, streamingCleanup); + } finally { + closeObservation(observation); + } + })); } finally { if (close && closeStatement()) { statement.close(); } } } catch (Exception e) { + if (!handedOff) { + observationError(observation, e); + closeObservation(observation); + } throw exceptionTransformer.apply(e); } } @@ -452,19 +488,27 @@ public boolean isVersionAware() { */ @Override public int executeUpdate() { - PreparedStatement statement = getStatement(); + var observation = observe(ExecutionKind.UPDATE); try { + PreparedStatement statement = getStatement(); try { - int result = statement.executeUpdate(); - invalidateAffectedEntityCaches(); - return result; - } finally { - if (closeStatement()) { - statement.close(); + try { + int result = statement.executeUpdate(); + invalidateAffectedEntityCaches(); + return result; + } finally { + if (closeStatement()) { + statement.close(); + } } + } catch (SQLException e) { + throw exceptionTransformer.apply(e); } - } catch (SQLException e) { - throw exceptionTransformer.apply(e); + } catch (Throwable t) { + observationError(observation, t); + throw t; + } finally { + closeObservation(observation); } } @@ -480,17 +524,19 @@ private void invalidateAffectedEntityCaches() { if (managed) { return; // Caller is managing cache. } - TRANSACTION_TEMPLATE.currentContext().ifPresent(ctx -> { - if (affectedType == null) { - // Unknown affected type: clear all caches to avoid stale observed state. - ctx.clearAllEntityCaches(); - } else if (Entity.class.isAssignableFrom(affectedType)) { - var cache = ctx.findEntityCache((Class>) affectedType); - if (cache != null) { - cache.clear(); - } + var context = TransactionScope.peekContext(environment.transactionTemplateProvider()); + if (context == null) { + return; + } + if (affectedType == null) { + // Unknown affected type: clear all caches to avoid stale observed state. + context.clearAllEntityCaches(); + } else if (Entity.class.isAssignableFrom(affectedType)) { + var cache = context.findEntityCache((Class>) affectedType); + if (cache != null) { + cache.clear(); } - }); + } } /** @@ -503,19 +549,27 @@ private void invalidateAffectedEntityCaches() { */ @Override public int[] executeBatch() { - PreparedStatement statement = getStatement(); + var observation = observe(ExecutionKind.BATCH); try { + PreparedStatement statement = getStatement(); try { - int[] result = statement.executeBatch(); - invalidateAffectedEntityCaches(); - return result; - } finally { - if (closeStatement()) { - statement.close(); + try { + int[] result = statement.executeBatch(); + invalidateAffectedEntityCaches(); + return result; + } finally { + if (closeStatement()) { + statement.close(); + } } + } catch (SQLException e) { + throw exceptionTransformer.apply(e); } - } catch (SQLException e) { - throw exceptionTransformer.apply(e); + } catch (Throwable t) { + observationError(observation, t); + throw t; + } finally { + closeObservation(observation); } } @@ -668,4 +722,27 @@ public String toString() { throw new PersistenceException(e); } } + + /** + * Describes a statement execution for the query observer. + */ + private record QueryContextImpl(@Nonnull SqlOperation operation, + @Nullable Class affectedType, + @Nonnull ExecutionKind kind, + @Nullable String statementText) implements QueryContext { + @Override + public Optional> dataType() { + return ofNullable(affectedType); + } + + @Override + public OptionalInt batchSize() { + return OptionalInt.empty(); + } + + @Override + public Optional statement() { + return ofNullable(statementText); + } + } } diff --git a/storm-core/src/main/java/st/orm/core/template/impl/QueryTemplateImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/QueryTemplateImpl.java index c66d79063..58380ea32 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/QueryTemplateImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/QueryTemplateImpl.java @@ -62,6 +62,17 @@ public SqlDialect dialect() { return queryFactory.sqlTemplate().dialect(); } + /** + * Returns the transaction template provider used by this template. + * + * @return the transaction template provider. + * @since 1.13 + */ + @Override + public st.orm.core.spi.TransactionTemplateProvider transactionTemplateProvider() { + return queryFactory.transactionTemplateProvider(); + } + /** * Create a new bind variables instance that can be used to add bind variables to a batch. * diff --git a/storm-core/src/test/java/st/orm/core/template/impl/FetchSizeTest.java b/storm-core/src/test/java/st/orm/core/template/impl/FetchSizeTest.java index a02fd4fbb..1b1aaca00 100644 --- a/storm-core/src/test/java/st/orm/core/template/impl/FetchSizeTest.java +++ b/storm-core/src/test/java/st/orm/core/template/impl/FetchSizeTest.java @@ -72,8 +72,17 @@ private QueryImpl createQueryWithFetchSize(Connection connection, int defaultFetchSize, boolean streamOnlyFetchSize, boolean streamingRequiresTransaction) { - return new QueryImpl( + var environment = new QueryImpl.Environment( DETACHED_REF_FACTORY, + st.orm.core.spi.Providers.getTransactionTemplateProvider(), + st.orm.core.spi.QueryObserver.noop(), + e -> e instanceof PersistenceException persistenceException + ? persistenceException + : new PersistenceException(e), + st.orm.core.template.SqlOperation.SELECT, + sql); + return new QueryImpl( + environment, unsafe -> { try { return connection.prepareStatement(sql); @@ -88,8 +97,7 @@ private QueryImpl createQueryWithFetchSize(Connection connection, false, defaultFetchSize, streamOnlyFetchSize, - streamingRequiresTransaction, - e -> new PersistenceException(e) + streamingRequiresTransaction ); } diff --git a/storm-core/src/test/java/st/orm/core/testsupport/ProviderResolutionTest.java b/storm-core/src/test/java/st/orm/core/testsupport/ProviderResolutionTest.java new file mode 100644 index 000000000..45d5aaccf --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/testsupport/ProviderResolutionTest.java @@ -0,0 +1,25 @@ +package st.orm.core.testsupport; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import st.orm.core.spi.Providers; + +/** + * Verifies that the test-only Spring-aware providers are resolved through the {@code ServiceLoader} fallback when + * running under the test harness. The storm-core test suite depends on these providers for transaction-per-test + * isolation. + */ +public class ProviderResolutionTest { + + @Test + public void testConnectionProviderResolvesToTestProvider() { + assertEquals(TestSpringConnectionProvider.class, Providers.getConnectionProvider().getClass()); + } + + @Test + public void testTransactionTemplateProviderResolvesToTestProvider() { + assertEquals(TestSpringTransactionTemplateProvider.class, + Providers.getTransactionTemplateProvider().getClass()); + } +} diff --git a/storm-core/src/test/java/st/orm/core/testsupport/TestSpringConnectionProvider.java b/storm-core/src/test/java/st/orm/core/testsupport/TestSpringConnectionProvider.java new file mode 100644 index 000000000..c5ebc3192 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/testsupport/TestSpringConnectionProvider.java @@ -0,0 +1,52 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.core.testsupport; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.sql.Connection; +import javax.sql.DataSource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import st.orm.PersistenceException; +import st.orm.core.spi.ConnectionProvider; +import st.orm.core.spi.Orderable.BeforeAny; +import st.orm.core.spi.TransactionContext; + +/** + * Test-only connection provider that binds connections to Spring's transaction management. + * + *

The storm-core test suite runs under Spring's test framework ({@code @DataJpaTest}), which wraps each test in a + * transaction that is rolled back afterwards. Templates created with {@code ORMTemplate.of(dataSource)} pick up this + * provider through the {@code ServiceLoader} fallback, so their statements join the test-managed transaction and the + * shared database stays clean between tests.

+ */ +@BeforeAny +public class TestSpringConnectionProvider implements ConnectionProvider { + + @Override + public Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { + try { + return DataSourceUtils.getConnection(dataSource); + } catch (Exception e) { + throw new PersistenceException("Failed to get connection from DataSource.", e); + } + } + + @Override + public void releaseConnection(@Nonnull Connection connection, @Nonnull DataSource dataSource, @Nullable TransactionContext context) { + DataSourceUtils.releaseConnection(connection, dataSource); + } +} diff --git a/storm-core/src/test/java/st/orm/core/testsupport/TestSpringTransactionTemplateProvider.java b/storm-core/src/test/java/st/orm/core/testsupport/TestSpringTransactionTemplateProvider.java new file mode 100644 index 000000000..753da0e2e --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/testsupport/TestSpringTransactionTemplateProvider.java @@ -0,0 +1,158 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.core.testsupport; + +import static java.util.Optional.empty; +import static java.util.Optional.of; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.sql.Connection; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import st.orm.Entity; +import st.orm.core.spi.CacheRetention; +import st.orm.core.spi.EntityCache; +import st.orm.core.spi.EntityCacheImpl; +import st.orm.core.spi.Orderable.BeforeAny; +import st.orm.core.spi.TransactionContext; +import st.orm.core.spi.TransactionTemplate; +import st.orm.core.spi.TransactionTemplateProvider; + +/** + * Test-only transaction template provider that exposes a transaction context bound to Spring's + * {@code TransactionSynchronizationManager}. + * + *

The storm-core test suite runs under Spring's test framework, which manages transactions around each test. + * This provider gives templates a transaction context, and thereby transaction-scoped entity caching, for the + * duration of the Spring-managed transaction. Storm's own transaction API is not supported by this provider.

+ */ +@BeforeAny +public class TestSpringTransactionTemplateProvider implements TransactionTemplateProvider { + + private static final Object CONTEXT_RESOURCE_KEY = + TestSpringTransactionTemplateProvider.class.getName() + ".TX_CONTEXT"; + + private static final ThreadLocal CONTEXT_HOLDER = new ThreadLocal<>(); + + @Override + public TransactionTemplate getTransactionTemplate() { + return new TransactionTemplate() { + @Override + public TransactionTemplate propagation(String propagation) { + throw new UnsupportedOperationException("Transaction template not supported."); + } + + @Override + public TransactionTemplate isolation(int isolation) { + throw new UnsupportedOperationException("Transaction template not supported."); + } + + @Override + public TransactionTemplate readOnly(boolean readOnly) { + throw new UnsupportedOperationException("Transaction template not supported."); + } + + @Override + public TransactionTemplate timeout(int timeoutSeconds) { + throw new UnsupportedOperationException("Transaction template not supported."); + } + + @Override + public TransactionHandle open(@Nullable TransactionContext existing, boolean suspendMode) { + throw new UnsupportedOperationException("Transaction template not supported."); + } + + @Override + public Optional currentContext() { + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + return empty(); + } + Object existing = TransactionSynchronizationManager.getResource(CONTEXT_RESOURCE_KEY); + if (existing instanceof TransactionContext context) { + return of(context); + } + var created = new SpringLinkedTransactionContext(); + TransactionSynchronizationManager.bindResource(CONTEXT_RESOURCE_KEY, created); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + TransactionSynchronizationManager.unbindResourceIfPossible(CONTEXT_RESOURCE_KEY); + } + }); + return of(created); + } + + @Override + public ThreadLocal contextHolder() { + return CONTEXT_HOLDER; + } + }; + } + + /** + * Transaction context bound to Spring's {@code TransactionSynchronizationManager} resources. + */ + private static final class SpringLinkedTransactionContext implements TransactionContext { + private final Map>, EntityCache, ?>> caches = new HashMap<>(); + private final Decorator noopDecorator = resource -> resource; + + @Override + public boolean isRepeatableRead() { + Integer isolationLevel = TransactionSynchronizationManager.getCurrentTransactionIsolationLevel(); + if (isolationLevel == null || isolationLevel < 0) { + return false; + } + return isolationLevel >= Connection.TRANSACTION_REPEATABLE_READ; + } + + @Override + public EntityCache, ?> entityCache(@Nonnull Class> entityType, + @Nonnull CacheRetention retention) { + return caches.computeIfAbsent(entityType, ignore -> new EntityCacheImpl<>(retention)); + } + + @Override + public EntityCache, ?> getEntityCache(@Nonnull Class> entityType) { + var cache = caches.get(entityType); + if (cache == null) { + throw new IllegalStateException("No entity cache exists for " + entityType.getName() + "."); + } + return cache; + } + + @Override + public EntityCache, ?> findEntityCache(@Nonnull Class> entityType) { + return caches.get(entityType); + } + + @Override + public void clearAllEntityCaches() { + for (EntityCache, ?> cache : caches.values()) { + cache.clear(); + } + } + + @Override + @SuppressWarnings("unchecked") + public Decorator getDecorator(@Nonnull Class resourceType) { + return (Decorator) noopDecorator; + } + } +} diff --git a/storm-core/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-core/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider new file mode 100644 index 000000000..f7481be9b --- /dev/null +++ b/storm-core/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -0,0 +1 @@ +st.orm.core.testsupport.TestSpringConnectionProvider diff --git a/storm-core/src/test/resources/META-INF/services/st.orm.core.spi.TransactionTemplateProvider b/storm-core/src/test/resources/META-INF/services/st.orm.core.spi.TransactionTemplateProvider new file mode 100644 index 000000000..89daa8019 --- /dev/null +++ b/storm-core/src/test/resources/META-INF/services/st.orm.core.spi.TransactionTemplateProvider @@ -0,0 +1 @@ +st.orm.core.testsupport.TestSpringTransactionTemplateProvider diff --git a/storm-java21/src/main/java/st/orm/template/ORMTemplate.java b/storm-java21/src/main/java/st/orm/template/ORMTemplate.java index a19617999..a54f83189 100644 --- a/storm-java21/src/main/java/st/orm/template/ORMTemplate.java +++ b/storm-java21/src/main/java/st/orm/template/ORMTemplate.java @@ -317,4 +317,127 @@ static ORMTemplate of(@Nonnull Connection connection, @Nonnull StormConfig confi @Nonnull UnaryOperator decorator) { return new ORMTemplateImpl(st.orm.core.template.ORMTemplate.of(connection, config, decorator)); } + + /** + * Returns a builder for constructing an {@link ORMTemplate} with instance-scoped integration strategies. + * + *

The builder is the injection point for platform services: integrations hand their connection provider, + * transaction template provider, exception mapper and query observer to the template they construct, instead of + * relying on JVM-global discovery.

+ * + * @param dataSource the {@link DataSource} to use for database operations; must not be {@code null}. + * @return a builder for constructing the ORM template. + * @since 1.13 + */ + static Builder builder(@Nonnull DataSource dataSource) { + return new Builder(st.orm.core.template.ORMTemplate.builder(dataSource)); + } + + /** + * Returns a builder for constructing an {@link ORMTemplate} backed by a single connection, with instance-scoped + * integration strategies. + * + *

Note: The caller is responsible for closing the connection after usage.

+ * + * @param connection the {@link Connection} to use for database operations; must not be {@code null}. + * @return a builder for constructing the ORM template. + * @since 1.13 + */ + static Builder builder(@Nonnull Connection connection) { + return new Builder(st.orm.core.template.ORMTemplate.builder(connection)); + } + + /** + * Builder for constructing an {@link ORMTemplate} with instance-scoped integration strategies. + * + * @since 1.13 + */ + final class Builder { + private final st.orm.core.template.ORMTemplate.Builder core; + + private Builder(@Nonnull st.orm.core.template.ORMTemplate.Builder core) { + this.core = core; + } + + /** + * Sets the Storm configuration to apply to the template instance. + * + * @param config the Storm configuration; must not be {@code null}. + * @return this builder. + */ + public Builder config(@Nonnull StormConfig config) { + core.config(config); + return this; + } + + /** + * Sets a function that transforms the {@link TemplateDecorator} to customize template processing. + * + * @param decorator the decorator function; must not be {@code null}. + * @return this builder. + */ + public Builder decorator(@Nonnull UnaryOperator decorator) { + core.decorator(decorator); + return this; + } + + /** + * Sets the connection provider used by the template to acquire and release connections. + * + *

Only valid for data source backed templates; {@link #build()} fails fast otherwise.

+ * + * @param connectionProvider the connection provider; must not be {@code null}. + * @return this builder. + */ + public Builder connectionProvider(@Nonnull st.orm.core.spi.ConnectionProvider connectionProvider) { + core.connectionProvider(connectionProvider); + return this; + } + + /** + * Sets the transaction template provider used by the template to participate in transactions. + * + *

Templates that should share transactions must be configured with the same provider instance.

+ * + * @param transactionTemplateProvider the transaction template provider; must not be {@code null}. + * @return this builder. + */ + public Builder transactionTemplateProvider( + @Nonnull st.orm.core.spi.TransactionTemplateProvider transactionTemplateProvider) { + core.transactionTemplateProvider(transactionTemplateProvider); + return this; + } + + /** + * Sets the exception mapper that maps failures raised during query execution to the runtime exception thrown + * to the caller. + * + * @param exceptionMapper the exception mapper; must not be {@code null}. + * @return this builder. + */ + public Builder exceptionMapper(@Nonnull st.orm.core.spi.ExceptionMapper exceptionMapper) { + core.exceptionMapper(exceptionMapper); + return this; + } + + /** + * Sets the query observer that is notified of query executions performed by the template. + * + * @param queryObserver the query observer; must not be {@code null}. + * @return this builder. + */ + public Builder queryObserver(@Nonnull st.orm.core.spi.QueryObserver queryObserver) { + core.queryObserver(queryObserver); + return this; + } + + /** + * Builds the ORM template. + * + * @return the ORM template. + */ + public ORMTemplate build() { + return new ORMTemplateImpl(core.build()); + } + } } diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/ORMTemplate.kt b/storm-kotlin/src/main/kotlin/st/orm/template/ORMTemplate.kt index cb1fc1242..7e3b44e20 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/ORMTemplate.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/ORMTemplate.kt @@ -305,6 +305,79 @@ interface ORMTemplate : config: StormConfig, decorator: (TemplateDecorator) -> TemplateDecorator, ): ORMTemplate = ORMTemplateImpl(st.orm.core.template.ORMTemplate.of(connection, config, decorator)) + + /** + * Returns a builder for constructing an [ORMTemplate] with instance-scoped integration strategies. + * + * The builder is the injection point for platform services: integrations hand their connection provider, + * transaction template provider, exception mapper and query observer to the template they construct, instead + * of relying on JVM-global discovery. + * + * @param dataSource the [DataSource] to use for database operations. + * @return a builder for constructing the ORM template. + * @since 1.13 + */ + fun builder(dataSource: DataSource): Builder = Builder(st.orm.core.template.ORMTemplate.builder(dataSource)) + + /** + * Returns a builder for constructing an [ORMTemplate] backed by a single connection, with instance-scoped + * integration strategies. + * + * **Note:** The caller is responsible for closing the connection after usage. + * + * @param connection the [Connection] to use for database operations. + * @return a builder for constructing the ORM template. + * @since 1.13 + */ + fun builder(connection: Connection): Builder = Builder(st.orm.core.template.ORMTemplate.builder(connection)) + } + + /** + * Builder for constructing an [ORMTemplate] with instance-scoped integration strategies. + * + * @since 1.13 + */ + class Builder internal constructor(private val core: st.orm.core.template.ORMTemplate.Builder) { + + /** + * Sets the Storm configuration to apply to the template instance. + */ + fun config(config: StormConfig): Builder = apply { core.config(config) } + + /** + * Sets a function that transforms the [TemplateDecorator] to customize template processing. + */ + fun decorator(decorator: (TemplateDecorator) -> TemplateDecorator): Builder = apply { core.decorator(decorator) } + + /** + * Sets the connection provider used by the template to acquire and release connections. + * + * Only valid for data source backed templates; [build] fails fast otherwise. + */ + fun connectionProvider(connectionProvider: st.orm.core.spi.ConnectionProvider): Builder = apply { core.connectionProvider(connectionProvider) } + + /** + * Sets the transaction template provider used by the template to participate in transactions. + * + * Templates that should share transactions must be configured with the *same provider instance*. + */ + fun transactionTemplateProvider(transactionTemplateProvider: st.orm.core.spi.TransactionTemplateProvider): Builder = apply { core.transactionTemplateProvider(transactionTemplateProvider) } + + /** + * Sets the exception mapper that maps failures raised during query execution to the runtime exception thrown + * to the caller. + */ + fun exceptionMapper(exceptionMapper: st.orm.core.spi.ExceptionMapper): Builder = apply { core.exceptionMapper(exceptionMapper) } + + /** + * Sets the query observer that is notified of query executions performed by the template. + */ + fun queryObserver(queryObserver: st.orm.core.spi.QueryObserver): Builder = apply { core.queryObserver(queryObserver) } + + /** + * Builds the ORM template. + */ + fun build(): ORMTemplate = ORMTemplateImpl(core.build()) } } diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/Transactions.kt b/storm-kotlin/src/main/kotlin/st/orm/template/Transactions.kt index 305567164..d816b7872 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/Transactions.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/Transactions.kt @@ -16,13 +16,12 @@ package st.orm.template import kotlinx.coroutines.* -import st.orm.core.spi.Providers.getTransactionTemplate -import st.orm.core.spi.TransactionContext -import st.orm.core.spi.TransactionTemplate +import st.orm.core.spi.TransactionScope import st.orm.template.TransactionIsolation.* import st.orm.template.TransactionPropagation.* import st.orm.template.impl.TransactionCallbacks import java.sql.Connection.* +import java.sql.SQLTimeoutException import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext @@ -30,6 +29,10 @@ import kotlin.coroutines.CoroutineContext /** * Executes the given [block] within a database transaction. * + * The transaction binds to the first ORM template that executes inside the block: opening the block only records the + * requested options, and the template's transaction provider opens the actual transaction on first use. A block that + * never touches a template completes as a no-op. + * * ## Propagation behavior matrix * * | Propagation | Inner commit | Inner rollback | Outer commit | Outer rollback | @@ -59,63 +62,75 @@ fun transactionBlocking( ): T { val options = localTransactionOptions.get() ?: globalTransactionOptions.get() val resolvedPropagation = propagation ?: options.propagation - val transactionTemplate = getTransactionTemplate( + val scopeOptions = scopeOptions( propagation = resolvedPropagation, isolation = isolation ?: options.isolation, timeoutSeconds = timeoutSeconds ?: options.timeoutSeconds, readOnly = readOnly ?: options.readOnly, + suspendMode = false, ) - val contextHolder = transactionTemplate.contextHolder() - contextHolder.get()?.let { existingCtx -> - // An outer transaction context exists. Determine whether this scope joins it or owns a new physical tx. - val parentCallbacks = currentBlockingCallbacks.get() - if (resolvedPropagation.isJoining && parentCallbacks != null) { - // Joining: delegate callbacks to the outer holder; do not fire here. - return executeBlocking(transactionTemplate, existingCtx, parentCallbacks, block) - } - // REQUIRES_NEW / NOT_SUPPORTED or no parent callbacks: own lifecycle. - // contextHolder is managed by the outer scope; only currentBlockingCallbacks is ours. - val callbacks = TransactionCallbacks() - val previousCallbacks = currentBlockingCallbacks.get() - currentBlockingCallbacks.set(callbacks) - val outcome = try { - executeBlockingAndCapture(transactionTemplate, existingCtx, callbacks, block) + val scope = TransactionScope.open(scopeOptions) + val parentCallbacks = currentBlockingCallbacks.get() + if (resolvedPropagation.isJoining && scope.parent() != null && parentCallbacks != null) { + // Joining an outer transaction block: delegate callbacks to the outer holder; do not fire here. + val result = try { + block(scopeTransaction(scope, parentCallbacks)) } catch (e: Throwable) { - // Restore before firing so callbacks see a clean state. - if (previousCallbacks == null) currentBlockingCallbacks.remove() else currentBlockingCallbacks.set(previousCallbacks) try { - callbacks.fireRollbackBlocking() - } catch (callbackException: Throwable) { - e.addSuppressed(callbackException) + completeAfterFailure(scope, e) + } finally { + scope.close() } - throw e } - // Restore before firing so callbacks see a clean state. - if (previousCallbacks == null) currentBlockingCallbacks.remove() else currentBlockingCallbacks.set(previousCallbacks) - if (outcome.rollbackOnly) callbacks.fireRollbackBlocking() else callbacks.fireCommitBlocking() - return outcome.value + try { + scope.complete(false) + afterSuccessfulCompletion(scope) + } finally { + scope.close() + } + return result } - // Outermost: we own the physical transaction. + // Owner of the callback lifecycle: outermost block, REQUIRES_NEW / NOT_SUPPORTED, or no parent callbacks. val callbacks = TransactionCallbacks() val previousCallbacks = currentBlockingCallbacks.get() currentBlockingCallbacks.set(callbacks) - val newContext = transactionTemplate.newContext(false).also { contextHolder.set(it) } val outcome = try { - executeBlockingAndCapture(transactionTemplate, newContext, callbacks, block) + val value = block(scopeTransaction(scope, callbacks)) + TransactionOutcome(value, scope.isRollbackOnly) } catch (e: Throwable) { - // Clean up transaction state before firing rollback callbacks. - contextHolder.remove() + // Restore and uninstall the scope before firing so callbacks see a clean state. if (previousCallbacks == null) currentBlockingCallbacks.remove() else currentBlockingCallbacks.set(previousCallbacks) + val wrapped = try { + completeAfterFailure(scope, e) + } catch (completionException: Throwable) { + completionException + } finally { + scope.close() + } try { callbacks.fireRollbackBlocking() } catch (callbackException: Throwable) { - e.addSuppressed(callbackException) + wrapped.addSuppressed(callbackException) } - throw e + throw wrapped } - // Clean up transaction state before firing callbacks. - contextHolder.remove() + // Restore and uninstall the scope before firing so callbacks see a clean state. if (previousCallbacks == null) currentBlockingCallbacks.remove() else currentBlockingCallbacks.set(previousCallbacks) + try { + try { + scope.complete(false) + afterSuccessfulCompletion(scope) + } finally { + scope.close() + } + } catch (completionException: Throwable) { + try { + callbacks.fireRollbackBlocking() + } catch (callbackException: Throwable) { + completionException.addSuppressed(callbackException) + } + throw completionException + } if (outcome.rollbackOnly) callbacks.fireRollbackBlocking() else callbacks.fireCommitBlocking() return outcome.value } @@ -127,6 +142,10 @@ fun transactionBlocking( * (e.g. a dispatcher) while preserving all the usual Spring semantics for propagation, * isolation, timeout and read-only settings. * + * The transaction binds to the first ORM template that executes inside the block: opening the block only records the + * requested options, and the template's transaction provider opens the actual transaction on first use. A block that + * never touches a template completes as a no-op. + * * ## Propagation behavior matrix * * | Propagation | Inner commit | Inner rollback | Outer commit | Outer rollback | @@ -160,80 +179,75 @@ suspend fun transaction( val currentContext = currentCoroutineContext() val options = currentContext[Scoped]?.options ?: globalTransactionOptions.get() val resolvedPropagation = propagation ?: options.propagation - val transactionTemplate = getTransactionTemplate( + val scopeOptions = scopeOptions( propagation = resolvedPropagation, isolation = isolation ?: options.isolation, timeoutSeconds = timeoutSeconds ?: options.timeoutSeconds, readOnly = readOnly ?: options.readOnly, + suspendMode = true, ) - // Already in a transaction: re-use it and ensure the ThreadLocal holder is visible on this thread. - currentContext[TransactionKey]?.context?.let { existing -> - val parentCallbacks = currentContext[CallbacksKey]?.callbacks - if (resolvedPropagation.isJoining && parentCallbacks != null) { - // Joining: delegate callbacks to the outer holder; do not fire here. - val elements = TransactionKey(existing) + - CallbacksKey(parentCallbacks) + - transactionTemplate.contextHolder().asContextElement(existing) + - currentBlockingCallbacks.asContextElement(parentCallbacks) + - localTransactionOptions.asContextElement(options) - return withContext(currentContext + elements) { - executeSuspend(coroutineContext, transactionTemplate, existing, parentCallbacks, block) - } - } - // REQUIRES_NEW / NOT_SUPPORTED or no parent callbacks: own lifecycle. - val callbacks = TransactionCallbacks() - val elements = TransactionKey(existing) + - CallbacksKey(callbacks) + - transactionTemplate.contextHolder().asContextElement(existing) + - currentBlockingCallbacks.asContextElement(callbacks) + + val parentScope = TransactionScope.current() + val scope = TransactionScope.create(scopeOptions, parentScope) + val parentCallbacks = currentContext[CallbacksKey]?.callbacks + if (resolvedPropagation.isJoining && parentScope != null && parentCallbacks != null) { + // Joining an outer transaction block: delegate callbacks to the outer holder; do not fire here. + val elements = CallbacksKey(parentCallbacks) + + TransactionScope.holder().asContextElement(scope) + + currentBlockingCallbacks.asContextElement(parentCallbacks) + localTransactionOptions.asContextElement(options) - // Fire callbacks AFTER withContext returns, so TransactionKey, CallbacksKey, and ThreadLocals are restored. - val outcome = try { + val result = try { withContext(currentContext + elements) { - executeSuspendAndCapture(coroutineContext, transactionTemplate, existing, callbacks, block) + block(scopeTransaction(scope, parentCallbacks)) } } catch (e: Throwable) { - try { - callbacks.fireRollback() - } catch (callbackException: Throwable) { - e.addSuppressed(callbackException) - } - throw e + completeAfterFailure(scope, e) } - if (outcome.rollbackOnly) callbacks.fireRollback() else callbacks.fireCommit() - return outcome.value + scope.complete(false) + afterSuccessfulCompletion(scope) + return result } - val newContext = transactionTemplate.newContext(true) + // Owner of the callback lifecycle: outermost block, REQUIRES_NEW / NOT_SUPPORTED, or no parent callbacks. val callbacks = TransactionCallbacks() - val elements = TransactionKey(newContext) + - CallbacksKey(callbacks) + - transactionTemplate.contextHolder().asContextElement(newContext) + // Make the context available via the ThreadLocal. + val elements = CallbacksKey(callbacks) + + TransactionScope.holder().asContextElement(scope) + // Make the scope available via the ThreadLocal. currentBlockingCallbacks.asContextElement(callbacks) + localTransactionOptions.asContextElement(options) // Make the options available via the ThreadLocal in case the blocking variant is invoked from suspend context. - // Fire callbacks AFTER withContext returns, so TransactionKey, CallbacksKey, and ThreadLocals are restored. + // The dispatcher only applies to outermost transactions; nested blocks stay on the caller's dispatcher. + val context = if (parentScope == null) currentContext + dispatcher + elements else currentContext + elements + // Fire callbacks AFTER withContext returns, so CallbacksKey and ThreadLocals are restored. val outcome = try { - withContext(currentContext + dispatcher + elements) { - // Potentially add limitedParallelism(1) here in the future. - // Just pass the elements, not the context, as the caller might have switched dispatchers. We just want to make - // the transaction context available to the caller's coroutine. - executeSuspendAndCapture(coroutineContext, transactionTemplate, newContext, callbacks, block) + withContext(context) { + val value = block(scopeTransaction(scope, callbacks)) + TransactionOutcome(value, scope.isRollbackOnly) } } catch (e: Throwable) { + val wrapped = try { + completeAfterFailure(scope, e) + } catch (completionException: Throwable) { + completionException + } try { callbacks.fireRollback() } catch (callbackException: Throwable) { - e.addSuppressed(callbackException) + wrapped.addSuppressed(callbackException) } - throw e + throw wrapped + } + try { + scope.complete(false) + afterSuccessfulCompletion(scope) + } catch (completionException: Throwable) { + try { + callbacks.fireRollback() + } catch (callbackException: Throwable) { + completionException.addSuppressed(callbackException) + } + throw completionException } if (outcome.rollbackOnly) callbacks.fireRollback() else callbacks.fireCommit() return outcome.value } -private class TransactionKey(val context: TransactionContext) : AbstractCoroutineContextElement(Key) { - companion object Key : CoroutineContext.Key -} - /** * Coroutine context element that carries the [TransactionCallbacks] holder for the current physical transaction. */ @@ -260,151 +274,96 @@ private val TransactionPropagation.isJoining: Boolean */ private class TransactionOutcome(val value: T, val rollbackOnly: Boolean) -private fun getTransactionTemplate( - propagation: TransactionPropagation = REQUIRED, - isolation: TransactionIsolation? = null, - timeoutSeconds: Int? = null, - readOnly: Boolean = false, -) = getTransactionTemplate().apply { - propagation(propagation.toString()) - isolation?.let { - isolation( - when (it) { - READ_UNCOMMITTED -> TRANSACTION_READ_UNCOMMITTED - READ_COMMITTED -> TRANSACTION_READ_COMMITTED - REPEATABLE_READ -> TRANSACTION_REPEATABLE_READ - SERIALIZABLE -> TRANSACTION_SERIALIZABLE - }, - ) - } - timeoutSeconds?.let { timeout(it) } - readOnly(readOnly) -} - /** - * Executes [block] inside the transaction and returns the result with the rollback-only status. Does not fire - * callbacks; the caller is responsible for cleanup and firing. + * Maps the resolved transaction options onto the provider-agnostic scope options. */ -private fun executeBlockingAndCapture( - transactionTemplate: TransactionTemplate, - transactionContext: TransactionContext, - callbacks: TransactionCallbacks, - block: Transaction.() -> T, -): TransactionOutcome { - var rollbackOnly = false - val result = transactionTemplate.execute({ status -> - val transaction = object : Transaction { - override val isRollbackOnly: Boolean - get() = status.isRollbackOnly - override fun setRollbackOnly() { - status.setRollbackOnly() - } - override fun onCommit(callback: suspend () -> Unit) { - callbacks.addOnCommit(callback) - } - override fun onRollback(callback: suspend () -> Unit) { - callbacks.addOnRollback(callback) - } +private fun scopeOptions( + propagation: TransactionPropagation, + isolation: TransactionIsolation?, + timeoutSeconds: Int?, + readOnly: Boolean, + suspendMode: Boolean, +): TransactionScope.Options = TransactionScope.Options( + propagation.toString(), + isolation?.let { + when (it) { + READ_UNCOMMITTED -> TRANSACTION_READ_UNCOMMITTED + READ_COMMITTED -> TRANSACTION_READ_COMMITTED + REPEATABLE_READ -> TRANSACTION_REPEATABLE_READ + SERIALIZABLE -> TRANSACTION_SERIALIZABLE } - block(transaction).also { rollbackOnly = status.isRollbackOnly } - }, transactionContext) - return TransactionOutcome(result, rollbackOnly) -} + }, + timeoutSeconds, + readOnly, + suspendMode, +) /** - * Executes [block] inside the transaction, delegating callbacks to the provided [callbacks] holder. - * Used when joining an existing physical transaction (blocking path). + * Returns the [Transaction] receiver for the given scope, delegating callback registration to [callbacks]. */ -private fun executeBlocking( - transactionTemplate: TransactionTemplate, - transactionContext: TransactionContext, - callbacks: TransactionCallbacks, - block: Transaction.() -> T, -): T = transactionTemplate.execute({ status -> - val transaction = object : Transaction { - override val isRollbackOnly: Boolean - get() = status.isRollbackOnly - override fun setRollbackOnly() { - status.setRollbackOnly() - } - override fun onCommit(callback: suspend () -> Unit) { - callbacks.addOnCommit(callback) - } - override fun onRollback(callback: suspend () -> Unit) { - callbacks.addOnRollback(callback) - } +private fun scopeTransaction(scope: TransactionScope, callbacks: TransactionCallbacks): Transaction = object : Transaction { + override val isRollbackOnly: Boolean + get() = scope.isRollbackOnly + + override fun setRollbackOnly() { + scope.setRollbackOnly() + } + + override fun onCommit(callback: suspend () -> Unit) { + callbacks.addOnCommit(callback) + } + + override fun onRollback(callback: suspend () -> Unit) { + callbacks.addOnRollback(callback) } - block(transaction) -}, transactionContext) +} /** - * Suspend variant: executes [block] inside the transaction and returns the result with the rollback-only status. - * Does not fire callbacks; the caller is responsible for cleanup and firing. + * Post-completion checks that mirror the semantics of eagerly entered transaction frames. + * + * A scope that never touched a template still fails deterministically when its deadline has passed, marking a joined + * outer scope rollback-only. A scope whose rollback-only mark was inherited from a joined inner scope raises an + * [UnexpectedRollbackException] when its block attempts to commit; materialized frames raise this from the provider's + * commit path, this check covers marks that were propagated at the scope level. */ -private fun executeSuspendAndCapture( - context: CoroutineContext, - transactionTemplate: TransactionTemplate, - transactionContext: TransactionContext, - callbacks: TransactionCallbacks, - block: suspend Transaction.() -> T, -): TransactionOutcome { - var rollbackOnly = false - val result = transactionTemplate.execute({ status -> - val transaction = object : Transaction { - override val isRollbackOnly: Boolean - get() = status.isRollbackOnly - override fun setRollbackOnly() { - status.setRollbackOnly() - } - override fun onCommit(callback: suspend () -> Unit) { - callbacks.addOnCommit(callback) - } - override fun onRollback(callback: suspend () -> Unit) { - callbacks.addOnRollback(callback) - } +private fun afterSuccessfulCompletion(scope: TransactionScope) { + if (!scope.isMaterialized && scope.isDeadlineExpired) { + val propagation = scope.options().propagation() + val joining = propagation == null || propagation == "REQUIRED" || propagation == "SUPPORTS" || propagation == "MANDATORY" + if (joining) { + scope.parent()?.setRollbackOnly() } - runBlocking(context) { - block(transaction) - }.also { rollbackOnly = status.isRollbackOnly } - }, transactionContext) - return TransactionOutcome(result, rollbackOnly) + throw TransactionTimedOutException("Did not complete within timeout.") + } + if (scope.isRollbackInherited) { + throw UnexpectedRollbackException("Transaction was marked rollback-only by a joined scope.") + } } /** - * Suspend variant: executes [block] inside the transaction, delegating callbacks to the provided [callbacks] holder. - * Used when joining an existing physical transaction. + * Completes the scope after a failed block and returns the exception to throw: the original failure, or a + * [TransactionTimedOutException] when the failure was caused by a statement timeout. Completion failures are + * suppressed onto the original failure. */ -private fun executeSuspend( - context: CoroutineContext, - transactionTemplate: TransactionTemplate, - transactionContext: TransactionContext, - callbacks: TransactionCallbacks, - block: suspend Transaction.() -> T, -): T { - @Suppress("UNCHECKED_CAST") - return transactionTemplate.execute({ status -> - val transaction = object : Transaction { - override val isRollbackOnly: Boolean - get() = status.isRollbackOnly - override fun setRollbackOnly() { - status.setRollbackOnly() - } - override fun onCommit(callback: suspend () -> Unit) { - callbacks.addOnCommit(callback) - } - override fun onRollback(callback: suspend () -> Unit) { - callbacks.addOnRollback(callback) - } - } - runBlocking(context) { - // Bridges between the blocking TransactionTemplate.execute call and the suspending block, while propagating - // the transaction context into the coroutine. - // - // Note: On pre-Java 24 virtual threads, runBlocking will pin the carrier thread for the entire duration of - // the block, eliminating the scalability benefits of virtual threads. - block(transaction) - } - }, transactionContext) +private fun completeAfterFailure(scope: TransactionScope, e: Throwable): Nothing { + val description = try { + scope.materializedContext()?.describe()?.orElse(null) + } catch (ignore: Throwable) { + null + } + try { + scope.complete(true) // Suppresses rollback exceptions internally to surface the original error. + } catch (completionException: Throwable) { + e.addSuppressed(completionException) + } + if (e.cause is SQLTimeoutException) { + val base = e.message ?: "Did not complete within timeout." + throw TransactionTimedOutException( + if (description != null) "$base [$description]" else base, + e, + ) + } + throw e } /** diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/impl/CoroutineAwareConnectionProviderImpl.kt b/storm-kotlin/src/main/kotlin/st/orm/template/impl/CoroutineAwareConnectionProviderImpl.kt index 156c0551d..1c5ad1be3 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/impl/CoroutineAwareConnectionProviderImpl.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/impl/CoroutineAwareConnectionProviderImpl.kt @@ -21,13 +21,17 @@ import st.orm.core.spi.TransactionContext import java.lang.System.identityHashCode import java.lang.ref.ReferenceQueue import java.lang.ref.WeakReference -import java.lang.reflect.InvocationTargetException -import java.lang.reflect.Method import java.sql.Connection import java.util.concurrent.ConcurrentHashMap import javax.sql.DataSource /** + * Coroutine-aware connection provider that binds connections to the active [JdbcTransactionContext]. + * + *

This provider is platform-neutral: outside a programmatic transaction, connections are acquired and closed + * directly on the data source. Integrations that bind connections to an external transaction subsystem supply their + * own [ConnectionProvider] via the template builder.

+ * * @since 1.5 */ class CoroutineAwareConnectionProviderImpl : ConnectionProvider { @@ -35,7 +39,6 @@ class CoroutineAwareConnectionProviderImpl : ConnectionProvider { override fun getConnection(dataSource: DataSource, context: TransactionContext?): Connection { if (context != null) { require(context is JdbcTransactionContext) { "Transaction context must be of type JdbcTransactionContext." } - validateState() val connection = context.getConnection(dataSource) ConcurrencyDetector.beforeAccess(connection, context) return connection @@ -57,38 +60,9 @@ class CoroutineAwareConnectionProviderImpl : ConnectionProvider { releaseRegularConnection(connection, dataSource) } - private fun validateState() { - try { - if (IS_ACTUAL_TRANSACTION_ACTIVE != null) { - try { - if (IS_ACTUAL_TRANSACTION_ACTIVE.invoke(null) as Boolean) { - throw PersistenceException( - "Programmatic transactions and Spring managed transactions cannot be mixed when spring-managed transactions are disabled for storm. " + - "Use `@EnableTransactionIntegration` to enable spring-managed transactions for storm.", - ) - } - } catch (e: InvocationTargetException) { - throw e.targetException - } - } - } catch (e: PersistenceException) { - throw e - } catch (t: Throwable) { - throw PersistenceException("Failed to validate connection.", t) - } - } - private fun getRegularConnection(dataSource: DataSource): Connection { try { - return if (GET_CONNECTION_METHOD != null) { - try { - GET_CONNECTION_METHOD.invoke(null, dataSource) as Connection - } catch (e: InvocationTargetException) { - throw e.targetException - } - } else { - dataSource.connection - } + return dataSource.connection } catch (t: Throwable) { throw PersistenceException("Failed to get connection from DataSource.", t) } @@ -96,15 +70,7 @@ class CoroutineAwareConnectionProviderImpl : ConnectionProvider { private fun releaseRegularConnection(connection: Connection, dataSource: DataSource) { try { - if (RELEASE_CONNECTION_METHOD != null) { - try { - RELEASE_CONNECTION_METHOD.invoke(null, connection, dataSource) - } catch (e: InvocationTargetException) { - throw e.targetException - } - } else { - connection.close() - } + connection.close() } catch (t: Throwable) { throw PersistenceException("Failed to release connection.", t) } @@ -168,36 +134,4 @@ class CoroutineAwareConnectionProviderImpl : ConnectionProvider { if (clear) owners.remove(key, owner) } } - - companion object { - private val GET_CONNECTION_METHOD: Method? - private val RELEASE_CONNECTION_METHOD: Method? - private val IS_ACTUAL_TRANSACTION_ACTIVE: Method? - - init { - var getConnection: Method? - var releaseConnection: Method? - var isActualTransactionActive: Method? - try { - val utilsClass = Class.forName("org.springframework.jdbc.datasource.DataSourceUtils") - val transactionSynchonizationManagerClass = - Class.forName("org.springframework.transaction.support.TransactionSynchronizationManager") - getConnection = utilsClass.getMethod("getConnection", DataSource::class.java) - releaseConnection = - utilsClass.getMethod("releaseConnection", Connection::class.java, DataSource::class.java) - isActualTransactionActive = transactionSynchonizationManagerClass.getMethod("isActualTransactionActive") - } catch (_: ClassNotFoundException) { - getConnection = null - releaseConnection = null - isActualTransactionActive = null - } catch (_: NoSuchMethodException) { - getConnection = null - releaseConnection = null - isActualTransactionActive = null - } - GET_CONNECTION_METHOD = getConnection - RELEASE_CONNECTION_METHOD = releaseConnection - IS_ACTUAL_TRANSACTION_ACTIVE = isActualTransactionActive - } - } } diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/impl/JdbcTransactionContext.kt b/storm-kotlin/src/main/kotlin/st/orm/template/impl/JdbcTransactionContext.kt index 6bb2c46d1..f03f70bc4 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/impl/JdbcTransactionContext.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/impl/JdbcTransactionContext.kt @@ -234,22 +234,22 @@ internal class JdbcTransactionContext : TransactionContext { private fun Int.toDeadlineFromNowNanos(): Long = System.nanoTime() + this.toLong() * 1_000_000_000L /** - * Executes a transaction block with specified attributes. + * Begins a transaction frame with the specified attributes. + * + *

The physical connection is bound lazily, when the first data source touches this context via + * [useDataSource]. The frame is finished with [complete].

* * @param propagation Transaction propagation behavior * @param isolation Transaction isolation level + * @param timeoutSeconds Transaction timeout in seconds * @param readOnly Transaction read-only setting - * @param callback Transaction operation to execute - * @return Result of the transaction operation - * @throws PersistenceException on transaction or database errors */ - fun execute( + internal fun begin( propagation: TransactionPropagation, isolation: Int?, timeoutSeconds: Int?, readOnly: Boolean?, - callback: TransactionCallback, - ): T { + ) { val state = TransactionState(propagation, isolation, timeoutSeconds, readOnly) logger.debug( """ @@ -262,44 +262,37 @@ internal class JdbcTransactionContext : TransactionContext { ) state.deadlineNanos = timeoutSeconds?.toDeadlineFromNowNanos() stack.add(state) - val result = try { - callback.doInTransaction(object : TransactionStatus { - override fun isRollbackOnly(): Boolean = this@JdbcTransactionContext.isRollbackOnly + } - override fun setRollbackOnly() { - this@JdbcTransactionContext.setRollbackOnly() - } - }) - } catch (e: Throwable) { - logger.trace("Transaction failed (${state.transactionId}).", e) + /** + * Completes the current transaction frame. + * + *

When [rollback] is `true`, the frame rolls back and any rollback failure is suppressed so the caller can + * surface the original error. Otherwise the frame commits; the commit path detects timeouts and rollback-only + * marks and rolls back instead, throwing accordingly.

+ * + * @param rollback whether the transactional work failed and the frame must be rolled back. + */ + internal fun complete(rollback: Boolean) { + if (rollback) { + logger.trace("Transaction failed (${stack.lastOrNull()?.transactionId}).") rollback(suppressException = true) // Suppress any rollback exception to ensure handling of exception. - when (e.cause) { - is SQLTimeoutException -> { - // TimeoutJob may not have registered timeout yet. - val base = e.message ?: "Did not complete within timeout." - throw TransactionTimedOutException( - "$base [${state.timeoutDescription()}]", - e, - ) - } - else -> throw e - } + } else { + // Let commit detect timeout or rollback-only. + commit() } - // Let commit detect timeout or rollback-only. - commit() - return result as T } /** * Check if the current transaction is marked for rollback-only. */ - private val isRollbackOnly: Boolean - get() = currentState.rollbackOnly + internal val isRollbackOnly: Boolean + get() = stack.lastOrNull()?.rollbackOnly ?: false /** * Mark the current transaction so that it will roll back on completion. */ - private fun setRollbackOnly() { + internal fun setRollbackOnly() { val lastIndex = stack.lastIndex val currentState = stack[lastIndex] logger.debug("Marking transaction for rollback (${currentState.transactionId}).") diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/impl/TransactionTemplateProviderImpl.kt b/storm-kotlin/src/main/kotlin/st/orm/template/impl/TransactionTemplateProviderImpl.kt index 85813df17..1c0cbb8b3 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/impl/TransactionTemplateProviderImpl.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/impl/TransactionTemplateProviderImpl.kt @@ -1,9 +1,10 @@ package st.orm.template.impl import st.orm.PersistenceException -import st.orm.core.spi.TransactionCallback import st.orm.core.spi.TransactionContext +import st.orm.core.spi.TransactionStatus import st.orm.core.spi.TransactionTemplate +import st.orm.core.spi.TransactionTemplate.TransactionHandle import st.orm.core.spi.TransactionTemplateProvider import st.orm.template.TransactionPropagation.* @@ -48,17 +49,29 @@ class TransactionTemplateProviderImpl : TransactionTemplateProvider { return this } - override fun newContext(suspendMode: Boolean): TransactionContext = // Suspend mode is supported in this implementation. - JdbcTransactionContext() + override fun open(existing: TransactionContext?, suspendMode: Boolean): TransactionHandle { + // Suspend mode is supported by this implementation; the JDBC context binds state to the context + // object rather than the thread. + val context = when (existing) { + null -> JdbcTransactionContext() + is JdbcTransactionContext -> existing + else -> throw PersistenceException("Transaction context must be of type JdbcTransactionContext.") + } + context.begin(propagation, isolation, timeoutSeconds, readOnly) + return object : TransactionHandle { + override fun context(): TransactionContext = context - override fun contextHolder(): ThreadLocal = CONTEXT_HOLDER + override fun status(): TransactionStatus = object : TransactionStatus { + override fun setRollbackOnly() = context.setRollbackOnly() + + override fun isRollbackOnly(): Boolean = context.isRollbackOnly + } - override fun execute(callback: TransactionCallback, context: TransactionContext): T { - if (context !is JdbcTransactionContext) { - throw PersistenceException("Transaction context must be of type JdbcTransactionContext.") + override fun complete(rollback: Boolean) = context.complete(rollback) } - return context.execute(propagation, isolation, timeoutSeconds, readOnly, callback) } + + override fun contextHolder(): ThreadLocal = CONTEXT_HOLDER } } } From a3342a662f0e74df96bd43bee64c153915b2f0a7 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 9 Jul 2026 19:31:41 +0200 Subject: [PATCH 2/4] feat(spring,ktor)!: compose integration modules on instance-scoped strategies storm-spring: rename TransactionAwareConnectionProviderImpl to the public st.orm.spring.SpringConnectionProvider and add SpringTransactionTemplateProvider, a non-reflective port of the TransactionSynchronizationManager-linked context that previously lived in storm-core, preserving transaction-scoped entity caching for Spring-managed transactions. The ServiceLoader registration is removed. storm-kotlin-spring: delete SpringTransactionConfiguration's static transaction manager list and @EnableTransactionIntegration. The providers become public, constructor-injected classes (SpringConnectionProvider, SpringTransactionTemplateProvider) that carry their application context's transaction managers; SpringTransactionContext is restructured to the open/complete SPI. springOrmTemplate() is the canonical plain-Spring composition. ServiceLoader registrations are removed. Starters: StormTransactionAutoConfiguration now contributes Spring-aware ConnectionProvider and TransactionTemplateProvider beans (backed off via @ConditionalOnMissingBean), and both StormAutoConfigurations consume the strategy beans, plus optional ExceptionMapper and QueryObserver beans, through the template builder. The Java starter gains transaction-scoped entity caching under @Transactional via the ported provider. storm-ktor: install(Storm) gains connectionProvider, transactionTemplateProvider, exceptionMapper and queryObserver slots and composes the template with explicit plugin-scoped provider instances. --- .../autoconfigure/StormAutoConfiguration.kt | 23 ++- .../StormTransactionAutoConfiguration.kt | 38 +++- .../StormAutoConfigurationTest.kt | 39 +++- .../src/main/java/module-info.java | 2 - .../spring/EnableTransactionIntegration.kt | 28 --- ...derImpl.kt => SpringConnectionProvider.kt} | 21 ++- .../kotlin/st/orm/spring/SpringOrmTemplate.kt | 62 +++++++ .../spring/SpringTransactionConfiguration.kt | 42 ----- ...t => SpringTransactionTemplateProvider.kt} | 65 ++++--- .../spring/impl/SpringTransactionContext.kt | 63 +++---- .../st.orm.core.spi.ConnectionProvider | 1 - ...t.orm.core.spi.TransactionTemplateProvider | 1 - .../kotlin/st/orm/spring/AdditionalTest.kt | 3 +- .../st/orm/spring/SpringIntegrationConfig.kt | 19 ++ .../spring/SpringManagedTransactionTest.kt | 3 +- .../spring/SpringTransactionContextTest.kt | 3 +- .../orm/spring/TransactionPropagationTest.kt | 5 +- .../kotlin/st/orm/spring/TransactionTest.kt | 11 +- .../src/main/kotlin/st/orm/ktor/Storm.kt | 13 +- .../kotlin/st/orm/ktor/StormConfiguration.kt | 33 ++++ .../autoconfigure/StormAutoConfiguration.java | 33 +++- .../StormTransactionAutoConfiguration.java | 58 ++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + storm-spring/src/main/java/module-info.java | 1 - ...mpl.java => SpringConnectionProvider.java} | 21 ++- .../SpringTransactionTemplateProvider.java | 173 ++++++++++++++++++ .../st.orm.core.spi.ConnectionProvider | 1 - .../src/test/java/st/orm/spring/ImplTest.java | 7 +- 28 files changed, 596 insertions(+), 174 deletions(-) delete mode 100644 storm-kotlin-spring/src/main/kotlin/st/orm/spring/EnableTransactionIntegration.kt rename storm-kotlin-spring/src/main/kotlin/st/orm/spring/{impl/SpringConnectionProviderImpl.kt => SpringConnectionProvider.kt} (68%) create mode 100644 storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringOrmTemplate.kt delete mode 100644 storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringTransactionConfiguration.kt rename storm-kotlin-spring/src/main/kotlin/st/orm/spring/{impl/SpringTransactionTemplateProviderImpl.kt => SpringTransactionTemplateProvider.kt} (52%) delete mode 100644 storm-kotlin-spring/src/main/resources/META-INF/services/st.orm.core.spi.ConnectionProvider delete mode 100644 storm-kotlin-spring/src/main/resources/META-INF/services/st.orm.core.spi.TransactionTemplateProvider create mode 100644 storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringIntegrationConfig.kt create mode 100644 storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormTransactionAutoConfiguration.java rename storm-spring/src/main/java/st/orm/spring/{impl/TransactionAwareConnectionProviderImpl.java => SpringConnectionProvider.java} (64%) create mode 100644 storm-spring/src/main/java/st/orm/spring/SpringTransactionTemplateProvider.java delete mode 100644 storm-spring/src/main/resources/META-INF/services/st.orm.core.spi.ConnectionProvider diff --git a/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.kt b/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.kt index 08e271931..08dc3165b 100644 --- a/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.kt +++ b/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.kt @@ -15,6 +15,7 @@ */ package st.orm.spring.boot.autoconfigure +import org.springframework.beans.factory.ObjectProvider import org.springframework.beans.factory.SmartInitializingSingleton import org.springframework.boot.autoconfigure.AutoConfiguration import org.springframework.boot.autoconfigure.condition.ConditionalOnBean @@ -24,6 +25,10 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import st.orm.EntityCallback import st.orm.StormConfig +import st.orm.core.spi.ConnectionProvider +import st.orm.core.spi.ExceptionMapper +import st.orm.core.spi.QueryObserver +import st.orm.core.spi.TransactionTemplateProvider import st.orm.core.template.impl.SchemaValidator import st.orm.template.ORMTemplate import javax.sql.DataSource @@ -50,6 +55,11 @@ open class StormAutoConfiguration { * A [StormConfig] is built from the bound properties. Fields not explicitly configured in `application.yml` * fall back to system properties and then to built-in defaults. * + * Integration strategies are consumed from the application context when present: the Spring-aware connection and + * transaction template providers contributed by [StormTransactionAutoConfiguration] (or user-defined + * replacements), and optional [ExceptionMapper] and [QueryObserver] beans. Without such beans the template falls + * back to `ServiceLoader` discovery, matching standalone behavior. + * * This bean backs off if the user has already defined their own `ORMTemplate` bean. * * @param dataSource the data source to use for database operations. @@ -62,7 +72,18 @@ open class StormAutoConfiguration { dataSource: DataSource, properties: StormProperties, entityCallbacks: List>, - ): ORMTemplate = ORMTemplate.of(dataSource, toStormConfig(properties)).withEntityCallbacks(entityCallbacks) + connectionProvider: ObjectProvider, + transactionTemplateProvider: ObjectProvider, + exceptionMapper: ObjectProvider, + queryObserver: ObjectProvider, + ): ORMTemplate { + val builder = ORMTemplate.builder(dataSource).config(toStormConfig(properties)) + connectionProvider.ifAvailable { builder.connectionProvider(it) } + transactionTemplateProvider.ifAvailable { builder.transactionTemplateProvider(it) } + exceptionMapper.ifAvailable { builder.exceptionMapper(it) } + queryObserver.ifAvailable { builder.queryObserver(it) } + return builder.build().withEntityCallbacks(entityCallbacks) + } /** * Runs schema validation after all singleton beans have been fully initialized. This guarantees that migration diff --git a/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormTransactionAutoConfiguration.kt b/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormTransactionAutoConfiguration.kt index 2712388b8..84917fcd0 100644 --- a/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormTransactionAutoConfiguration.kt +++ b/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormTransactionAutoConfiguration.kt @@ -15,16 +15,25 @@ */ package st.orm.spring.boot.autoconfigure +import org.springframework.beans.factory.ObjectProvider import org.springframework.boot.autoconfigure.AutoConfiguration import org.springframework.boot.autoconfigure.condition.ConditionalOnBean -import org.springframework.context.annotation.Import +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.context.annotation.Bean import org.springframework.transaction.PlatformTransactionManager -import st.orm.spring.SpringTransactionConfiguration +import st.orm.core.spi.ConnectionProvider +import st.orm.core.spi.TransactionTemplateProvider +import st.orm.spring.SpringConnectionProvider +import st.orm.spring.SpringTransactionTemplateProvider /** - * Auto-configuration that activates [SpringTransactionConfiguration] when a [PlatformTransactionManager] is available. + * Auto-configuration that provides Spring-aware [ConnectionProvider] and [TransactionTemplateProvider] beans when a + * [PlatformTransactionManager] is available. * - * This replaces the need for `@EnableTransactionIntegration` in Spring Boot applications. + * The providers carry the transaction managers of this application context and are handed to the + * [st.orm.template.ORMTemplate] created by [StormAutoConfiguration]; nothing is registered globally, so multiple + * application contexts in one JVM each get their own, correctly matched transaction integration. Define your own + * [ConnectionProvider] or [TransactionTemplateProvider] bean to override the defaults. * * The ordering hints reference DataSourceTransactionManagerAutoConfiguration by name rather than by * class literal: the class moved to the modular spring-boot-jdbc jar in Spring Boot 4, and a class @@ -40,5 +49,22 @@ import st.orm.spring.SpringTransactionConfiguration ], ) @ConditionalOnBean(PlatformTransactionManager::class) -@Import(SpringTransactionConfiguration::class) -open class StormTransactionAutoConfiguration +open class StormTransactionAutoConfiguration { + + /** + * Provides the connection provider that binds connections to Spring's transaction management. + */ + @Bean + @ConditionalOnMissingBean(ConnectionProvider::class) + open fun stormConnectionProvider(): ConnectionProvider = SpringConnectionProvider() + + /** + * Provides the transaction template provider that bridges Storm's transaction API into this application + * context's [PlatformTransactionManager] beans. + */ + @Bean + @ConditionalOnMissingBean(TransactionTemplateProvider::class) + open fun stormTransactionTemplateProvider( + transactionManagers: ObjectProvider, + ): TransactionTemplateProvider = SpringTransactionTemplateProvider { transactionManagers.orderedStream().toList() } +} diff --git a/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.kt b/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.kt index c080523ff..b680340da 100644 --- a/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.kt +++ b/storm-kotlin-spring-boot-starter/src/test/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.kt @@ -30,8 +30,11 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import st.orm.Entity import st.orm.EntityCallback +import st.orm.core.spi.ConnectionProvider +import st.orm.core.spi.TransactionTemplateProvider import st.orm.spring.RepositoryBeanFactoryPostProcessor -import st.orm.spring.SpringTransactionConfiguration +import st.orm.spring.SpringConnectionProvider +import st.orm.spring.SpringTransactionTemplateProvider import st.orm.template.ORMTemplate import javax.sql.DataSource @@ -122,19 +125,45 @@ class StormAutoConfigurationTest { } @Test - fun `transaction auto-configuration activates SpringTransactionConfiguration`() { - // StormTransactionAutoConfiguration should register a SpringTransactionConfiguration bean - // when a PlatformTransactionManager is present (provided by DataSourceTransactionManagerAutoConfiguration). + fun `transaction auto-configuration provides spring-aware providers when a transaction manager is present`() { + // StormTransactionAutoConfiguration should contribute the Spring-aware ConnectionProvider and + // TransactionTemplateProvider beans when a PlatformTransactionManager is present (provided by + // DataSourceTransactionManagerAutoConfiguration). contextRunner .withPropertyValues( "spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1", "spring.datasource.driver-class-name=org.h2.Driver", ) .run { context -> - context.getBean(SpringTransactionConfiguration::class.java) shouldNotBe null + context.getBean(ConnectionProvider::class.java).shouldBeInstanceOf() + context.getBean(TransactionTemplateProvider::class.java) + .shouldBeInstanceOf() } } + @Test + fun `user-defined provider beans take precedence over the auto-configured ones`() { + // The auto-configured providers back off via @ConditionalOnMissingBean when the user defines their own. + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + ) + .withUserConfiguration(CustomProviderConfig::class.java) + .run { context -> + context.getBean(ConnectionProvider::class.java) + .shouldBeInstanceOf() + } + } + + @Configuration + open class CustomProviderConfig { + class CustomConnectionProvider(private val delegate: ConnectionProvider = SpringConnectionProvider()) : ConnectionProvider by delegate + + @Bean + open fun customConnectionProvider(): ConnectionProvider = CustomConnectionProvider() + } + @Test fun `storm properties bound from application configuration`() { // Storm properties under the "storm.*" prefix should be bound to StormProperties and applied diff --git a/storm-kotlin-spring/src/main/java/module-info.java b/storm-kotlin-spring/src/main/java/module-info.java index 95832dcc3..e7a71aa64 100644 --- a/storm-kotlin-spring/src/main/java/module-info.java +++ b/storm-kotlin-spring/src/main/java/module-info.java @@ -20,6 +20,4 @@ requires spring.boot.autoconfigure; exports st.orm.spring; opens st.orm.spring.impl to kotlin.reflect; - provides st.orm.core.spi.ConnectionProvider with st.orm.spring.impl.SpringConnectionProviderImpl; - provides st.orm.core.spi.TransactionTemplateProvider with st.orm.spring.impl.SpringTransactionTemplateProviderImpl; } diff --git a/storm-kotlin-spring/src/main/kotlin/st/orm/spring/EnableTransactionIntegration.kt b/storm-kotlin-spring/src/main/kotlin/st/orm/spring/EnableTransactionIntegration.kt deleted file mode 100644 index 041b3653b..000000000 --- a/storm-kotlin-spring/src/main/kotlin/st/orm/spring/EnableTransactionIntegration.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2024 - 2026 the original author or authors. - * - * Licensed 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 st.orm.spring - -import org.springframework.context.annotation.Import -import kotlin.annotation.AnnotationRetention.RUNTIME -import kotlin.annotation.AnnotationTarget.CLASS - -/** - * Enables Spring transaction integration for Storm. - */ -@Target(CLASS) -@Retention(RUNTIME) -@Import(SpringTransactionConfiguration::class) -annotation class EnableTransactionIntegration diff --git a/storm-kotlin-spring/src/main/kotlin/st/orm/spring/impl/SpringConnectionProviderImpl.kt b/storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringConnectionProvider.kt similarity index 68% rename from storm-kotlin-spring/src/main/kotlin/st/orm/spring/impl/SpringConnectionProviderImpl.kt rename to storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringConnectionProvider.kt index dd60c66d2..13d71c0ec 100644 --- a/storm-kotlin-spring/src/main/kotlin/st/orm/spring/impl/SpringConnectionProviderImpl.kt +++ b/storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringConnectionProvider.kt @@ -13,24 +13,31 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package st.orm.spring.impl +package st.orm.spring import org.springframework.jdbc.CannotGetJdbcConnectionException import org.springframework.jdbc.datasource.DataSourceUtils import st.orm.PersistenceException import st.orm.core.spi.ConnectionProvider -import st.orm.core.spi.Orderable.BeforeAny import st.orm.core.spi.TransactionContext -import st.orm.spring.SpringTransactionConfiguration +import st.orm.spring.impl.SpringTransactionContext import java.sql.Connection import javax.sql.DataSource /** - * @since 1.5 + * Connection provider that binds connections to Spring's transaction management. + * + * Connections are acquired through [DataSourceUtils], so statements executed by the template participate in + * Spring-managed (`@Transactional`) transactions via thread-bound connections. When Storm's own `transaction { }` + * API drives the transaction, the active [SpringTransactionContext] is bound to the data source before the + * connection is acquired, which starts the Spring transaction through the matching `PlatformTransactionManager`. + * + * Configure this provider on the template that belongs to the owning application context, together with + * [SpringTransactionTemplateProvider]; see [springOrmTemplate]. + * + * @since 1.13 */ -@BeforeAny -class SpringConnectionProviderImpl : ConnectionProvider { - override fun isEnabled(): Boolean = SpringTransactionConfiguration.transactionManagers.isNotEmpty() +class SpringConnectionProvider : ConnectionProvider { override fun getConnection(dataSource: DataSource, context: TransactionContext?): Connection { if (context != null) { diff --git a/storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringOrmTemplate.kt b/storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringOrmTemplate.kt new file mode 100644 index 000000000..49e1c48f5 --- /dev/null +++ b/storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringOrmTemplate.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.spring + +import org.springframework.transaction.PlatformTransactionManager +import st.orm.EntityCallback +import st.orm.StormConfig +import st.orm.template.ORMTemplate +import javax.sql.DataSource + +/** + * Creates an [ORMTemplate] wired to Spring's transaction management. + * + * This is the canonical composition for plain Spring (non-Boot) applications; the Spring Boot starter performs the + * equivalent wiring automatically. The returned template participates in Spring-managed (`@Transactional`) + * transactions and bridges Storm's `transaction { }` API into the given transaction managers. + * + * Example: + * ```kotlin + * @Configuration + * @EnableTransactionManagement + * open class AppConfig { + * @Bean + * open fun ormTemplate( + * dataSource: DataSource, + * transactionManagers: ObjectProvider, + * ): ORMTemplate = springOrmTemplate(dataSource) { transactionManagers.orderedStream().toList() } + * } + * ``` + * + * @param dataSource the data source to use for database operations. + * @param config the Storm configuration to apply. + * @param entityCallbacks the entity callbacks to register on the template. + * @param transactionManagers supplies the transaction managers of the owning application context; resolved lazily + * on first use. + * @return an ORM template wired to Spring's transaction management. + * @since 1.13 + */ +fun springOrmTemplate( + dataSource: DataSource, + config: StormConfig = StormConfig.defaults(), + entityCallbacks: List> = emptyList(), + transactionManagers: () -> List, +): ORMTemplate = ORMTemplate.builder(dataSource) + .config(config) + .connectionProvider(SpringConnectionProvider()) + .transactionTemplateProvider(SpringTransactionTemplateProvider(transactionManagers)) + .build() + .withEntityCallbacks(entityCallbacks) diff --git a/storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringTransactionConfiguration.kt b/storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringTransactionConfiguration.kt deleted file mode 100644 index dcd57648e..000000000 --- a/storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringTransactionConfiguration.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2024 - 2026 the original author or authors. - * - * Licensed 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 st.orm.spring - -import jakarta.annotation.PostConstruct -import org.springframework.boot.autoconfigure.AutoConfiguration -import org.springframework.transaction.PlatformTransactionManager - -/** - * A Spring configuration class that provides access to the transaction managers configured in the Spring context. - * - * @since 1.5 - */ -@AutoConfiguration -open class SpringTransactionConfiguration(val transactionManagers: List) { - - @PostConstruct - fun init() { - configured = transactionManagers - } - - companion object { - @Volatile - private var configured: List? = null - - val transactionManagers: List - get() = configured ?: emptyList() - } -} diff --git a/storm-kotlin-spring/src/main/kotlin/st/orm/spring/impl/SpringTransactionTemplateProviderImpl.kt b/storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringTransactionTemplateProvider.kt similarity index 52% rename from storm-kotlin-spring/src/main/kotlin/st/orm/spring/impl/SpringTransactionTemplateProviderImpl.kt rename to storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringTransactionTemplateProvider.kt index 2e9e852ee..32a392a06 100644 --- a/storm-kotlin-spring/src/main/kotlin/st/orm/spring/impl/SpringTransactionTemplateProviderImpl.kt +++ b/storm-kotlin-spring/src/main/kotlin/st/orm/spring/SpringTransactionTemplateProvider.kt @@ -13,28 +13,42 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package st.orm.spring.impl +package st.orm.spring +import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.TransactionDefinition.* import org.springframework.transaction.support.DefaultTransactionDefinition import st.orm.PersistenceException -import st.orm.core.spi.Orderable.BeforeAny -import st.orm.core.spi.TransactionCallback import st.orm.core.spi.TransactionContext +import st.orm.core.spi.TransactionStatus import st.orm.core.spi.TransactionTemplate +import st.orm.core.spi.TransactionTemplate.TransactionHandle import st.orm.core.spi.TransactionTemplateProvider -import st.orm.spring.SpringTransactionConfiguration +import st.orm.spring.impl.SpringTransactionContext /** - * Transaction template integration for the Spring framework. + * Transaction template provider that bridges Storm's transaction API into Spring's + * `PlatformTransactionManager`. * - * @since 1.5 + * The provider is constructed with the transaction managers of the owning application context; the matching + * `DataSourceTransactionManager` is resolved lazily, when the first data source touches the transaction. Templates + * that should share transactions must be configured with the *same provider instance*, so integrations expose one + * provider per application context; see [springOrmTemplate]. + * + * @param transactionManagers supplies the transaction managers of the owning application context; resolved lazily + * on first use. + * @since 1.13 */ -@BeforeAny -class SpringTransactionTemplateProviderImpl : TransactionTemplateProvider { - private val contextHolder = ThreadLocal() +class SpringTransactionTemplateProvider( + private val transactionManagers: () -> List, +) : TransactionTemplateProvider { + + /** + * Creates a provider for an eagerly resolved list of transaction managers. + */ + constructor(transactionManagers: List) : this({ transactionManagers }) - override fun isEnabled(): Boolean = SpringTransactionConfiguration.transactionManagers.isNotEmpty() + private val contextHolder = ThreadLocal() /** * Obtains a new transaction template instance. @@ -74,25 +88,34 @@ class SpringTransactionTemplateProviderImpl : TransactionTemplateProvider { return this } - override fun newContext(suspendMode: Boolean): TransactionContext { + override fun open(existing: TransactionContext?, suspendMode: Boolean): TransactionHandle { if (suspendMode) { throw PersistenceException( - "Suspend mode is not supported when spring-managed transactions are " + - "enabled for storm. Remove `@EnableTransactionIntegration` to disable spring-" + - "managed transactions for storm.", + "Suspend mode is not supported with Spring-managed transactions. Use " + + "transactionBlocking { } instead, or configure the template without the Spring " + + "transaction template provider.", ) } - return SpringTransactionContext() - } + val context = when (existing) { + null -> SpringTransactionContext(transactionManagers) + is SpringTransactionContext -> existing + else -> throw PersistenceException("Transaction context must be of type SpringTransactionContext.") + } + context.begin(definition) + return object : TransactionHandle { + override fun context(): TransactionContext = context - override fun contextHolder(): ThreadLocal = contextHolder + override fun status(): TransactionStatus = object : TransactionStatus { + override fun setRollbackOnly() = context.setRollbackOnly() - override fun execute(callback: TransactionCallback, context: TransactionContext): T { - if (context !is SpringTransactionContext) { - throw PersistenceException("Transaction context must be of type SpringTransactionContext.") + override fun isRollbackOnly(): Boolean = context.isRollbackOnly + } + + override fun complete(rollback: Boolean) = context.complete(rollback) } - return context.execute(definition, callback) } + + override fun contextHolder(): ThreadLocal = contextHolder } } } diff --git a/storm-kotlin-spring/src/main/kotlin/st/orm/spring/impl/SpringTransactionContext.kt b/storm-kotlin-spring/src/main/kotlin/st/orm/spring/impl/SpringTransactionContext.kt index de16ced37..cc49d2a1e 100644 --- a/storm-kotlin-spring/src/main/kotlin/st/orm/spring/impl/SpringTransactionContext.kt +++ b/storm-kotlin-spring/src/main/kotlin/st/orm/spring/impl/SpringTransactionContext.kt @@ -26,14 +26,11 @@ import st.orm.PersistenceException import st.orm.core.spi.CacheRetention import st.orm.core.spi.EntityCache import st.orm.core.spi.EntityCacheImpl -import st.orm.core.spi.TransactionCallback import st.orm.core.spi.TransactionContext -import st.orm.spring.SpringTransactionConfiguration import st.orm.template.TransactionTimedOutException import st.orm.template.UnexpectedRollbackException import java.sql.Connection.* import java.sql.PreparedStatement -import java.sql.SQLTimeoutException import java.util.Optional import javax.sql.DataSource import kotlin.math.min @@ -52,22 +49,20 @@ import kotlin.reflect.KClass * - Integration with Spring's PlatformTransactionManager * - Transaction state management including rollback flags * - * Example usage: - * ``` - * val context = SpringTransactionContext() - * context.execute(definition) { status -> - * // Execute transactional operations - * } - * ``` + * Transaction frames are opened with [begin] and finished with [complete]; the physical Spring transaction starts + * lazily, when the first data source touches this context via [useDataSource]. * * @property stack Maintains the stack of transaction states for nested transaction support * @property currentState Provides access to the current transaction state or throws if no transaction is active * + * @param transactionManagers supplies the transaction managers of the owning application context. * @see TransactionContext * @see PlatformTransactionManager * @since 1.5 */ -internal class SpringTransactionContext : TransactionContext { +internal class SpringTransactionContext( + private val transactionManagers: () -> List, +) : TransactionContext { companion object { val logger = LoggerFactory.getLogger("st.orm.transaction") } @@ -261,10 +256,13 @@ internal class SpringTransactionContext : TransactionContext { } } - fun execute( - definition: TransactionDefinition, - callback: TransactionCallback, - ): T { + /** + * Begins a transaction frame with the specified definition. + * + * The physical Spring transaction starts lazily, when the first data source touches this context via + * [useDataSource]. The frame is finished with [complete]. + */ + fun begin(definition: TransactionDefinition) { val state = TransactionState( transactionDefinition = definition, timeoutSeconds = definition.timeout.takeIf { it > 0 }, @@ -303,24 +301,21 @@ internal class SpringTransactionContext : TransactionContext { ) } stack.add(state) - val result = try { - callback.doInTransaction(object : st.orm.core.spi.TransactionStatus { - override fun isRollbackOnly(): Boolean = this@SpringTransactionContext.isRollbackOnly - override fun setRollbackOnly() { - this@SpringTransactionContext.setRollbackOnly() - } - }) - } catch (e: Throwable) { - // Let Spring roll back if a transaction was actually started for this frame. + } + + /** + * Completes the current transaction frame. + * + * When [rollback] is `true`, the frame rolls back; Spring rolls back the physical transaction if one was + * actually started for this frame. Otherwise the frame commits; the commit path detects timeouts and + * rollback-only marks and rolls back instead, throwing accordingly. + */ + fun complete(rollback: Boolean) { + if (rollback) { rollback() - when (e.cause) { - is SQLTimeoutException -> - throw TransactionTimedOutException(e.message ?: "Did not complete within timeout.", e) - else -> throw e - } + } else { + commit() } - commit() - return result as T } /** @@ -339,10 +334,10 @@ internal class SpringTransactionContext : TransactionContext { ensureStartedInRange(startIndex, index, dataSource) } - private val isRollbackOnly: Boolean + internal val isRollbackOnly: Boolean get() = (currentState.rollbackOnly || (currentState.transactionStatus?.isRollbackOnly ?: false)) - private fun setRollbackOnly() { + internal fun setRollbackOnly() { val index = stack.lastIndex if (index < 0) throw IllegalStateException("No transaction active.") val startIndex = ownerIndexFor(index) @@ -355,7 +350,7 @@ internal class SpringTransactionContext : TransactionContext { currentState.transactionStatus?.setRollbackOnly() } - private fun resolveTransactionManager(dataSource: DataSource): PlatformTransactionManager = SpringTransactionConfiguration.transactionManagers + private fun resolveTransactionManager(dataSource: DataSource): PlatformTransactionManager = transactionManagers() .mapNotNull { it as? DataSourceTransactionManager } .firstOrNull { it.dataSource === dataSource } ?: throw IllegalStateException("No TransactionManager found for DataSource $dataSource.") diff --git a/storm-kotlin-spring/src/main/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-kotlin-spring/src/main/resources/META-INF/services/st.orm.core.spi.ConnectionProvider deleted file mode 100644 index 1b9648e76..000000000 --- a/storm-kotlin-spring/src/main/resources/META-INF/services/st.orm.core.spi.ConnectionProvider +++ /dev/null @@ -1 +0,0 @@ -st.orm.spring.impl.SpringConnectionProviderImpl diff --git a/storm-kotlin-spring/src/main/resources/META-INF/services/st.orm.core.spi.TransactionTemplateProvider b/storm-kotlin-spring/src/main/resources/META-INF/services/st.orm.core.spi.TransactionTemplateProvider deleted file mode 100644 index 9143c547c..000000000 --- a/storm-kotlin-spring/src/main/resources/META-INF/services/st.orm.core.spi.TransactionTemplateProvider +++ /dev/null @@ -1 +0,0 @@ -st.orm.spring.impl.SpringTransactionTemplateProviderImpl diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/AdditionalTest.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/AdditionalTest.kt index 7cdf60cfc..04aabe062 100644 --- a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/AdditionalTest.kt +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/AdditionalTest.kt @@ -46,8 +46,7 @@ import st.orm.template.transactionBlocking * - RepositoryBeanFactoryPostProcessor: getOrmTemplateBeanName, getRepositoryBasePackages * - RepositoryAopAutoConfiguration: constructor, repositoryProxyingPostProcessor */ -@ContextConfiguration(classes = [IntegrationConfig::class]) -@EnableTransactionIntegration +@ContextConfiguration(classes = [SpringIntegrationConfig::class]) @SpringBootTest @Sql("/data.sql") open class AdditionalTest( diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringIntegrationConfig.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringIntegrationConfig.kt new file mode 100644 index 000000000..51095fe6c --- /dev/null +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringIntegrationConfig.kt @@ -0,0 +1,19 @@ +package st.orm.spring + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import st.orm.template.ORMTemplate +import javax.sql.DataSource + +/** + * Test configuration whose [ORMTemplate] is wired to Spring's transaction management via [springOrmTemplate]. + * + * Used by tests that exercise Spring-managed transactions; tests that exercise Storm's plain JDBC transactions use + * [IntegrationConfig], whose template falls back to the platform-neutral providers. + */ +@Configuration +open class SpringIntegrationConfig : IntegrationConfig() { + + @Bean + override fun ormTemplate(dataSource: DataSource): ORMTemplate = springOrmTemplate(dataSource) { listOf(transactionManager(dataSource)) } +} diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringManagedTransactionTest.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringManagedTransactionTest.kt index db474118c..7015e028e 100644 --- a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringManagedTransactionTest.kt +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringManagedTransactionTest.kt @@ -19,8 +19,7 @@ import st.orm.template.* import st.orm.template.TransactionIsolation.* import st.orm.template.TransactionPropagation.* -@ContextConfiguration(classes = [IntegrationConfig::class]) -@EnableTransactionIntegration +@ContextConfiguration(classes = [SpringIntegrationConfig::class]) @SpringBootTest @Sql("/data.sql") open class SpringManagedTransactionTest( diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringTransactionContextTest.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringTransactionContextTest.kt index 8cb2decc5..833a12f05 100644 --- a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringTransactionContextTest.kt +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/SpringTransactionContextTest.kt @@ -25,8 +25,7 @@ import st.orm.template.TransactionPropagation.* * isRepeatableRead, getDecorator timeout behavior, nested cache sharing/isolation, * and timeout edge cases. */ -@ContextConfiguration(classes = [IntegrationConfig::class]) -@EnableTransactionIntegration +@ContextConfiguration(classes = [SpringIntegrationConfig::class]) @SpringBootTest @Sql("/data.sql") open class SpringTransactionContextTest( diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/TransactionPropagationTest.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/TransactionPropagationTest.kt index a7d5a9701..f967de574 100644 --- a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/TransactionPropagationTest.kt +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/TransactionPropagationTest.kt @@ -22,11 +22,10 @@ import st.orm.template.TransactionPropagation.* * Tests for the SUPPORTS, MANDATORY, NOT_SUPPORTED, and NEVER transaction propagation modes. * * These tests cover both programmatic transactions (without Spring-managed transaction integration) - * and Spring-managed transactions (with [EnableTransactionIntegration]), verifying that each + * and Spring-managed transactions (with [SpringIntegrationConfig]), verifying that each * propagation mode behaves correctly in single-layer and two-layer (nested) scenarios. */ -@ContextConfiguration(classes = [IntegrationConfig::class]) -@EnableTransactionIntegration +@ContextConfiguration(classes = [SpringIntegrationConfig::class]) @SpringBootTest @Sql("/data.sql") open class TransactionPropagationTest( diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/TransactionTest.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/TransactionTest.kt index dcb005318..b9dbd600f 100644 --- a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/TransactionTest.kt +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/TransactionTest.kt @@ -439,11 +439,12 @@ open class TransactionTest( @Transactional @Test - open fun `programmatic transactions not allowed with spring managed transactions`(): Unit = runBlocking { - assertThrows { - transactionBlocking { - orm.removeAll() - } + open fun `programmatic transactions are independent of spring managed transactions on a plain template`(): Unit = runBlocking { + // This template is configured with the platform-neutral JDBC providers, so a programmatic transaction runs + // independently of the surrounding Spring-managed transaction: it executes on its own connection instead of + // failing fast, as templates never silently enlist in Spring transactions. + transactionBlocking { + orm.countAll() } } diff --git a/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt b/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt index a1ece7cd2..abc1b1050 100644 --- a/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt +++ b/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt @@ -19,6 +19,8 @@ import io.ktor.server.application.ApplicationStopped import io.ktor.server.application.createApplicationPlugin import io.ktor.server.application.log import st.orm.core.template.impl.SchemaValidator +import st.orm.template.impl.CoroutineAwareConnectionProviderImpl +import st.orm.template.impl.TransactionTemplateProviderImpl /** * Ktor plugin that configures Storm ORM for the application. @@ -67,7 +69,16 @@ val Storm = createApplicationPlugin(name = "Storm", createConfiguration = ::Stor // validated, so validation always sees the migrated schema. pluginConfig.migration?.invoke(dataSource) - var ormTemplate = st.orm.template.ORMTemplate.of(dataSource, stormConfig) + // Compose the template with explicit, plugin-scoped integration strategies: one provider instance per + // install, so all repositories of this application share transactions, and the ambient transaction { } + // API binds to this template's provider when it executes inside a transaction block. + val builder = st.orm.template.ORMTemplate.builder(dataSource) + .config(stormConfig) + .connectionProvider(pluginConfig.connectionProvider ?: CoroutineAwareConnectionProviderImpl()) + .transactionTemplateProvider(pluginConfig.transactionTemplateProvider ?: TransactionTemplateProviderImpl()) + pluginConfig.exceptionMapper?.let { builder.exceptionMapper(it) } + pluginConfig.queryObserver?.let { builder.queryObserver(it) } + var ormTemplate = builder.build() if (pluginConfig.entityCallbacks.isNotEmpty()) { ormTemplate = ormTemplate.withEntityCallbacks(pluginConfig.entityCallbacks) } diff --git a/storm-ktor/src/main/kotlin/st/orm/ktor/StormConfiguration.kt b/storm-ktor/src/main/kotlin/st/orm/ktor/StormConfiguration.kt index 86e073b24..76bc6c5ae 100644 --- a/storm-ktor/src/main/kotlin/st/orm/ktor/StormConfiguration.kt +++ b/storm-ktor/src/main/kotlin/st/orm/ktor/StormConfiguration.kt @@ -45,6 +45,39 @@ class StormConfiguration { */ var config: StormConfig? = null + /** + * Optional [st.orm.core.spi.ConnectionProvider] override. When not set, the plugin uses the coroutine-aware + * provider that binds connections to Storm's programmatic transactions. + * + * @since 1.13 + */ + var connectionProvider: st.orm.core.spi.ConnectionProvider? = null + + /** + * Optional [st.orm.core.spi.TransactionTemplateProvider] override. When not set, the plugin uses the JDBC + * transaction provider that backs Storm's `transaction { }` API. Templates that should share transactions must + * use the same provider instance. + * + * @since 1.13 + */ + var transactionTemplateProvider: st.orm.core.spi.TransactionTemplateProvider? = null + + /** + * Optional [st.orm.core.spi.ExceptionMapper] that maps failures raised during query execution to the runtime + * exception thrown to the caller. + * + * @since 1.13 + */ + var exceptionMapper: st.orm.core.spi.ExceptionMapper? = null + + /** + * Optional [st.orm.core.spi.QueryObserver] that is notified of query executions, for metrics and tracing + * bindings. + * + * @since 1.13 + */ + var queryObserver: st.orm.core.spi.QueryObserver? = null + /** * Schema validation mode: `"none"`, `"warn"`, or `"fail"`. * diff --git a/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.java b/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.java index 01cc5f06f..d5fc64b20 100644 --- a/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.java +++ b/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.java @@ -19,6 +19,7 @@ import java.util.List; import java.util.Map; import javax.sql.DataSource; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -28,7 +29,12 @@ import org.springframework.context.annotation.Bean; import st.orm.EntityCallback; import st.orm.StormConfig; +import st.orm.core.spi.ConnectionProvider; +import st.orm.core.spi.ExceptionMapper; +import st.orm.core.spi.QueryObserver; +import st.orm.core.spi.TransactionTemplateProvider; import st.orm.core.template.impl.SchemaValidator; +import st.orm.spring.SpringConnectionProvider; import st.orm.template.ORMTemplate; /** @@ -63,9 +69,30 @@ public class StormAutoConfiguration { @Bean @ConditionalOnMissingBean(ORMTemplate.class) public ORMTemplate ormTemplate(DataSource dataSource, StormProperties properties, - List> entityCallbacks) { - return ORMTemplate.of(dataSource, toStormConfig(properties)) - .withEntityCallbacks(entityCallbacks); + List> entityCallbacks, + ObjectProvider connectionProvider, + ObjectProvider transactionTemplateProvider, + ObjectProvider exceptionMapper, + ObjectProvider queryObserver) { + var builder = ORMTemplate.builder(dataSource).config(toStormConfig(properties)); + connectionProvider.ifAvailable(builder::connectionProvider); + transactionTemplateProvider.ifAvailable(builder::transactionTemplateProvider); + exceptionMapper.ifAvailable(builder::exceptionMapper); + queryObserver.ifAvailable(builder::queryObserver); + return builder.build().withEntityCallbacks(entityCallbacks); + } + + /** + * Provides the connection provider that binds connections to Spring's transaction management. + * + *

Connections are acquired through Spring's {@code DataSourceUtils}, so statements executed by the template + * participate in Spring-managed ({@code @Transactional}) transactions and degrade gracefully to plain + * connections when no transaction is active. Define your own {@link ConnectionProvider} bean to override.

+ */ + @Bean + @ConditionalOnMissingBean(ConnectionProvider.class) + public ConnectionProvider stormConnectionProvider() { + return new SpringConnectionProvider(); } /** diff --git a/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormTransactionAutoConfiguration.java b/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormTransactionAutoConfiguration.java new file mode 100644 index 000000000..d6fcde741 --- /dev/null +++ b/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormTransactionAutoConfiguration.java @@ -0,0 +1,58 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.spring.boot.autoconfigure; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.transaction.PlatformTransactionManager; +import st.orm.core.spi.TransactionTemplateProvider; +import st.orm.spring.SpringTransactionTemplateProvider; + +/** + * Auto-configuration that provides a Spring-aware {@link TransactionTemplateProvider} bean when a + * {@link PlatformTransactionManager} is available. + * + *

The provider exposes a transaction context bound to Spring's {@code TransactionSynchronizationManager}, giving + * templates transaction-scoped entity caching for the duration of Spring-managed transactions. It is handed to the + * {@code ORMTemplate} created by {@link StormAutoConfiguration}; nothing is registered globally. Define your own + * {@link TransactionTemplateProvider} bean to override.

+ * + *

The ordering hints reference DataSourceTransactionManagerAutoConfiguration by name rather than by class + * literal: the class moved to the modular spring-boot-jdbc jar in Spring Boot 4, and a class literal to whichever + * location is absent would fail annotation introspection. Name-based hints are ignored when the class is not on the + * classpath, so both locations can be listed safely.

+ */ +@AutoConfiguration( + afterName = { + // Spring Boot 3.x location. + "org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration", + // Spring Boot 4.x location. + "org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration", + }) +@ConditionalOnBean(PlatformTransactionManager.class) +public class StormTransactionAutoConfiguration { + + /** + * Provides the transaction template provider that exposes Spring-managed transactions to Storm templates. + */ + @Bean + @ConditionalOnMissingBean(TransactionTemplateProvider.class) + public TransactionTemplateProvider stormTransactionTemplateProvider() { + return new SpringTransactionTemplateProvider(); + } +} diff --git a/storm-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/storm-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 9628f58a2..e5b8508e7 100644 --- a/storm-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/storm-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1,3 @@ st.orm.spring.boot.autoconfigure.StormAutoConfiguration st.orm.spring.boot.autoconfigure.StormRepositoryAutoConfiguration +st.orm.spring.boot.autoconfigure.StormTransactionAutoConfiguration diff --git a/storm-spring/src/main/java/module-info.java b/storm-spring/src/main/java/module-info.java index 710fbfca8..e8e73c135 100644 --- a/storm-spring/src/main/java/module-info.java +++ b/storm-spring/src/main/java/module-info.java @@ -17,5 +17,4 @@ requires java.sql; exports st.orm.spring; exports st.orm.spring.impl; - provides st.orm.core.spi.ConnectionProvider with st.orm.spring.impl.TransactionAwareConnectionProviderImpl; } diff --git a/storm-spring/src/main/java/st/orm/spring/impl/TransactionAwareConnectionProviderImpl.java b/storm-spring/src/main/java/st/orm/spring/SpringConnectionProvider.java similarity index 64% rename from storm-spring/src/main/java/st/orm/spring/impl/TransactionAwareConnectionProviderImpl.java rename to storm-spring/src/main/java/st/orm/spring/SpringConnectionProvider.java index 06e3a2e28..998f127e8 100644 --- a/storm-spring/src/main/java/st/orm/spring/impl/TransactionAwareConnectionProviderImpl.java +++ b/storm-spring/src/main/java/st/orm/spring/SpringConnectionProvider.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package st.orm.spring.impl; +package st.orm.spring; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; @@ -25,7 +25,24 @@ import st.orm.core.spi.ConnectionProvider; import st.orm.core.spi.TransactionContext; -public class TransactionAwareConnectionProviderImpl implements ConnectionProvider { +/** + * Connection provider that binds connections to Spring's transaction management. + * + *

Connections are acquired through {@link DataSourceUtils}, so statements executed by the template participate in + * Spring-managed ({@code @Transactional}) transactions via thread-bound connections, and degrade gracefully to plain + * connections when no transaction is active. Storm's own transaction API is not bridged by this provider.

+ * + *

Configure this provider on the template that belongs to the owning application context: + *

{@code
+ * ORMTemplate orm = ORMTemplate.builder(dataSource)
+ *         .connectionProvider(new SpringConnectionProvider())
+ *         .transactionTemplateProvider(new SpringTransactionTemplateProvider())
+ *         .build();
+ * }
+ * + * @since 1.13 + */ +public class SpringConnectionProvider implements ConnectionProvider { @Override public Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { diff --git a/storm-spring/src/main/java/st/orm/spring/SpringTransactionTemplateProvider.java b/storm-spring/src/main/java/st/orm/spring/SpringTransactionTemplateProvider.java new file mode 100644 index 000000000..55f1ca509 --- /dev/null +++ b/storm-spring/src/main/java/st/orm/spring/SpringTransactionTemplateProvider.java @@ -0,0 +1,173 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.spring; + +import static java.util.Optional.empty; +import static java.util.Optional.of; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.sql.Connection; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import st.orm.Entity; +import st.orm.core.spi.CacheRetention; +import st.orm.core.spi.EntityCache; +import st.orm.core.spi.EntityCacheImpl; +import st.orm.core.spi.TransactionContext; +import st.orm.core.spi.TransactionTemplate; +import st.orm.core.spi.TransactionTemplateProvider; + +/** + * Transaction template provider that exposes a transaction context bound to Spring's + * {@code TransactionSynchronizationManager}. + * + *

When a Spring-managed transaction is active, templates configured with this provider observe a transaction + * context scoped to that transaction, enabling transaction-scoped entity caching and dirty tracking. The context is + * bound once per physical Spring transaction and unbound on completion.

+ * + *

Storm's own transaction API is not bridged by this provider: transactions are managed by Spring, typically via + * {@code @Transactional} or Spring's {@code TransactionTemplate}.

+ * + * @see SpringConnectionProvider + * @since 1.13 + */ +public class SpringTransactionTemplateProvider implements TransactionTemplateProvider { + + private static final Object CONTEXT_RESOURCE_KEY = + SpringTransactionTemplateProvider.class.getName() + ".TX_CONTEXT"; + + private static final ThreadLocal CONTEXT_HOLDER = new ThreadLocal<>(); + + @Override + public TransactionTemplate getTransactionTemplate() { + return new TransactionTemplate() { + @Override + public TransactionTemplate propagation(String propagation) { + throw new UnsupportedOperationException( + "Storm transactions are not supported with Spring-managed transactions; use Spring's transaction management."); + } + + @Override + public TransactionTemplate isolation(int isolation) { + throw new UnsupportedOperationException( + "Storm transactions are not supported with Spring-managed transactions; use Spring's transaction management."); + } + + @Override + public TransactionTemplate readOnly(boolean readOnly) { + throw new UnsupportedOperationException( + "Storm transactions are not supported with Spring-managed transactions; use Spring's transaction management."); + } + + @Override + public TransactionTemplate timeout(int timeoutSeconds) { + throw new UnsupportedOperationException( + "Storm transactions are not supported with Spring-managed transactions; use Spring's transaction management."); + } + + @Override + public TransactionHandle open(@Nullable TransactionContext existing, boolean suspendMode) { + throw new UnsupportedOperationException( + "Storm transactions are not supported with Spring-managed transactions; use Spring's transaction management."); + } + + @Override + public Optional currentContext() { + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + return empty(); + } + Object existing = TransactionSynchronizationManager.getResource(CONTEXT_RESOURCE_KEY); + if (existing instanceof TransactionContext context) { + return of(context); + } + var created = new SpringLinkedTransactionContext(); + TransactionSynchronizationManager.bindResource(CONTEXT_RESOURCE_KEY, created); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + TransactionSynchronizationManager.unbindResourceIfPossible(CONTEXT_RESOURCE_KEY); + } + }); + return of(created); + } + + @Override + public ThreadLocal contextHolder() { + return CONTEXT_HOLDER; + } + }; + } + + /** + * Transaction context bound to Spring's {@code TransactionSynchronizationManager} resources. + */ + private static final class SpringLinkedTransactionContext implements TransactionContext { + private final Map>, EntityCache, ?>> caches = new HashMap<>(); + private final Decorator noopDecorator = resource -> resource; + + @Override + public boolean isRepeatableRead() { + // Spring returns null when no explicit isolation level is set (database default). Most databases default + // to READ_COMMITTED, so return false to ensure fresh data is fetched on each read; users who want cached + // instances should explicitly set REPEATABLE_READ or higher. + Integer isolationLevel = TransactionSynchronizationManager.getCurrentTransactionIsolationLevel(); + if (isolationLevel == null || isolationLevel < 0) { + return false; + } + return isolationLevel >= Connection.TRANSACTION_REPEATABLE_READ; + } + + @Override + public EntityCache, ?> entityCache(@Nonnull Class> entityType, + @Nonnull CacheRetention retention) { + // The context is bound once per physical Spring transaction, which gives correct cache scoping for + // REQUIRED and REQUIRES_NEW. NESTED savepoint rollbacks are not observable through Spring's hooks, so no + // cache splitting is attempted for savepoints. + return caches.computeIfAbsent(entityType, ignore -> new EntityCacheImpl<>(retention)); + } + + @Override + public EntityCache, ?> getEntityCache(@Nonnull Class> entityType) { + var cache = caches.get(entityType); + if (cache == null) { + throw new IllegalStateException("No entity cache exists for " + entityType.getName() + "."); + } + return cache; + } + + @Override + public EntityCache, ?> findEntityCache(@Nonnull Class> entityType) { + return caches.get(entityType); + } + + @Override + public void clearAllEntityCaches() { + for (EntityCache, ?> cache : caches.values()) { + cache.clear(); + } + } + + @Override + @SuppressWarnings("unchecked") + public Decorator getDecorator(@Nonnull Class resourceType) { + return (Decorator) noopDecorator; + } + } +} diff --git a/storm-spring/src/main/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-spring/src/main/resources/META-INF/services/st.orm.core.spi.ConnectionProvider deleted file mode 100644 index 850e48c88..000000000 --- a/storm-spring/src/main/resources/META-INF/services/st.orm.core.spi.ConnectionProvider +++ /dev/null @@ -1 +0,0 @@ -st.orm.spring.impl.TransactionAwareConnectionProviderImpl diff --git a/storm-spring/src/test/java/st/orm/spring/ImplTest.java b/storm-spring/src/test/java/st/orm/spring/ImplTest.java index 562109ae5..e8fb4bc5c 100644 --- a/storm-spring/src/test/java/st/orm/spring/ImplTest.java +++ b/storm-spring/src/test/java/st/orm/spring/ImplTest.java @@ -19,7 +19,6 @@ import st.orm.spring.impl.RepositoryAopAutoConfiguration; import st.orm.spring.impl.RepositoryProxyingPostProcessor; import st.orm.spring.impl.ResolverRegistration; -import st.orm.spring.impl.TransactionAwareConnectionProviderImpl; /** * Unit tests for coverage of implementation classes in st.orm.spring.impl. @@ -77,13 +76,13 @@ public void repositoryProxyingPostProcessorShouldNotWrapNonRepositoryBeans() { assertSame(bean, result); } - // TransactionAwareConnectionProviderImpl error path (2 lines) + // SpringConnectionProvider error path (2 lines) @Test public void connectionProviderShouldWrapSqlExceptionInPersistenceException() { - // When the underlying DataSource throws a SQLException, the TransactionAwareConnectionProviderImpl + // When the underlying DataSource throws a SQLException, the SpringConnectionProvider // should wrap it in a PersistenceException to maintain the framework's exception hierarchy. - var provider = new TransactionAwareConnectionProviderImpl(); + var provider = new SpringConnectionProvider(); var dataSource = mock(DataSource.class); try { when(dataSource.getConnection()).thenThrow(new SQLException("Connection failed")); From 31d5c0b3ba8776b843f744d158b3b5314337167f Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 9 Jul 2026 20:21:51 +0200 Subject: [PATCH 3/4] test: prove dual-context isolation and adapt suites to explicit integration Add DualContextIsolationTest: two application contexts in one JVM bind transactions to their own managers, survive each other's shutdown, fail fast when templates with different transaction providers share one block, and standalone templates no longer silently enlist in Spring-managed transactions. Add IntegrationStrategiesTest covering builder precedence over ServiceLoader discovery, ordered fallback resolution, the ExceptionMapper contract (context propagation, mapper failures never mask the original error), and the QueryObserver lifecycle (stream-close, error path, observer containment). The test suites of storm-core, the JSON modules and the dialect modules relied on the removed reflective Spring probe for their transaction-per-test isolation under @DataJpaTest. They now register an explicit test-scoped Spring-aware ConnectionProvider via META-INF/services and run surefire on the classpath so ServiceLoader picks it up. --- .../core/spi/IntegrationStrategiesTest.java | 263 ++++++++++++++++++ storm-h2/pom.xml | 7 +- .../TestSpringConnectionProvider.java | 48 ++++ .../st.orm.core.spi.ConnectionProvider | 1 + storm-jackson2/pom.xml | 9 +- .../TestSpringConnectionProvider.java | 48 ++++ .../st.orm.core.spi.ConnectionProvider | 1 + storm-jackson3/pom.xml | 8 +- .../TestSpringConnectionProvider.java | 48 ++++ .../st.orm.core.spi.ConnectionProvider | 1 + .../kotlin/st/orm/spring/AdditionalTest.kt | 2 +- .../st/orm/spring/DualContextIsolationTest.kt | 142 ++++++++++ storm-kotlinx-serialization/pom.xml | 6 +- .../TestSpringConnectionProvider.kt | 44 +++ .../st.orm.core.spi.ConnectionProvider | 1 + storm-mariadb/pom.xml | 7 +- .../TestSpringConnectionProvider.java | 48 ++++ .../st.orm.core.spi.ConnectionProvider | 1 + storm-mssqlserver/pom.xml | 7 +- .../TestSpringConnectionProvider.java | 48 ++++ .../st.orm.core.spi.ConnectionProvider | 1 + storm-mysql/pom.xml | 7 +- .../TestSpringConnectionProvider.java | 48 ++++ .../st.orm.core.spi.ConnectionProvider | 1 + storm-oracle/pom.xml | 7 +- .../TestSpringConnectionProvider.java | 48 ++++ .../st.orm.core.spi.ConnectionProvider | 1 + storm-postgresql/pom.xml | 7 +- .../TestSpringConnectionProvider.java | 48 ++++ .../st.orm.core.spi.ConnectionProvider | 1 + storm-sqlite/pom.xml | 7 +- .../TestSpringConnectionProvider.java | 48 ++++ .../st.orm.core.spi.ConnectionProvider | 1 + 33 files changed, 940 insertions(+), 25 deletions(-) create mode 100644 storm-core/src/test/java/st/orm/core/spi/IntegrationStrategiesTest.java create mode 100644 storm-h2/src/test/java/st/orm/spi/h2/testsupport/TestSpringConnectionProvider.java create mode 100644 storm-h2/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider create mode 100644 storm-jackson2/src/test/java/st/orm/jackson/testsupport/TestSpringConnectionProvider.java create mode 100644 storm-jackson2/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider create mode 100644 storm-jackson3/src/test/java/st/orm/jackson/testsupport/TestSpringConnectionProvider.java create mode 100644 storm-jackson3/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider create mode 100644 storm-kotlin-spring/src/test/kotlin/st/orm/spring/DualContextIsolationTest.kt create mode 100644 storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/testsupport/TestSpringConnectionProvider.kt create mode 100644 storm-kotlinx-serialization/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider create mode 100644 storm-mariadb/src/test/java/st/orm/spi/mariadb/testsupport/TestSpringConnectionProvider.java create mode 100644 storm-mariadb/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider create mode 100644 storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/testsupport/TestSpringConnectionProvider.java create mode 100644 storm-mssqlserver/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider create mode 100644 storm-mysql/src/test/java/st/orm/spi/mysql/testsupport/TestSpringConnectionProvider.java create mode 100644 storm-mysql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider create mode 100644 storm-oracle/src/test/java/st/orm/spi/oracle/testsupport/TestSpringConnectionProvider.java create mode 100644 storm-oracle/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider create mode 100644 storm-postgresql/src/test/java/st/orm/spi/postgresql/testsupport/TestSpringConnectionProvider.java create mode 100644 storm-postgresql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider create mode 100644 storm-sqlite/src/test/java/st/orm/spi/sqlite/testsupport/TestSpringConnectionProvider.java create mode 100644 storm-sqlite/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider diff --git a/storm-core/src/test/java/st/orm/core/spi/IntegrationStrategiesTest.java b/storm-core/src/test/java/st/orm/core/spi/IntegrationStrategiesTest.java new file mode 100644 index 000000000..2fbf00c2b --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/spi/IntegrationStrategiesTest.java @@ -0,0 +1,263 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.core.spi; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static st.orm.core.template.TemplateString.raw; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import javax.sql.DataSource; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import st.orm.PersistenceException; +import st.orm.core.spi.QueryContext.ExecutionKind; +import st.orm.core.spi.QueryObserver.Observation; +import st.orm.core.template.ORMTemplate; +import st.orm.core.template.SqlOperation; +import st.orm.core.testsupport.TestSpringConnectionProvider; + +/** + * Tests for the instance-scoped integration strategies configured through {@link ORMTemplate.Builder}: strategy + * precedence over {@code ServiceLoader} discovery, the {@link ExceptionMapper} contract, and the + * {@link QueryObserver} lifecycle. + */ +public class IntegrationStrategiesTest { + + private static DataSource dataSource; + + @BeforeAll + static void setUp() throws SQLException { + var h2DataSource = org.springframework.boot.jdbc.DataSourceBuilder.create() + .url("jdbc:h2:mem:integrationStrategies;DB_CLOSE_DELAY=-1") + .username("sa") + .password("") + .driverClassName("org.h2.Driver") + .build(); + try (var connection = h2DataSource.getConnection(); var statement = connection.createStatement()) { + statement.execute("CREATE TABLE IF NOT EXISTS strategy_city (id INTEGER AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255))"); + statement.execute("DELETE FROM strategy_city"); + statement.execute("INSERT INTO strategy_city (name) VALUES ('Amsterdam'), ('Utrecht')"); + } + dataSource = h2DataSource; + } + + static class CountingConnectionProvider implements ConnectionProvider { + final AtomicInteger acquired = new AtomicInteger(); + final AtomicInteger released = new AtomicInteger(); + + @Override + public Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { + acquired.incrementAndGet(); + try { + return dataSource.getConnection(); + } catch (SQLException e) { + throw new PersistenceException(e); + } + } + + @Override + public void releaseConnection(@Nonnull Connection connection, @Nonnull DataSource dataSource, + @Nullable TransactionContext context) { + released.incrementAndGet(); + try { + connection.close(); + } catch (SQLException e) { + throw new PersistenceException(e); + } + } + } + + record ObservedExecution(SqlOperation operation, ExecutionKind kind, @Nullable String statement, + List errors, AtomicInteger closed) { + } + + static class RecordingObserver implements QueryObserver { + final List executions = new ArrayList<>(); + + @Override + public Observation onExecute(@Nonnull QueryContext context) { + var execution = new ObservedExecution(context.operation(), context.kind(), + context.statement().orElse(null), new ArrayList<>(), new AtomicInteger()); + executions.add(execution); + return new Observation() { + @Override + public void error(@Nonnull Throwable throwable) { + execution.errors().add(throwable); + } + + @Override + public void close() { + execution.closed().incrementAndGet(); + } + }; + } + } + + static class MappedException extends RuntimeException { + MappedException(Throwable cause) { + super(cause); + } + } + + @Test + public void explicitConnectionProviderTakesPrecedenceOverServiceLoader() { + var connectionProvider = new CountingConnectionProvider(); + var orm = ORMTemplate.builder(dataSource) + .connectionProvider(connectionProvider) + .build(); + var rows = orm.query(raw("SELECT id, name FROM strategy_city")).getResultList(); + assertTrue(rows.size() >= 2); + assertTrue(connectionProvider.acquired.get() > 0); + assertEquals(connectionProvider.acquired.get(), connectionProvider.released.get()); + } + + @Test + public void serviceLoaderFallbackResolvesOrderedWinner() { + // The test classpath registers a @BeforeAny provider next to the @AfterAny core default; the fallback + // resolution must pick the explicitly ordered winner instead of relying on classpath order. + assertEquals(TestSpringConnectionProvider.class, Providers.getConnectionProvider().getClass()); + } + + @Test + public void connectionProviderRejectedForConnectionBackedTemplate() throws SQLException { + try (var connection = dataSource.getConnection()) { + var builder = ORMTemplate.builder(connection) + .connectionProvider(new CountingConnectionProvider()); + assertThrows(PersistenceException.class, builder::build); + } + } + + @Test + public void exceptionMapperReceivesContextAndMapsFailures() { + var contexts = new ArrayList(); + var orm = ORMTemplate.builder(dataSource) + .exceptionMapper((cause, context) -> { + contexts.add(context); + return new MappedException(cause); + }) + .build(); + assertThrows(MappedException.class, () -> orm.query(raw("SELECT * FROM does_not_exist")).getResultList()); + assertEquals(1, contexts.size()); + var context = contexts.getFirst(); + assertEquals(SqlOperation.SELECT, context.operation()); + assertTrue(context.statement().isPresent()); + assertTrue(context.statement().get().contains("does_not_exist")); + } + + @Test + public void exceptionMapperFailureNeverMasksOriginalError() { + var mapperFailure = new IllegalStateException("mapper blew up"); + var orm = ORMTemplate.builder(dataSource) + .exceptionMapper((cause, context) -> { + throw mapperFailure; + }) + .build(); + var thrown = assertThrows(PersistenceException.class, + () -> orm.query(raw("SELECT * FROM does_not_exist")).getResultList()); + assertTrue(Arrays.asList(thrown.getSuppressed()).contains(mapperFailure), + "The mapper failure must be suppressed onto the original error."); + } + + @Test + public void queryObserverObservesQueryUntilStreamCompletion() { + var observer = new RecordingObserver(); + var orm = ORMTemplate.builder(dataSource) + .queryObserver(observer) + .build(); + var rows = orm.query(raw("SELECT id, name FROM strategy_city")).getResultList(); + assertTrue(rows.size() >= 2); + assertEquals(1, observer.executions.size()); + var execution = observer.executions.getFirst(); + assertEquals(ExecutionKind.QUERY, execution.kind()); + assertEquals(SqlOperation.SELECT, execution.operation()); + assertNotNull(execution.statement()); + assertTrue(execution.errors().isEmpty()); + assertEquals(1, execution.closed().get(), "The observation must be closed exactly once."); + } + + @Test + public void queryObserverObservesUpdate() { + var observer = new RecordingObserver(); + var orm = ORMTemplate.builder(dataSource) + .queryObserver(observer) + .build(); + int affected = orm.query(raw("INSERT INTO strategy_city (name) VALUES ('Eindhoven')")).executeUpdate(); + assertEquals(1, affected); + assertEquals(1, observer.executions.size()); + var execution = observer.executions.getFirst(); + assertEquals(ExecutionKind.UPDATE, execution.kind()); + assertTrue(execution.errors().isEmpty()); + assertEquals(1, execution.closed().get()); + } + + @Test + public void queryObserverReceivesErrorAndCloseOnFailure() { + var observer = new RecordingObserver(); + var orm = ORMTemplate.builder(dataSource) + .queryObserver(observer) + .build(); + assertThrows(PersistenceException.class, () -> orm.query(raw("SELECT * FROM does_not_exist")).getResultList()); + assertEquals(1, observer.executions.size()); + var execution = observer.executions.getFirst(); + assertFalse(execution.errors().isEmpty(), "The observation must be signaled of the failure."); + assertEquals(1, execution.closed().get(), "The observation must be closed exactly once."); + } + + @Test + public void queryObserverFailuresNeverAffectQueryExecution() { + var orm = ORMTemplate.builder(dataSource) + .queryObserver(context -> { + throw new IllegalStateException("observer blew up"); + }) + .build(); + var rows = orm.query(raw("SELECT id, name FROM strategy_city")).getResultList(); + assertFalse(rows.isEmpty()); + } + + @Test + public void observationFailuresAreContained() { + var orm = ORMTemplate.builder(dataSource) + .queryObserver(context -> new Observation() { + @Override + public void error(@Nonnull Throwable throwable) { + throw new IllegalStateException("error signal blew up"); + } + + @Override + public void close() { + throw new IllegalStateException("close signal blew up"); + } + }) + .build(); + var rows = orm.query(raw("SELECT id, name FROM strategy_city")).getResultList(); + assertFalse(rows.isEmpty()); + assertInstanceOf(PersistenceException.class, + assertThrows(RuntimeException.class, + () -> orm.query(raw("SELECT * FROM does_not_exist")).getResultList())); + } +} diff --git a/storm-h2/pom.xml b/storm-h2/pom.xml index 8c0062f84..a4bf1c6fa 100644 --- a/storm-h2/pom.xml +++ b/storm-h2/pom.xml @@ -36,11 +36,14 @@ org.apache.maven.plugins maven-surefire-plugin + + false @{argLine} --add-opens java.base/java.lang=ALL-UNNAMED - --add-opens storm.h2/st.orm.spi.h2=ALL-UNNAMED - --add-opens storm.h2/st.orm.spi.h2=storm.core diff --git a/storm-h2/src/test/java/st/orm/spi/h2/testsupport/TestSpringConnectionProvider.java b/storm-h2/src/test/java/st/orm/spi/h2/testsupport/TestSpringConnectionProvider.java new file mode 100644 index 000000000..697c637ed --- /dev/null +++ b/storm-h2/src/test/java/st/orm/spi/h2/testsupport/TestSpringConnectionProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.spi.h2.testsupport; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.sql.Connection; +import javax.sql.DataSource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import st.orm.PersistenceException; +import st.orm.core.spi.ConnectionProvider; +import st.orm.core.spi.Orderable.BeforeAny; +import st.orm.core.spi.TransactionContext; + +/** + * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's + * transaction-per-test isolation applies to statements executed by Storm templates. + */ +@BeforeAny +public class TestSpringConnectionProvider implements ConnectionProvider { + + @Override + public Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { + try { + return DataSourceUtils.getConnection(dataSource); + } catch (Exception e) { + throw new PersistenceException("Failed to get connection from DataSource.", e); + } + } + + @Override + public void releaseConnection(@Nonnull Connection connection, @Nonnull DataSource dataSource, @Nullable TransactionContext context) { + DataSourceUtils.releaseConnection(connection, dataSource); + } +} diff --git a/storm-h2/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-h2/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider new file mode 100644 index 000000000..f0d44d863 --- /dev/null +++ b/storm-h2/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -0,0 +1 @@ +st.orm.spi.h2.testsupport.TestSpringConnectionProvider diff --git a/storm-jackson2/pom.xml b/storm-jackson2/pom.xml index 78fd620cd..e17e6ff9f 100644 --- a/storm-jackson2/pom.xml +++ b/storm-jackson2/pom.xml @@ -36,15 +36,14 @@ org.apache.maven.plugins maven-surefire-plugin + + false @{argLine} -Dstorm.validation.record_mode=none --add-opens java.base/java.lang=ALL-UNNAMED - --add-opens storm.jackson2/st.orm.jackson=storm.core - --add-opens storm.jackson2/st.orm.jackson.model=storm.core - --add-opens storm.jackson2/st.orm.jackson=com.fasterxml.jackson.databind - --add-opens storm.jackson2/st.orm.jackson.model=com.fasterxml.jackson.databind - --add-reads storm.jackson2=com.fasterxml.jackson.datatype.jsr310 diff --git a/storm-jackson2/src/test/java/st/orm/jackson/testsupport/TestSpringConnectionProvider.java b/storm-jackson2/src/test/java/st/orm/jackson/testsupport/TestSpringConnectionProvider.java new file mode 100644 index 000000000..825c3515b --- /dev/null +++ b/storm-jackson2/src/test/java/st/orm/jackson/testsupport/TestSpringConnectionProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.jackson.testsupport; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.sql.Connection; +import javax.sql.DataSource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import st.orm.PersistenceException; +import st.orm.core.spi.ConnectionProvider; +import st.orm.core.spi.Orderable.BeforeAny; +import st.orm.core.spi.TransactionContext; + +/** + * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's + * transaction-per-test isolation applies to statements executed by Storm templates. + */ +@BeforeAny +public class TestSpringConnectionProvider implements ConnectionProvider { + + @Override + public Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { + try { + return DataSourceUtils.getConnection(dataSource); + } catch (Exception e) { + throw new PersistenceException("Failed to get connection from DataSource.", e); + } + } + + @Override + public void releaseConnection(@Nonnull Connection connection, @Nonnull DataSource dataSource, @Nullable TransactionContext context) { + DataSourceUtils.releaseConnection(connection, dataSource); + } +} diff --git a/storm-jackson2/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-jackson2/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider new file mode 100644 index 000000000..22f4324fe --- /dev/null +++ b/storm-jackson2/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -0,0 +1 @@ +st.orm.jackson.testsupport.TestSpringConnectionProvider diff --git a/storm-jackson3/pom.xml b/storm-jackson3/pom.xml index c3883b7e8..477b69c35 100644 --- a/storm-jackson3/pom.xml +++ b/storm-jackson3/pom.xml @@ -36,14 +36,14 @@ org.apache.maven.plugins maven-surefire-plugin + + false @{argLine} -Dstorm.validation.record_mode=none --add-opens java.base/java.lang=ALL-UNNAMED - --add-opens storm.jackson3/st.orm.jackson=storm.core - --add-opens storm.jackson3/st.orm.jackson.model=storm.core - --add-opens storm.jackson3/st.orm.jackson=tools.jackson.databind - --add-opens storm.jackson3/st.orm.jackson.model=tools.jackson.databind diff --git a/storm-jackson3/src/test/java/st/orm/jackson/testsupport/TestSpringConnectionProvider.java b/storm-jackson3/src/test/java/st/orm/jackson/testsupport/TestSpringConnectionProvider.java new file mode 100644 index 000000000..825c3515b --- /dev/null +++ b/storm-jackson3/src/test/java/st/orm/jackson/testsupport/TestSpringConnectionProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.jackson.testsupport; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.sql.Connection; +import javax.sql.DataSource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import st.orm.PersistenceException; +import st.orm.core.spi.ConnectionProvider; +import st.orm.core.spi.Orderable.BeforeAny; +import st.orm.core.spi.TransactionContext; + +/** + * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's + * transaction-per-test isolation applies to statements executed by Storm templates. + */ +@BeforeAny +public class TestSpringConnectionProvider implements ConnectionProvider { + + @Override + public Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { + try { + return DataSourceUtils.getConnection(dataSource); + } catch (Exception e) { + throw new PersistenceException("Failed to get connection from DataSource.", e); + } + } + + @Override + public void releaseConnection(@Nonnull Connection connection, @Nonnull DataSource dataSource, @Nullable TransactionContext context) { + DataSourceUtils.releaseConnection(connection, dataSource); + } +} diff --git a/storm-jackson3/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-jackson3/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider new file mode 100644 index 000000000..22f4324fe --- /dev/null +++ b/storm-jackson3/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -0,0 +1 @@ +st.orm.jackson.testsupport.TestSpringConnectionProvider diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/AdditionalTest.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/AdditionalTest.kt index 04aabe062..8cb2ab6cd 100644 --- a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/AdditionalTest.kt +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/AdditionalTest.kt @@ -391,7 +391,7 @@ open class AdditionalTest( } } - // SpringConnectionProviderImpl: getConnection with transaction context + // SpringConnectionProvider: getConnection with transaction context @Test fun `connection within transaction should be managed by Spring`(): Unit = runBlocking { diff --git a/storm-kotlin-spring/src/test/kotlin/st/orm/spring/DualContextIsolationTest.kt b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/DualContextIsolationTest.kt new file mode 100644 index 000000000..005e36cae --- /dev/null +++ b/storm-kotlin-spring/src/test/kotlin/st/orm/spring/DualContextIsolationTest.kt @@ -0,0 +1,142 @@ +package st.orm.spring + +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.boot.jdbc.DataSourceBuilder +import org.springframework.context.annotation.AnnotationConfigApplicationContext +import org.springframework.core.io.ClassPathResource +import org.springframework.jdbc.datasource.DataSourceTransactionManager +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator +import st.orm.PersistenceException +import st.orm.repository.countAll +import st.orm.repository.removeAll +import st.orm.spring.model.Visit +import st.orm.template.ORMTemplate +import st.orm.template.transactionBlocking +import java.util.function.Supplier +import javax.sql.DataSource + +/** + * Proves that two application contexts in one JVM get fully isolated transaction integration. + * + * Each context carries its own database, its own [DataSourceTransactionManager] and its own template composed via + * [springOrmTemplate]. Before integration points were instance-scoped, the transaction manager list was held in + * JVM-global static state that the second context overwrote, so transactions could bind to the wrong manager and + * standalone templates were silently hijacked by whichever Spring context refreshed last. + */ +class DualContextIsolationTest { + + private val contexts = mutableListOf() + + @AfterEach + fun closeContexts() { + contexts.forEach { if (it.isActive) it.close() } + contexts.clear() + } + + private fun createDataSource(name: String): DataSource = DataSourceBuilder.create() + .url("jdbc:h2:mem:$name;DB_CLOSE_DELAY=-1") + .username("sa") + .password("") + .driverClassName("org.h2.Driver") + .build() + .also { ResourceDatabasePopulator(ClassPathResource("data.sql")).execute(it) } + + private fun createContext(name: String): AnnotationConfigApplicationContext { + val context = AnnotationConfigApplicationContext() + context.registerBean(DataSource::class.java, Supplier { createDataSource(name) }) + context.registerBean( + DataSourceTransactionManager::class.java, + Supplier { DataSourceTransactionManager(context.getBean(DataSource::class.java)) }, + ) + context.registerBean( + ORMTemplate::class.java, + Supplier { + springOrmTemplate(context.getBean(DataSource::class.java)) { + listOf(context.getBean(DataSourceTransactionManager::class.java)) + } + }, + ) + context.refresh() + contexts += context + return context + } + + @Test + fun `transactions of two application contexts bind to their own transaction managers`() { + val contextA = createContext("dualContextA") + val contextB = createContext("dualContextB") + val ormA = contextA.getBean(ORMTemplate::class.java) + val ormB = contextB.getBean(ORMTemplate::class.java) + val visitsInA = ormA.countAll() + (visitsInA > 0).shouldBeTrue() + // A rolled-back delete in context A must not touch context A's data, regardless of context B's presence. + transactionBlocking { + ormA.removeAll() + setRollbackOnly() + } + ormA.countAll() shouldBe visitsInA + // A committed delete in context B must not touch context A's data. + transactionBlocking { + ormB.removeAll() + } + ormB.countAll() shouldBe 0 + ormA.countAll() shouldBe visitsInA + } + + @Test + fun `closing one application context does not affect the other`() { + val contextA = createContext("dualCloseA") + val contextB = createContext("dualCloseB") + val ormB = contextB.getBean(ORMTemplate::class.java) + contextA.close() + transactionBlocking { + ormB.removeAll() + setRollbackOnly() + } + (ormB.countAll() > 0).shouldBeTrue() + } + + @Test + fun `templates with different transaction providers cannot share one transaction block`() { + val contextA = createContext("dualMixA") + val ormA = contextA.getBean(ORMTemplate::class.java) + val standalone = ORMTemplate.of(createDataSource("dualMixStandalone")) + val visitsInA = ormA.countAll() + // The block materializes with context A's Spring transaction provider; the standalone template uses the + // platform-neutral fallback provider, so a single transaction cannot span both. + assertThrows { + transactionBlocking { + ormA.removeAll() + standalone.countAll() + } + } + // The failed block rolled back context A's work. + ormA.countAll() shouldBe visitsInA + } + + @Test + fun `standalone template does not enlist in a spring managed transaction on the same data source`() { + val contextA = createContext("dualEnlistA") + val dataSourceA = contextA.getBean(DataSource::class.java) + val ormA = contextA.getBean(ORMTemplate::class.java) + val standalone = ORMTemplate.of(dataSourceA) + val visits = standalone.countAll() + (visits > 0).shouldBeTrue() + val springTransaction = org.springframework.transaction.support.TransactionTemplate( + contextA.getBean(DataSourceTransactionManager::class.java), + ) + springTransaction.execute { status -> + // The context-owned template joins the Spring-managed transaction through its connection provider. + ormA.removeAll() + // The standalone template runs on its own connection: the uncommitted delete is not visible, proving it + // did not silently enlist in the Spring transaction. + standalone.countAll() shouldBe visits + status.setRollbackOnly() + } + standalone.countAll() shouldBe visits + } +} diff --git a/storm-kotlinx-serialization/pom.xml b/storm-kotlinx-serialization/pom.xml index ab40d7661..7beb20f9d 100644 --- a/storm-kotlinx-serialization/pom.xml +++ b/storm-kotlinx-serialization/pom.xml @@ -38,7 +38,11 @@ org.apache.maven.plugins maven-surefire-plugin - true + + false @{argLine} -Dstorm.validation.record_mode=fail diff --git a/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/testsupport/TestSpringConnectionProvider.kt b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/testsupport/TestSpringConnectionProvider.kt new file mode 100644 index 000000000..cfff6499e --- /dev/null +++ b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/testsupport/TestSpringConnectionProvider.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.serialization.testsupport + +import org.springframework.jdbc.datasource.DataSourceUtils +import st.orm.PersistenceException +import st.orm.core.spi.ConnectionProvider +import st.orm.core.spi.Orderable.BeforeAny +import st.orm.core.spi.TransactionContext +import java.sql.Connection +import javax.sql.DataSource + +/** + * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's + * transaction-per-test isolation applies to statements executed by Storm templates. + */ +@BeforeAny +class TestSpringConnectionProvider : ConnectionProvider { + + override fun getConnection(dataSource: DataSource, context: TransactionContext?): Connection { + try { + return DataSourceUtils.getConnection(dataSource) + } catch (e: Exception) { + throw PersistenceException("Failed to get connection from DataSource.", e) + } + } + + override fun releaseConnection(connection: Connection, dataSource: DataSource, context: TransactionContext?) { + DataSourceUtils.releaseConnection(connection, dataSource) + } +} diff --git a/storm-kotlinx-serialization/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-kotlinx-serialization/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider new file mode 100644 index 000000000..c19f3b819 --- /dev/null +++ b/storm-kotlinx-serialization/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -0,0 +1 @@ +st.orm.serialization.testsupport.TestSpringConnectionProvider diff --git a/storm-mariadb/pom.xml b/storm-mariadb/pom.xml index e8ef961f5..9da893ec3 100644 --- a/storm-mariadb/pom.xml +++ b/storm-mariadb/pom.xml @@ -36,11 +36,14 @@ org.apache.maven.plugins maven-surefire-plugin + + false @{argLine} --add-opens java.base/java.lang=ALL-UNNAMED - --add-opens storm.mariadb/st.orm.spi.mariadb=ALL-UNNAMED - --add-opens storm.mariadb/st.orm.spi.mariadb=storm.core diff --git a/storm-mariadb/src/test/java/st/orm/spi/mariadb/testsupport/TestSpringConnectionProvider.java b/storm-mariadb/src/test/java/st/orm/spi/mariadb/testsupport/TestSpringConnectionProvider.java new file mode 100644 index 000000000..b05b4c157 --- /dev/null +++ b/storm-mariadb/src/test/java/st/orm/spi/mariadb/testsupport/TestSpringConnectionProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.spi.mariadb.testsupport; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.sql.Connection; +import javax.sql.DataSource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import st.orm.PersistenceException; +import st.orm.core.spi.ConnectionProvider; +import st.orm.core.spi.Orderable.BeforeAny; +import st.orm.core.spi.TransactionContext; + +/** + * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's + * transaction-per-test isolation applies to statements executed by Storm templates. + */ +@BeforeAny +public class TestSpringConnectionProvider implements ConnectionProvider { + + @Override + public Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { + try { + return DataSourceUtils.getConnection(dataSource); + } catch (Exception e) { + throw new PersistenceException("Failed to get connection from DataSource.", e); + } + } + + @Override + public void releaseConnection(@Nonnull Connection connection, @Nonnull DataSource dataSource, @Nullable TransactionContext context) { + DataSourceUtils.releaseConnection(connection, dataSource); + } +} diff --git a/storm-mariadb/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-mariadb/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider new file mode 100644 index 000000000..95aca5c0f --- /dev/null +++ b/storm-mariadb/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -0,0 +1 @@ +st.orm.spi.mariadb.testsupport.TestSpringConnectionProvider diff --git a/storm-mssqlserver/pom.xml b/storm-mssqlserver/pom.xml index 85b16c9be..3db10d175 100644 --- a/storm-mssqlserver/pom.xml +++ b/storm-mssqlserver/pom.xml @@ -36,11 +36,14 @@ org.apache.maven.plugins maven-surefire-plugin + + false @{argLine} --add-opens java.base/java.lang=ALL-UNNAMED - --add-opens storm.mssqlserver/st.orm.spi.mssqlserver=ALL-UNNAMED - --add-opens storm.mssqlserver/st.orm.spi.mssqlserver=storm.core diff --git a/storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/testsupport/TestSpringConnectionProvider.java b/storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/testsupport/TestSpringConnectionProvider.java new file mode 100644 index 000000000..b6c9f4002 --- /dev/null +++ b/storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/testsupport/TestSpringConnectionProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.spi.mssqlserver.testsupport; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.sql.Connection; +import javax.sql.DataSource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import st.orm.PersistenceException; +import st.orm.core.spi.ConnectionProvider; +import st.orm.core.spi.Orderable.BeforeAny; +import st.orm.core.spi.TransactionContext; + +/** + * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's + * transaction-per-test isolation applies to statements executed by Storm templates. + */ +@BeforeAny +public class TestSpringConnectionProvider implements ConnectionProvider { + + @Override + public Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { + try { + return DataSourceUtils.getConnection(dataSource); + } catch (Exception e) { + throw new PersistenceException("Failed to get connection from DataSource.", e); + } + } + + @Override + public void releaseConnection(@Nonnull Connection connection, @Nonnull DataSource dataSource, @Nullable TransactionContext context) { + DataSourceUtils.releaseConnection(connection, dataSource); + } +} diff --git a/storm-mssqlserver/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-mssqlserver/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider new file mode 100644 index 000000000..ed8241117 --- /dev/null +++ b/storm-mssqlserver/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -0,0 +1 @@ +st.orm.spi.mssqlserver.testsupport.TestSpringConnectionProvider diff --git a/storm-mysql/pom.xml b/storm-mysql/pom.xml index c5ded494b..15c7215d2 100644 --- a/storm-mysql/pom.xml +++ b/storm-mysql/pom.xml @@ -36,11 +36,14 @@ org.apache.maven.plugins maven-surefire-plugin + + false @{argLine} --add-opens java.base/java.lang=ALL-UNNAMED - --add-opens storm.mysql/st.orm.spi.mysql=ALL-UNNAMED - --add-opens storm.mysql/st.orm.spi.mysql=storm.core diff --git a/storm-mysql/src/test/java/st/orm/spi/mysql/testsupport/TestSpringConnectionProvider.java b/storm-mysql/src/test/java/st/orm/spi/mysql/testsupport/TestSpringConnectionProvider.java new file mode 100644 index 000000000..459ef98ff --- /dev/null +++ b/storm-mysql/src/test/java/st/orm/spi/mysql/testsupport/TestSpringConnectionProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.spi.mysql.testsupport; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.sql.Connection; +import javax.sql.DataSource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import st.orm.PersistenceException; +import st.orm.core.spi.ConnectionProvider; +import st.orm.core.spi.Orderable.BeforeAny; +import st.orm.core.spi.TransactionContext; + +/** + * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's + * transaction-per-test isolation applies to statements executed by Storm templates. + */ +@BeforeAny +public class TestSpringConnectionProvider implements ConnectionProvider { + + @Override + public Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { + try { + return DataSourceUtils.getConnection(dataSource); + } catch (Exception e) { + throw new PersistenceException("Failed to get connection from DataSource.", e); + } + } + + @Override + public void releaseConnection(@Nonnull Connection connection, @Nonnull DataSource dataSource, @Nullable TransactionContext context) { + DataSourceUtils.releaseConnection(connection, dataSource); + } +} diff --git a/storm-mysql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-mysql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider new file mode 100644 index 000000000..0c1629510 --- /dev/null +++ b/storm-mysql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -0,0 +1 @@ +st.orm.spi.mysql.testsupport.TestSpringConnectionProvider diff --git a/storm-oracle/pom.xml b/storm-oracle/pom.xml index 94854dac1..ac3948c68 100644 --- a/storm-oracle/pom.xml +++ b/storm-oracle/pom.xml @@ -36,11 +36,14 @@ org.apache.maven.plugins maven-surefire-plugin + + false @{argLine} --add-opens java.base/java.lang=ALL-UNNAMED - --add-opens storm.oracle/st.orm.spi.oracle=ALL-UNNAMED - --add-opens storm.oracle/st.orm.spi.oracle=storm.core diff --git a/storm-oracle/src/test/java/st/orm/spi/oracle/testsupport/TestSpringConnectionProvider.java b/storm-oracle/src/test/java/st/orm/spi/oracle/testsupport/TestSpringConnectionProvider.java new file mode 100644 index 000000000..d8a0adcdd --- /dev/null +++ b/storm-oracle/src/test/java/st/orm/spi/oracle/testsupport/TestSpringConnectionProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.spi.oracle.testsupport; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.sql.Connection; +import javax.sql.DataSource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import st.orm.PersistenceException; +import st.orm.core.spi.ConnectionProvider; +import st.orm.core.spi.Orderable.BeforeAny; +import st.orm.core.spi.TransactionContext; + +/** + * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's + * transaction-per-test isolation applies to statements executed by Storm templates. + */ +@BeforeAny +public class TestSpringConnectionProvider implements ConnectionProvider { + + @Override + public Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { + try { + return DataSourceUtils.getConnection(dataSource); + } catch (Exception e) { + throw new PersistenceException("Failed to get connection from DataSource.", e); + } + } + + @Override + public void releaseConnection(@Nonnull Connection connection, @Nonnull DataSource dataSource, @Nullable TransactionContext context) { + DataSourceUtils.releaseConnection(connection, dataSource); + } +} diff --git a/storm-oracle/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-oracle/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider new file mode 100644 index 000000000..12a418db8 --- /dev/null +++ b/storm-oracle/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -0,0 +1 @@ +st.orm.spi.oracle.testsupport.TestSpringConnectionProvider diff --git a/storm-postgresql/pom.xml b/storm-postgresql/pom.xml index 4c48ee75b..55f6e8a8b 100644 --- a/storm-postgresql/pom.xml +++ b/storm-postgresql/pom.xml @@ -36,11 +36,14 @@ org.apache.maven.plugins maven-surefire-plugin + + false @{argLine} --add-opens java.base/java.lang=ALL-UNNAMED - --add-opens storm.postgresql/st.orm.spi.postgresql=ALL-UNNAMED - --add-opens storm.postgresql/st.orm.spi.postgresql=storm.core diff --git a/storm-postgresql/src/test/java/st/orm/spi/postgresql/testsupport/TestSpringConnectionProvider.java b/storm-postgresql/src/test/java/st/orm/spi/postgresql/testsupport/TestSpringConnectionProvider.java new file mode 100644 index 000000000..b9dcc7159 --- /dev/null +++ b/storm-postgresql/src/test/java/st/orm/spi/postgresql/testsupport/TestSpringConnectionProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.spi.postgresql.testsupport; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.sql.Connection; +import javax.sql.DataSource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import st.orm.PersistenceException; +import st.orm.core.spi.ConnectionProvider; +import st.orm.core.spi.Orderable.BeforeAny; +import st.orm.core.spi.TransactionContext; + +/** + * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's + * transaction-per-test isolation applies to statements executed by Storm templates. + */ +@BeforeAny +public class TestSpringConnectionProvider implements ConnectionProvider { + + @Override + public Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { + try { + return DataSourceUtils.getConnection(dataSource); + } catch (Exception e) { + throw new PersistenceException("Failed to get connection from DataSource.", e); + } + } + + @Override + public void releaseConnection(@Nonnull Connection connection, @Nonnull DataSource dataSource, @Nullable TransactionContext context) { + DataSourceUtils.releaseConnection(connection, dataSource); + } +} diff --git a/storm-postgresql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-postgresql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider new file mode 100644 index 000000000..f5329c1e0 --- /dev/null +++ b/storm-postgresql/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -0,0 +1 @@ +st.orm.spi.postgresql.testsupport.TestSpringConnectionProvider diff --git a/storm-sqlite/pom.xml b/storm-sqlite/pom.xml index 6180c25e3..4ac2da89f 100644 --- a/storm-sqlite/pom.xml +++ b/storm-sqlite/pom.xml @@ -36,11 +36,14 @@ org.apache.maven.plugins maven-surefire-plugin + + false @{argLine} --add-opens java.base/java.lang=ALL-UNNAMED - --add-opens storm.sqlite/st.orm.spi.sqlite=ALL-UNNAMED - --add-opens storm.sqlite/st.orm.spi.sqlite=storm.core diff --git a/storm-sqlite/src/test/java/st/orm/spi/sqlite/testsupport/TestSpringConnectionProvider.java b/storm-sqlite/src/test/java/st/orm/spi/sqlite/testsupport/TestSpringConnectionProvider.java new file mode 100644 index 000000000..cab7c30d5 --- /dev/null +++ b/storm-sqlite/src/test/java/st/orm/spi/sqlite/testsupport/TestSpringConnectionProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.spi.sqlite.testsupport; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.sql.Connection; +import javax.sql.DataSource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import st.orm.PersistenceException; +import st.orm.core.spi.ConnectionProvider; +import st.orm.core.spi.Orderable.BeforeAny; +import st.orm.core.spi.TransactionContext; + +/** + * Test-only connection provider that binds connections to Spring's transaction management, so the test suite's + * transaction-per-test isolation applies to statements executed by Storm templates. + */ +@BeforeAny +public class TestSpringConnectionProvider implements ConnectionProvider { + + @Override + public Connection getConnection(@Nonnull DataSource dataSource, @Nullable TransactionContext context) { + try { + return DataSourceUtils.getConnection(dataSource); + } catch (Exception e) { + throw new PersistenceException("Failed to get connection from DataSource.", e); + } + } + + @Override + public void releaseConnection(@Nonnull Connection connection, @Nonnull DataSource dataSource, @Nullable TransactionContext context) { + DataSourceUtils.releaseConnection(connection, dataSource); + } +} diff --git a/storm-sqlite/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider b/storm-sqlite/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider new file mode 100644 index 000000000..268e99aa1 --- /dev/null +++ b/storm-sqlite/src/test/resources/META-INF/services/st.orm.core.spi.ConnectionProvider @@ -0,0 +1 @@ +st.orm.spi.sqlite.testsupport.TestSpringConnectionProvider From 0ad5603dc29e62a3aabd97949ad2a88c020b5443 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 9 Jul 2026 20:22:04 +0200 Subject: [PATCH 4/4] docs: instance-scoped integration recipes and lazily bound transaction semantics Replace the @EnableTransactionIntegration guidance with springOrmTemplate for plain Spring and the starter's provider beans for Spring Boot, document that transaction blocks bind to the first template that executes inside them, and add the changelog entries for the integration-point rework. --- CHANGELOG.md | 27 +++++++++++++++++++++ docs/api-kotlin.md | 2 +- docs/spring-integration.md | 25 +++++++++++-------- docs/transactions.md | 27 ++++++++++++++------- website/src/pages/tutorials/transactions.js | 2 +- 5 files changed, 62 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 534b3dfa4..07a56201a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,33 @@ for the CLI, to [npm](https://www.npmjs.com/package/@storm-orm/cli) (`@storm-orm/cli`). Full release notes for every version are on the [GitHub Releases](https://github.com/storm-orm/storm-framework/releases) page. +## [Unreleased] + +Framework integration points are now instance-scoped (#198). Breaking changes are accepted with no deprecation shims. + +### Added + +- `ORMTemplate.builder(dataSource)` / `builder(connection)` (core, Java 21 and Kotlin APIs) with instance-scoped integration strategies: `connectionProvider`, `transactionTemplateProvider`, `exceptionMapper` and `queryObserver`. `ServiceLoader` discovery remains the fallback for templates built without explicit strategies. +- `ExceptionMapper` SPI: maps failures raised during query execution to the exception thrown to the caller, enabling platform hierarchies such as Spring's `DataAccessException` (the translation itself lands with #199). +- `QueryObserver` SPI: observes query executions (operation, entity/projection type, execution kind, timing, outcome) for metrics and tracing bindings (the Micrometer binding lands with #203 and #207). +- `springOrmTemplate(dataSource) { transactionManagers }` (storm-kotlin-spring): the canonical plain-Spring composition, replacing `@EnableTransactionIntegration`. +- `st.orm.spring.SpringTransactionTemplateProvider` (storm-spring): gives Java applications transaction-scoped entity caching under Spring-managed transactions without reflective probes. +- The Ktor plugin gains `connectionProvider`, `transactionTemplateProvider`, `exceptionMapper` and `queryObserver` slots on `install(Storm) { }`. + +### Changed + +- `transaction { }` / `transactionBlocking { }` now bind to the first template that executes inside the block: the block records the requested options, and the template's transaction provider opens the actual transaction on first use. Signatures and semantics are unchanged for single-integration applications; a block that never touches a template completes as a no-op, and mixing templates with different transaction providers in one block fails fast. +- The `TransactionTemplate` SPI is reshaped from callback-wrapping `execute()` to `open()`/`complete()` handles to support the lazy binding. +- Ambiguous `ServiceLoader` resolution of connection or transaction template providers (two enabled candidates without a defined order) now throws a descriptive error naming the candidates instead of silently picking one; provider enablement is re-evaluated per resolution instead of being frozen at first use. +- The Spring Boot starters contribute Spring-aware `ConnectionProvider`/`TransactionTemplateProvider` beans (backing off to user-defined beans) and consume optional `ExceptionMapper`/`QueryObserver` beans when creating the template. + +### Removed + +- `@EnableTransactionIntegration` and `SpringTransactionConfiguration` (storm-kotlin-spring): the static, JVM-global transaction manager list is gone. Use `springOrmTemplate(...)` (plain Spring) or the starter (Spring Boot); multiple application contexts in one JVM no longer interfere. +- The Spring modules no longer register `ServiceLoader` providers, and the reflective Spring probes are removed from storm-core and storm-kotlin: templates never silently enlist in Spring transactions based on classpath presence. Plain templates created with `ORMTemplate.of(dataSource)` inside a Spring application now run independently of Spring transactions; compose with `springOrmTemplate` or the builder to integrate. This also removes the "programmatic and Spring managed transactions cannot be mixed" guard, superseded by the per-block provider check. +- `Providers.getConnection` / `Providers.releaseConnection`: connections are acquired through the template's own connection provider. +- `st.orm.spring.impl.SpringConnectionProviderImpl`, `SpringTransactionTemplateProviderImpl` and `TransactionAwareConnectionProviderImpl` are replaced by the public `st.orm.spring.SpringConnectionProvider` and `SpringTransactionTemplateProvider`. + ## [1.12.0] - 2026-07-06 Feature release centered on the reified Kotlin query API. Breaking changes are accepted with no deprecation shims (1.12 policy). diff --git a/docs/api-kotlin.md b/docs/api-kotlin.md index 0951dc6e9..cbae04a69 100644 --- a/docs/api-kotlin.md +++ b/docs/api-kotlin.md @@ -30,7 +30,7 @@ The Kotlin API does not depend on any preview features. All APIs are stable and ### storm-kotlin-spring -Spring Framework integration for Kotlin. Provides `RepositoryBeanFactoryPostProcessor` for repository auto-discovery and injection, `@EnableTransactionIntegration` for bridging Storm's programmatic transactions with Spring's `@Transactional`, and transaction-aware coroutine support. Add this module when you use Spring Framework without Spring Boot. +Spring Framework integration for Kotlin. Provides `RepositoryBeanFactoryPostProcessor` for repository auto-discovery and injection, and `springOrmTemplate` for composing a template that bridges Storm's programmatic transactions with Spring's `@Transactional`. Add this module when you use Spring Framework without Spring Boot. ```kotlin implementation("st.orm:storm-kotlin-spring:@@STORM_VERSION@@") diff --git a/docs/spring-integration.md b/docs/spring-integration.md index 022ce70a3..98496841a 100644 --- a/docs/spring-integration.md +++ b/docs/spring-integration.md @@ -114,15 +114,18 @@ class ORMConfiguration(private val dataSource: DataSource) { ### Transaction Integration -By default, Storm manages its own transactions independently of Spring's transaction context. The `@EnableTransactionIntegration` annotation bridges the two systems so that Storm's programmatic `transaction` and `transactionBlocking` blocks participate in Spring-managed transactions. Without this annotation, a transaction block inside a `@Transactional` method would open a separate database connection and transaction. +A template created with `dataSource.orm` manages its own transactions, independently of Spring's transaction context: a transaction block inside a `@Transactional` method would open a separate database connection and transaction. To wire a template to Spring's transaction management instead, compose it with `springOrmTemplate`, which hands the Spring-aware connection and transaction providers to that specific template. Since 1.13 the integration is per template rather than JVM-global, so multiple application contexts, and plain templates next to Spring-managed ones, coexist without interference. ```kotlin -@EnableTransactionIntegration @Configuration -class ORMConfiguration(private val dataSource: DataSource) { +@EnableTransactionManagement +class ORMConfiguration { @Bean - fun ormTemplate(): ORMTemplate = dataSource.orm + fun ormTemplate( + dataSource: DataSource, + transactionManagers: ObjectProvider, + ): ORMTemplate = springOrmTemplate(dataSource) { transactionManagers.orderedStream().toList() } } ``` @@ -460,9 +463,9 @@ The Spring Boot Starter modules provide zero-configuration setup for Storm. Add The starter auto-configures: -1. **`ORMTemplate` bean** created from the auto-configured `DataSource`. If you define your own `ORMTemplate` bean, the auto-configured one backs off. +1. **`ORMTemplate` bean** created from the auto-configured `DataSource`, composed with the Spring-aware integration strategies below. If you define your own `ORMTemplate` bean, the auto-configured one backs off. 2. **Repository scanning** via `AutoConfiguredRepositoryBeanFactoryPostProcessor`, which discovers repository interfaces in the `@SpringBootApplication` base package (and its sub-packages). If you define your own `RepositoryBeanFactoryPostProcessor` bean, the auto-configured one backs off. -3. **Transaction integration** (Kotlin only) by automatically activating `SpringTransactionConfiguration`, removing the need for `@EnableTransactionIntegration`. +3. **Transaction integration** through Spring-aware `ConnectionProvider` and `TransactionTemplateProvider` beans, contributed when a `PlatformTransactionManager` is present and handed to the template it creates. Nothing is registered globally: each application context gets its own, correctly matched transaction integration. Define your own `ConnectionProvider` or `TransactionTemplateProvider` bean to override, and optionally contribute `ExceptionMapper` or `QueryObserver` beans, which are applied to the template the same way. 4. **Configuration properties** bound from `storm.*` in `application.yml`/`application.properties`, passed to the `ORMTemplate` via `StormConfig`. ### Minimal Spring Boot Setup (with Starter) @@ -549,11 +552,13 @@ If you use the integration module directly (without the starter), you need to co class Application @Configuration -@EnableTransactionIntegration -class StormConfig(private val dataSource: DataSource) { +class StormConfig { @Bean - fun ormTemplate() = dataSource.orm + fun ormTemplate( + dataSource: DataSource, + transactionManagers: ObjectProvider, + ): ORMTemplate = springOrmTemplate(dataSource) { transactionManagers.orderedStream().toList() } } @Configuration @@ -586,7 +591,7 @@ public void doWork() { ## Transaction Propagation -When `@EnableTransactionIntegration` is active, Storm's programmatic transactions participate in Spring's transaction propagation. This means a `transaction` or `transactionBlocking` block checks for an existing Spring-managed transaction before starting a new one. If a transaction already exists, the block joins it. If not, it creates a new independent transaction. +When a template is wired to Spring's transaction management (via `springOrmTemplate` or the starter), Storm's programmatic transactions participate in Spring's transaction propagation. This means a `transaction` or `transactionBlocking` block checks for an existing Spring-managed transaction before starting a new one. If a transaction already exists, the block joins it. If not, it creates a new independent transaction. Understanding this behavior is important for controlling atomicity. When multiple operations must commit or roll back as a unit, they need to share the same transaction. When operations should be independent (for example, logging that should persist even if the main operation fails), they need separate transactions. diff --git a/docs/transactions.md b/docs/transactions.md index 427695a08..677baf8b2 100644 --- a/docs/transactions.md +++ b/docs/transactions.md @@ -971,43 +971,52 @@ withTransactionOptionsBlocking(isolation = SERIALIZABLE) { } ``` +### How Transactions Bind to Templates + +Since 1.13, a `transaction` or `transactionBlocking` block binds to the first `ORMTemplate` that executes inside it. Opening the block only records the requested options (propagation, isolation, timeout, read-only); the actual transaction is opened by that first template's transaction provider. This means the block automatically uses whatever transaction system the template is configured with, whether that is Storm's own JDBC transactions or a platform bridge such as Spring's transaction management. A block that never touches a template completes as a no-op. + +Templates that should share a transaction must use the same transaction provider instance. This is automatic for repositories of one application (the Spring Boot starter and the Ktor plugin configure one provider per application context or plugin installation). Mixing templates with *different* transaction providers inside one block fails fast with a descriptive error, since a single commit cannot span two transaction systems. + ### Spring-Managed Transactions While Storm's programmatic transaction API works standalone, many applications use Spring's transaction management for its declarative `@Transactional` support and integration with other Spring components. Storm integrates seamlessly with Spring's transaction management. -When `@EnableTransactionIntegration` is configured, Storm's programmatic `transaction` blocks automatically detect and participate in Spring-managed transactions. This gives you the best of both worlds: Spring's declarative transaction boundaries with Storm's coroutine-friendly transaction blocks. +When a template is wired to Spring's transaction management, Storm's programmatic `transactionBlocking` blocks run through Spring's `PlatformTransactionManager` and participate in Spring-managed transactions. This gives you the best of both worlds: Spring's declarative transaction boundaries with Storm's programmatic transaction blocks. The suspending `transaction` variant is not supported with Spring-managed transactions; use `transactionBlocking` there. #### Configuration -Enable Spring integration in your configuration class: +The Spring Boot starter wires this automatically when a `PlatformTransactionManager` is present. Without the starter, compose the template with `springOrmTemplate`: ```kotlin -@EnableTransactionIntegration @Configuration -class ORMConfiguration(private val dataSource: DataSource) { +@EnableTransactionManagement +class ORMConfiguration { @Bean - fun ormTemplate() = ORMTemplate.of(dataSource) + fun ormTemplate( + dataSource: DataSource, + transactionManagers: ObjectProvider, + ): ORMTemplate = springOrmTemplate(dataSource) { transactionManagers.orderedStream().toList() } } ``` #### Combining Declarative and Programmatic Transactions -You can use Spring's `@Transactional` annotation alongside Storm's programmatic `transaction` blocks. Storm will join the existing Spring transaction: +You can use Spring's `@Transactional` annotation alongside Storm's programmatic `transactionBlocking` blocks. Storm will join the existing Spring transaction: ```kotlin @Service class UserService(private val orm: ORMTemplate) { @Transactional - suspend fun createUserWithOrders(user: User, orders: List) { + fun createUserWithOrders(user: User, orders: List) { // Spring starts the transaction - transaction { + transactionBlocking { // Storm joins the Spring transaction (REQUIRED propagation by default) orm insert user } - transaction { + transactionBlocking { // Still in the same Spring transaction orders.forEach { orm insert it } } diff --git a/website/src/pages/tutorials/transactions.js b/website/src/pages/tutorials/transactions.js index 7c6b39bfe..2a410c562 100644 --- a/website/src/pages/tutorials/transactions.js +++ b/website/src/pages/tutorials/transactions.js @@ -124,7 +124,7 @@ ${navHtml('tutorials')}

The transaction is part of the coroutine context, so the code inside the switched dispatcher is still transaction-aware: a repository call there joins the same transaction rather than opening a new one. For synchronous code, transactionBlocking is the drop-in equivalent.

07Mixing with Spring

-

If you run Spring, the two systems bridge, on one condition. With @EnableTransactionIntegration (the Kotlin Spring Boot starter enables it by default), Storm's blocks delegate to Spring's transaction manager: a transactionBlocking inside a @Transactional method joins the surrounding transaction, and propagation behaves as one system. Without that bridge, the two managers are independent, and a block inside a @Transactional method would open its own connection and its own transaction.

+

If you run Spring, the two systems bridge, on one condition: the template must be wired to Spring's transaction management. The Spring Boot starters do this by default when a transaction manager is present; outside Boot, compose the template with springOrmTemplate. With the bridge in place, Storm's blocks delegate to Spring's transaction manager: a transactionBlocking inside a @Transactional method joins the surrounding transaction, and propagation behaves as one system. Without the bridge, the two managers are independent, and a block inside a @Transactional method would open its own connection and its own transaction.

One trade-off to know: suspend transaction blocks are not available while Spring manages Storm's transactions; Storm rejects them with a clear error. Coroutine-aware transactions belong to setups where Storm manages transactions itself, such as Ktor or standalone services. See Spring Integration for the details.

08Side by side