Skip to content

fix: resolve the validation dialect from the database product - #360

Merged
zantvoort merged 5 commits into
mainfrom
fix/schema-validator-dialect-resolution
Aug 2, 2026
Merged

fix: resolve the validation dialect from the database product#360
zantvoort merged 5 commits into
mainfrom
fix/schema-validator-dialect-resolution

Conversation

@zantvoort

Copy link
Copy Markdown
Collaborator

Fixes #359

The defect

SchemaValidator.of(DataSource) resolved its dialect with the no-argument Providers.getSqlDialect(), which returns the first registered SqlDialectProvider without consulting the database it is about to read. It had the DataSource in hand, and Providers.getSqlDialect(dataSource, config) already selects by product name, which is what PreparedStatementTemplateImpl does. Queries therefore stayed on the right dialect while validation did not.

With several dialect modules registered, the winner of that findFirst() depends on service loading order. An H2 database could be read with the MySQL dialect, whose INFORMATION_SCHEMA_REFERENCING strategy queries KEY_COLUMN_USAGE.REFERENCED_TABLE_NAME, a column H2 does not have.

The failure then became invisible. DatabaseSchema swallowed the SQLException, and an empty foreign key map reads exactly like a schema with no foreign keys, so validation reported every @FK as missing. The existing comments state the intent ("foreign key validation will be skipped"); the implementation reported instead of skipping.

The change

Dialect resolution. SchemaValidator.of(DataSource) and of(DataSource, ModelBuilder) resolve the dialect from the database product, falling back to the first registered dialect only when no provider claims the product.

Unknown is not missing. A schema read records which constraint kinds it managed to read (DatabaseSchema.isDiscovered). Validation skips the primary key, unique key and foreign key checks for a kind that stayed unknown, so a database that cannot answer a discovery query is reported as unknown rather than as wrong. Kinds are tracked separately: a failed foreign key query no longer suppresses primary key validation. The cause of a failed read is logged at debug instead of being discarded.

Verification

  • SchemaValidatorConstraintDiscoveryTest covers the reporting half: with a dialect whose constraint discovery does not fit the database, the foreign key that exists is not reported missing, while the primary keys that were readable are still validated. Both new assertions fail without the change.
  • DatabaseSchemaTest covers the read half: a fitting strategy discovers the foreign key, a mismatched one leaves FOREIGN_KEY undiscovered while KEY stays discovered.
  • storm-core (2500 tests) and storm-h2 (158 tests) pass.
  • End to end: an application carrying storm-h2, storm-mysql and storm-postgresql together, which failed its suite consistently before, now passes five consecutive runs.

Schema validation resolved its dialect with the no-argument provider
lookup, which returns the first registered dialect regardless of the
database it is about to read. The template factories already select by
database product; validation now does the same, and falls back to the
first registered dialect only when no provider claims the product.

With several dialect modules on the classpath the previous lookup could
hand an H2 database the MySQL dialect, whose constraint discovery reads
a KEY_COLUMN_USAGE column H2 does not have. That query failed, and a
failed discovery left the constraint maps empty, which validation read
as "the database has no such constraints" and reported every foreign key
as missing.

A schema read now records which constraint kinds it managed to read, and
validation skips the checks for a kind that stayed unknown, so a database
that cannot answer the query is no longer reported as wrong. The cause of
the failed read is logged at debug rather than discarded.

Fixes #359
Copilot AI review requested due to automatic review settings August 1, 2026 23:00
@zantvoort zantvoort added this to the 1.13.1 milestone Aug 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes schema validation flakiness and false negatives when multiple SQL dialect modules are present by (1) resolving the validator dialect based on the actual database product, and (2) distinguishing “constraints could not be discovered” from “constraints are missing” so validation skips unknown constraint kinds rather than reporting incorrect errors.

