From cea1c7e6b14fb4cc8966f8c8524fb1fa4d0bd101 Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort
Date: Mon, 10 Aug 2026 16:22:25 +0200
Subject: [PATCH 1/2] fix!: keep explicit dialects across withConfig and fail
fast on ambiguous dialect resolution
withConfig re-resolved the dialect from the classpath, silently discarding
a dialect set via withDialect. The template now tracks the explicitly set
dialect and retains it across withConfig; a classpath-resolved dialect is
re-resolved under the new configuration, as dialects capture their
configuration at construction.
Providers.getSqlDialect(StormConfig) now selects its provider through the
same unique-selection path as the connection and transaction providers, so
an ambiguous classpath fails with an error naming the candidates instead
of picking by classpath order. Ambient dialect resolution is deferred to
first use: SqlTemplate.PS/JPA no longer resolve a dialect during class
initialization, and database-bound templates never trigger classpath
resolution at all. The JPA classpath fallback is deferred the same way so
a provider filter can still be applied on an ambiguous classpath.
---
.../main/java/st/orm/core/spi/Providers.java | 17 +-
.../st/orm/core/template/SqlTemplate.java | 9 +-
.../core/template/impl/JpaTemplateImpl.java | 40 +++--
.../core/template/impl/SqlTemplateImpl.java | 71 ++++++---
.../spi/SqlDialectProviderResolutionTest.java | 145 ++++++++++++++++++
.../template/impl/SqlTemplateImplTest.java | 42 +++++
6 files changed, 283 insertions(+), 41 deletions(-)
create mode 100644 storm-core/src/test/java/st/orm/core/spi/SqlDialectProviderResolutionTest.java
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 578e6cb2c..7ef2e1b3e 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
@@ -235,11 +235,20 @@ public static SqlDialect getSqlDialect() {
return getSqlDialect(StormConfig.defaults());
}
+ /**
+ * Resolves the SQL dialect from the classpath, without a database in view.
+ *
+ *
Enablement is re-evaluated on every resolution, and an ambiguous resolution fails fast.
+ *
+ * @param config the Storm configuration to apply.
+ * @return the SQL dialect.
+ * @throws PersistenceException if no dialect provider is found or the resolution is ambiguous.
+ */
public static SqlDialect getSqlDialect(@Nonnull StormConfig config) {
- return enabled(SQL_DIALECT_PROVIDERS)
- .map(p -> p.getSqlDialect(config))
- .findFirst()
- .orElseThrow();
+ return selectUnique(SQL_DIALECT_PROVIDERS, "SQL dialect provider",
+ "SqlTemplate.withDialect(...), or by binding the template to a DataSource or Connection so the " +
+ "dialect is derived from the database")
+ .getSqlDialect(config);
}
public static SqlDialect getSqlDialect(@Nonnull Predicate super SqlDialectProvider> filter) {
diff --git a/storm-core/src/main/java/st/orm/core/template/SqlTemplate.java b/storm-core/src/main/java/st/orm/core/template/SqlTemplate.java
index 33956d94f..4c5055fb9 100644
--- a/storm-core/src/main/java/st/orm/core/template/SqlTemplate.java
+++ b/storm-core/src/main/java/st/orm/core/template/SqlTemplate.java
@@ -286,14 +286,19 @@ interface BatchListener {
/**
* Returns the SQL dialect used by this template.
*
+ *
When no dialect has been set via {@link #withDialect(SqlDialect)}, the dialect is resolved from the
+ * classpath on first use. That resolution fails with a descriptive exception when multiple dialect providers
+ * are eligible without a defined order.
+ *
* @return the SQL dialect used by this template.
* @since 1.2
*/
SqlDialect dialect();
/**
- * Returns a new SQL template with the specified Storm configuration. The SQL dialect is re-resolved from the
- * provided configuration.
+ * Returns a new SQL template with the specified Storm configuration. A dialect set via
+ * {@link #withDialect(SqlDialect)} is retained; a classpath-resolved dialect is re-resolved under the new
+ * configuration, as dialects capture their configuration at construction.
*
* @param config the Storm configuration to apply.
* @return a new SQL template.
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/JpaTemplateImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/JpaTemplateImpl.java
index 59c27dff8..0ddbbca74 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/JpaTemplateImpl.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/JpaTemplateImpl.java
@@ -19,6 +19,7 @@
import static jakarta.persistence.TemporalType.TIME;
import static jakarta.persistence.TemporalType.TIMESTAMP;
import static st.orm.core.template.SqlTemplate.JPA;
+import static st.orm.core.template.impl.LazySupplier.lazy;
import static st.orm.core.template.impl.RecordValidation.validate;
import jakarta.annotation.Nonnull;
@@ -28,6 +29,7 @@
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
+import java.util.function.Supplier;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import javax.sql.DataSource;
@@ -83,9 +85,21 @@ private interface TemplateProcessor {
private final TableAliasResolver tableAliasResolver;
private final Predicate providerFilter;
private final RefFactory refFactory;
- private final SqlTemplate sqlTemplate;
+
+ /**
+ * Built on first use: without a provider filter it consumes the resolved dialect, whose classpath fallback must
+ * stay untouched until the template is actually used.
+ */
+ private final Supplier sqlTemplate;
+
private final StormConfig config;
- private final SqlDialect dialect;
+
+ /**
+ * The dialect of the persistence unit's database, or a classpath fallback resolved on first use when the database
+ * is unknown. Deferring the fallback keeps an ambiguous classpath from failing template construction, so a
+ * provider filter can still be applied via {@link #withProviderFilter(Predicate)}.
+ */
+ private final Supplier dialect;
public JpaTemplateImpl(@Nonnull EntityManager entityManager) {
this(entityManager, StormConfig.defaults());
@@ -112,7 +126,7 @@ public JpaTemplateImpl(@Nonnull EntityManager entityManager, @Nonnull StormConfi
this.config = config;
this.dialect = resolveDialect(entityManager, config);
this.refFactory = new RefFactoryImpl(this, modelBuilder, providerFilter);
- this.sqlTemplate = createSqlTemplate();
+ this.sqlTemplate = lazy(this::createSqlTemplate);
}
/**
@@ -121,19 +135,19 @@ public JpaTemplateImpl(@Nonnull EntityManager entityManager, @Nonnull StormConfi
*
Asking the persistence unit for its data source is the portable way to reach the database: unwrapping an
* entity manager to a {@link java.sql.Connection} is not supported by every provider. A persistence unit
* configured with a connection URL rather than a data source, or one whose database cannot be reached while the
- * template is being built, leaves the database unknown, and the dialect then comes from the classpath as
- * before.
+ * template is being built, leaves the database unknown, and the dialect then comes from the classpath, resolved
+ * on first use.
*/
- private static SqlDialect resolveDialect(@Nonnull EntityManager entityManager, @Nonnull StormConfig config) {
+ private static Supplier resolveDialect(@Nonnull EntityManager entityManager, @Nonnull StormConfig config) {
DataSource dataSource = dataSourceOf(entityManager);
if (dataSource == null) {
- return Providers.getSqlDialect(config);
+ return lazy(() -> Providers.getSqlDialect(config));
}
try {
- return Providers.getSqlDialect(dataSource, config);
+ return new LazySupplier<>(Providers.getSqlDialect(dataSource, config));
} catch (RuntimeException e) {
LOGGER.debug("Failed to determine the database of the persistence unit.", e);
- return Providers.getSqlDialect(config);
+ return lazy(() -> Providers.getSqlDialect(config));
}
}
@@ -163,7 +177,7 @@ private JpaTemplateImpl(@Nonnull TemplateProcessor templateProcessor,
@Nonnull TableAliasResolver tableAliasResolver,
@Nullable Predicate providerFilter,
@Nonnull StormConfig config,
- @Nonnull SqlDialect dialect) {
+ @Nonnull Supplier dialect) {
this.dialect = dialect;
this.templateProcessor = templateProcessor;
this.modelBuilder = modelBuilder;
@@ -171,7 +185,7 @@ private JpaTemplateImpl(@Nonnull TemplateProcessor templateProcessor,
this.providerFilter = providerFilter;
this.config = config;
this.refFactory = new RefFactoryImpl(this, modelBuilder, providerFilter);
- this.sqlTemplate = createSqlTemplate();
+ this.sqlTemplate = lazy(this::createSqlTemplate);
}
private SqlTemplate createSqlTemplate() {
@@ -183,7 +197,7 @@ private SqlTemplate createSqlTemplate() {
// The shared template resolves a dialect without a database in view, so the dialect resolved for this
// persistence unit is applied on top. An explicit provider filter still wins.
return template.withDialect(
- providerFilter != null ? Providers.getSqlDialect(providerFilter, config) : dialect);
+ providerFilter != null ? Providers.getSqlDialect(providerFilter, config) : dialect.get());
}
private void setParameters(@Nonnull jakarta.persistence.Query query, @Nonnull List parameters) {
@@ -252,7 +266,7 @@ private jakarta.persistence.Query query(@Nonnull TemplateString template, @Nonnu
*/
@Override
public SqlTemplate sqlTemplate() {
- return SqlInterceptorManager.customize(sqlTemplate);
+ return SqlInterceptorManager.customize(sqlTemplate.get());
}
/**
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/SqlTemplateImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/SqlTemplateImpl.java
index 1157f7653..fbd97bf73 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/SqlTemplateImpl.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/SqlTemplateImpl.java
@@ -24,12 +24,14 @@
import static st.orm.core.template.impl.SqlInterceptorManager.intercept;
import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
+import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import st.orm.BindVars;
@@ -84,15 +86,33 @@ record Wrapped(@Nonnull List elements) implements Element {
private final boolean inlineParameters;
private final ModelBuilder modelBuilder;
private final TableAliasResolver tableAliasResolver;
- private final SqlDialect dialect;
- private final TemplatePreparation templatePreparation;
+
+ /**
+ * The dialect set via {@link #withDialect(SqlDialect)} or a dialect-taking constructor, or {@code null} when the
+ * dialect is resolved from the classpath. An explicit dialect is preserved across {@link #withConfig(StormConfig)},
+ * whereas a classpath-resolved dialect is re-resolved under the new configuration.
+ */
+ private final @Nullable SqlDialect explicitDialect;
+
+ /**
+ * The dialect in use, resolved on first use. Classpath resolution fails fast when multiple dialect providers are
+ * eligible without a defined order, so templates that receive an explicit dialect before processing, such as the
+ * shared {@link SqlTemplate#PS} and {@link SqlTemplate#JPA} instances customized by database-bound templates,
+ * must never trigger it. All dialect-dependent state is therefore initialized lazily.
+ */
+ private final LazySupplier dialect;
+
+ private final Supplier templatePreparation;
private final Function keyGenerator;
private final StormConfig config;
- private final SegmentedLruCache