Changes:

  • Update SchemaValidator.of(DataSource…) to resolve SqlDialect using the database product (with a fallback when no provider matches).
  • Track constraint discovery success in DatabaseSchema via ConstraintKind + isDiscovered(...), and skip PK/UK/FK checks when discovery failed; log discovery failures at debug.
  • Add focused tests covering dialect mismatch behavior and constraint discovery outcomes.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java Resolve dialect from DB product and skip PK/UK/FK validations when the corresponding constraint kind couldn’t be discovered.
storm-core/src/main/java/st/orm/core/template/impl/DatabaseSchema.java Track which constraint kinds were successfully discovered and log discovery failures instead of silently swallowing exceptions.
storm-core/src/test/java/st/orm/core/template/impl/SchemaValidatorConstraintDiscoveryTest.java New regression test ensuring FK validation is skipped (not reported missing) when FK discovery fails due to dialect mismatch.
storm-core/src/test/java/st/orm/core/template/impl/DatabaseSchemaTest.java New assertions validating isDiscovered(ConstraintKind) behavior for matching vs mismatched discovery strategies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +136 to 140
private static SqlDialect resolveSqlDialect(@Nonnull DataSource dataSource) {
StormConfig config = StormConfig.defaults();
SqlDialectProvider provider = getSqlDialectProvider(getDatabaseProductName(dataSource));
return provider != null ? provider.getSqlDialect(config) : getSqlDialect(config);
}
Comment on lines 303 to 318
@@ -268,8 +311,10 @@ private static void readConstraintsFromJdbcMetadata(
primaryKeysByTable.computeIfAbsent(pkTableName, k -> new ArrayList<>())
.add(new DbPrimaryKey(pkTableName, columnName, keySeq));
}
} catch (SQLException ignored) {
// Some databases/views may not support getPrimaryKeys; skip gracefully.
} catch (SQLException e) {
// Some databases/views may not support getPrimaryKeys; the keys stay unknown.
LOGGER.debug("Failed to read primary keys for table '{}'.", tableName, e);
keysRead = false;
}
Comment on lines +352 to +353
// Some databases/views may not support getImportedKeys; the keys stay unknown.
LOGGER.debug("Failed to read foreign keys for table '{}'.", tableName, e);
ORMTemplate.validateSchema() and validateSchemaOrThrow() build their
validator with a dialect resolved from the first registered provider
whenever the template carries no explicit provider filter, even though
the template knows the data source it is bound to.

The filter is absent exactly when no dialect module claims the database
product, so the fallback could hand the schema read an unrelated vendor's
dialect. Resolving from the data source keeps validation on the dialect
that matches the database, and an explicit provider filter still wins.
Copilot AI review requested due to automatic review settings August 2, 2026 08:06
@zantvoort

Copy link
Copy Markdown
Collaborator Author

Audit of the other dialect resolution points

Checked every place that resolves a SqlDialect, to see whether the same product-blind lookup appears elsewhere.

Entry point Resolution Verdict
PreparedStatementTemplateImpl(DataSource) getSqlDialectProvider(getDatabaseProductName(dataSource)) correct
PreparedStatementTemplateImpl(Connection) getSqlDialectProvider(getDatabaseProductName(connection)) correct
PreparedStatementTemplateImpl.withProviderFilter explicit filter correct
SchemaValidator.of(DataSource[, ModelBuilder]) first registered provider fixed in this PR
ORMTemplateImpl.createSchemaValidator first registered provider when no filter is set fixed here too, second commit
PreparedStatementTemplateImpl.createSqlTemplate inherits the ambient template dialect when no filter is set see below
JpaTemplateImpl.createSqlTemplate inherits the ambient template dialect when no filter is set see below
SqlTemplateImpl constructor / withConfig first registered provider no database in scope, callers must override

Also fixed: validation through the template

ORMTemplate.validateSchema() and validateSchemaOrThrow() reach SchemaValidator through the three-argument factory, passing a dialect the template resolved itself. That resolution had the same defect, so fixing the factories alone left this public path broken. It now resolves from the template's data source, with an explicit provider filter still taking precedence.

Left alone: the query dialect fallback

createSqlTemplate() starts from PS.withConfig(config) (or JPA.withConfig(config)), whose dialect comes from the product-blind lookup, and overrides it only when a provider filter is set. The filter is null exactly when no module claims the database product, because SqlDialectProvider.getProviderFilter() defaults to null and the default provider does not override it.

So with a vendor module on the classpath and a database no module claims, SQL is generated with that vendor's dialect. Measured with storm-mysql present and an H2 database:

product=H2
productBlind=MySQLSqlDialect     <- what createSqlTemplate() ends up with
productAware=DefaultSqlDialect   <- what the database says
matchedProvider=DefaultSqlDialectProviderImpl

Every provider claims only its own product name, so this also covers a case that may be load-bearing for someone today: MariaDB with only storm-mysql on the classpath currently gets the MySQL dialect through this fallback. Making the fallback product-aware would move that setup to the generic dialect, which is more correct but is a behaviour change for anyone relying on the accident. Left out of this PR deliberately; happy to take it on if you want it, as its own change.

Test coverage note

The ORMTemplateImpl line has no direct regression test: catching it needs two dialect modules on one test classpath, and every module currently has at most one. A small test module that puts two dialects together would have caught both occurrences of this bug. Worth considering separately.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (2)

storm-core/src/main/java/st/orm/core/template/impl/DatabaseSchema.java:339

  • In the JDBC_METADATA strategy, a failure in getIndexInfo() sets keysRead=false, which marks ConstraintKind.KEY as undiscovered and will also skip primary key validation (SchemaValidator gates PK checks on KEY). Since primary keys (getPrimaryKeys) and unique keys (getIndexInfo) are separate calls, it’s possible for PKs to be readable while UKs are not; in that case PK validation would be unnecessarily disabled.
            } catch (SQLException e) {
                // Some databases/views may not support getIndexInfo; the keys stay unknown.
                LOGGER.debug("Failed to read unique indexes for table '{}'.", tableName, e);
                keysRead = false;
            }

storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java:140

  • resolveSqlDialect() duplicates Providers.getSqlDialect(DataSource, StormConfig) and adds a fallback branch that is effectively unreachable when the default dialect provider is present (it supports all products). Using the existing DataSource-based resolution keeps behavior consistent with other templates and avoids drifting from Providers logic.
    private static SqlDialect resolveSqlDialect(@Nonnull DataSource dataSource) {
        StormConfig config = StormConfig.defaults();
        SqlDialectProvider provider = getSqlDialectProvider(getDatabaseProductName(dataSource));
        return provider != null ? provider.getSqlDialect(config) : getSqlDialect(config);
    }

Every module carries at most one dialect, so nothing in the reactor could
observe a resolution that ignores the database, and the same defect was
fixed twice before a test could see it.

This module's dialect claims only MySQL. Adding H2 to its test scope
creates the one configuration the reactor can check: a database no
provider here claims, where resolving without asking the database lands
on this module's dialect instead of the default. Reading an H2 schema
with MySQL's constraint queries silently reads nothing, so the tests
assert on findings that require the constraints to have been read, a
foreign key that is absent and one that points elsewhere, through both
the SchemaValidator factory and ORMTemplate.validateSchema.

One test asserts that the mismatch is still present, so adding another
dialect to this module's test scope fails loudly rather than quietly
leaving the rest asserting nothing.
Copilot AI review requested due to automatic review settings August 2, 2026 08:14
@zantvoort

Copy link
Copy Markdown
Collaborator Author

Regression tests for the resolution itself

The two fixes above landed without a test that could see them, because catching a resolution that ignores the database needs two dialects on one classpath and every module carries at most one. That gap is why the same defect had to be fixed twice.

storm-mysql now has H2 in its test scope. That creates the one configuration the reactor can check: a database no provider in that module claims, where resolving without asking the database lands on the MySQL dialect instead of the default. MySQLDialectResolutionTest covers it.

The assertions are on findings that are only possible once the constraints have actually been read, since MySQL's constraint discovery against H2 fails silently and leaves nothing to report:

  • a foreign key that is absent, expecting FOREIGN_KEY_MISSING
  • a foreign key that points at another table, expecting FOREIGN_KEY_MISMATCH

both through SchemaValidator.of(dataSource) and through ORMTemplate.validateSchema. The template path needs the misdirected key rather than the absent one, because a missing foreign key is a warning and warnings are logged but kept out of the returned list unless validation is strict.

theProductBlindLookupWouldPickThisModulesDialect asserts the mismatch is still in place, so adding another dialect to that test scope fails loudly instead of quietly leaving the rest asserting nothing.

Each test was checked against the unfixed code:

Reverted Failing tests
SchemaValidator factories validatorStillReportsAMissingForeignKeyOnAnUnclaimedDatabase, validatorReportsAMisdirectedForeignKeyOnAnUnclaimedDatabase
ORMTemplateImpl.createSchemaValidator templateValidationStillReportsAMisdirectedForeignKeyOnAnUnclaimedDatabase

Full storm-mysql suite: 182 tests, green.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

storm-core/src/main/java/st/orm/core/template/impl/DatabaseSchema.java:352

  • In the JDBC metadata constraint discovery, this catch block is specifically about foreign key discovery, but the comment says "the keys stay unknown", which is ambiguous (could read as primary/unique keys). Clarifying this avoids confusion when reading logs and discovery semantics.
                // Some databases/views may not support getImportedKeys; the keys stay unknown.

storm-core/src/main/java/st/orm/core/template/impl/DatabaseSchema.java:122

  • ConstraintKind.KEY is documented as "Primary keys and unique keys, which every strategy reads together", but the JDBC_METADATA strategy reads primary keys and unique indexes via separate metadata calls (and can fail independently). Rewording the enum comment avoids stating a guarantee that the implementation doesn’t uphold.
        /** Primary keys and unique keys, which every strategy reads together. */

A template resolves the dialect of the database it is bound to and hands
it to its processor, which uses it to bind parameters and to size fetches.
The SQL template it generates statements with was built on the shared
PS template instead, whose dialect is resolved once with no database in
view, and was only corrected when a provider filter happened to be set.

A filter is set only when a dialect module claims the database product,
so with a vendor module on the classpath and a database no module claims,
the two halves of a query disagreed: statements written in that vendor's
syntax, executed with the dialect the template had resolved for itself.

The resolved dialect is now carried through the constructor chain and
applied to the SQL template as well, so both halves speak for the same
database. An explicit provider filter still takes precedence.

The accompanying test compares exact dialect classes: the vendor dialects
extend the default one, so an instanceof check cannot tell them apart.
Copilot AI review requested due to automatic review settings August 2, 2026 09:06
@zantvoort

Copy link
Copy Markdown
Collaborator Author

The query dialect is fixed too, and a correction

I said earlier that the query dialect fallback was a judgement call about MariaDB. That framing rested on an inference, and the first test I wrote for it appeared to show the query path was fine. Both were wrong, for the same reason: MySQLSqlDialect extends DefaultSqlDialect, so assertInstanceOf(DefaultSqlDialect.class, ...) holds for either dialect and the assertion said nothing. Comparing exact classes, the defect is deterministic:

expected: <st.orm.core.spi.DefaultSqlDialect> but was: <st.orm.spi.mysql.MySQLSqlDialect>

What was wrong

A template resolves the dialect of the database it is bound to and hands it to its processor, which binds parameters and sizes fetches with it. The SQL template it generates statements with was built on the shared SqlTemplate.PS, whose dialect is resolved once with no database in view, and was corrected only when a provider filter happened to be set.

A filter is set only when a dialect module claims the database product. So with a vendor module on the classpath and a database no module claims, the two halves of a query disagreed about the database: statements written in that vendor's syntax, executed with the dialect the template had resolved for itself. The file already carried a comment noting the two could diverge.

The resolved dialect is now carried through the constructor chain and applied to the SQL template as well. An explicit provider filter still takes precedence.

That also changes how the MariaDB case should be read. MariaDB with only storm-mysql present was not quietly working; it was generating MySQL SQL and executing it with the default dialect's behaviour. It now consistently gets the default dialect, which is a behaviour change worth a release note, and the real answer for those users remains storm-mariadb.

Still not changed: JPA

JpaTemplateImpl has the same shape but no DataSource or Connection to ask, only an EntityManager. There the fallback is load-bearing: a JPA user with one vendor module gets that dialect through it, and switching to the default would take it away. Resolving the product from the EntityManager is not portable across providers. Left for a separate decision.

Verification

  • sqlIsGeneratedWithTheDialectOfTheDatabaseTheTemplateIsBoundTo fails against the unfixed code with the message above, and pins SqlTemplate.PS to this module's dialect first so the assertion is about resolution rather than about initialisation order.
  • Full reactor: BUILD SUCCESS, every module green.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

…base

A JPA template built its SQL on the shared JPA template, whose dialect is
resolved once from the classpath with no database in view, and corrected
it only when a provider filter was set. Nothing set one, so a persistence
unit was written for whichever dialect module happened to sort first.

Jakarta Persistence publishes the data source a persistence unit was
configured with, which is the portable way to reach its database: an
entity manager cannot be unwrapped to a Connection on every provider. The
template now resolves its dialect from that data source.

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; the dialect then comes from the
classpath as before and the cause is logged at debug. An explicit
provider filter still takes precedence.
Copilot AI review requested due to automatic review settings August 2, 2026 09:39
@zantvoort

Copy link
Copy Markdown
Collaborator Author

JPA resolves from its persistence unit too

The remaining path is fixed. What made it look undecidable was the assumption that a JPA template has no way to reach its database. It does, just not the obvious one.

Probing what an entity manager actually exposes under Hibernate:

em.unwrap(Connection.class)          -> PersistenceException
emf.unwrap(DataSource.class)         -> PersistenceException
emf.getProperties()                  -> jakarta.persistence.nonJtaDataSource = <DataSource>

Jakarta Persistence publishes the data source a persistence unit was configured with, so the template resolves its dialect from that, the same way the JDBC path does. Both the current property names and their javax.* predecessors are consulted, since providers carry the old names forward.

No regression where the database stays unknown

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. The dialect then comes from the classpath exactly as before, and the cause is logged at debug rather than swallowed. So the concern I raised earlier, that a JPA user with a single vendor module would lose their dialect, does not apply: they keep it, and they now get the right one whenever the database can be identified.

Verification

MySQLJpaDialectResolutionTest runs a persistence unit on an embedded database this module does not claim, and fails against the unfixed code with:

expected: <st.orm.core.spi.DefaultSqlDialect> but was: <st.orm.spi.mysql.MySQLSqlDialect>

It sets spring.sql.init.mode=never, because this module's SQL fixtures are written for MySQL and the persistence unit deliberately runs on another database.

Full reactor with all four fixes: BUILD SUCCESS, 7238 tests, no failures or errors.

Every dialect resolution point that has a database in reach now asks it.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (1)

storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java:139

  • SchemaValidator.of(DataSource) now resolves the dialect by calling Providers.getDatabaseProductName(dataSource), which opens a connection and throws a PersistenceException if the DataSource cannot provide one. That makes validator construction fail eagerly in scenarios where the database is temporarily unreachable or the DataSource is lazily initialized; previously it could still be constructed (dialect resolved from classpath) and fail later during actual validation.

Consider catching the runtime exception and falling back to classpath-based dialect resolution when product-name detection fails, so validation can still be invoked (and fail in the usual place) instead of failing at factory time.

    private static SqlDialect resolveSqlDialect(@Nonnull DataSource dataSource) {
        StormConfig config = StormConfig.defaults();
        SqlDialectProvider provider = getSqlDialectProvider(getDatabaseProductName(dataSource));
        return provider != null ? provider.getSqlDialect(config) : getSqlDialect(config);

@zantvoort
zantvoort merged commit 307a27e into main Aug 2, 2026
8 checks passed
@zantvoort
zantvoort deleted the fix/schema-validator-dialect-resolution branch August 2, 2026 12:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Schema validation picks the first dialect instead of the one matching the database

2 participants