Repository: hibernate/hibernate-reactive (java)

Issue:
Allow (and test) offline startup + on-demand DB version checks
Hibernate ORM allows starting up without access to the DB ("offline") by specifying `hibernate.boot.allow_jdbc_metadata_access = false`. When using this setting, Hibernate ORM will not try to reach out to the DB on startup to determine the dialect/version. Instead, it will assume a dialect configured explicitly (e.g. inferred from `jakarta.persistence.database-product-name`), and either a default version (minimum supported one) or an explicitly configured one (through `jakarta.persistence.database-product-version`).

This feature is useful in particular for applications that start up before the DB becomes accessible. It can also be useful for Quarkus, where (part of) startup happens at build time.

When this feature is used, it can be useful to check the DB version matches the one that was explicitly configured. Quarkus executes such checks automatically.
However, in some cases, that DB version is not very easy to get, and might require vendor-specific "hacks". That's why Hibernate ORM 7+ exposes [`Dialect#determineDatabaseVersion`](https://github.com/hibernate/hibernate-orm/blob/ae5e5b23681108fc6772b332e5999038512ee8d5/hibernate-core/src/main/java/org/hibernate/dialect/Dialect.java#L344): it allows anyone to use the dialect of an already-started-up application to retrieve the version of the DB it's currently connected to, and then do any check they want.

We should check that these features work well in Hibernate Reactive. Copying `org.hibernate.orm.test.boot.database.metadata.MetadataAccessTests` into the Reactive test suite might be enough for that.

Related: https://github.com/quarkusio/quarkus/pull/43764

Pull request description:
Allow offline startup
Fix #2129

Patch:
diff --git a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/engine/jdbc/dialect/internal/ReactiveStandardDialectResolver.java b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/engine/jdbc/dialect/internal/ReactiveStandardDialectResolver.java
index 1b24e567..d12791e5 100644
--- a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/engine/jdbc/dialect/internal/ReactiveStandardDialectResolver.java
+++ b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/engine/jdbc/dialect/internal/ReactiveStandardDialectResolver.java
@@ -11,24 +11,22 @@
 import org.hibernate.engine.jdbc.dialect.spi.DialectResolutionInfo;
 import org.hibernate.engine.jdbc.dialect.spi.DialectResolver;
 
-import static org.hibernate.dialect.CockroachDialect.parseVersion;
 
 public class ReactiveStandardDialectResolver implements DialectResolver {
 
 	@Override
 	public Dialect resolveDialect(DialectResolutionInfo info) {
-		// Hibernate ORM runs an extra query to recognize CockroachDB from PostgreSQL
-		// We've already done it, so we are trying to skip that step
+		// Hibernate ORM runs an extra query to recognize CockroachDB from PostgresSQL
+		// We already did it when we created the DialectResolutionInfo in NoJdbcEnvironmentInitiator,
+		// so we can skip that step here.
 		if ( info.getDatabaseName().startsWith( "Cockroach" ) ) {
-			return new CockroachDialect( parseVersion( info.getDatabaseVersion() ) );
+			return new CockroachDialect( info );
 		}
-
 		for ( Database database : Database.values() ) {
 			if ( database.matchesResolutionInfo( info ) ) {
 				return database.createDialect( info );
 			}
 		}
-
 		return null;
 	}
 }
diff --git a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/provider/service/NoJdbcEnvironmentInitiator.java b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/provider/service/NoJdbcEnvironmentInitiator.java
index 953285dd..0cf59c41 100644
--- a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/provider/service/NoJdbcEnvironmentInitiator.java
+++ b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/provider/service/NoJdbcEnvironmentInitiator.java
@@ -5,25 +5,29 @@
  */
 package org.hibernate.reactive.provider.service;
 
-import java.util.Map;
-import java.util.concurrent.CompletionStage;
-
 import org.hibernate.boot.registry.StandardServiceInitiator;
 import org.hibernate.dialect.Dialect;
+import org.hibernate.engine.jdbc.connections.spi.DatabaseConnectionInfo;
 import org.hibernate.engine.jdbc.dialect.spi.DialectFactory;
 import org.hibernate.engine.jdbc.dialect.spi.DialectResolutionInfo;
 import org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentImpl;
+import org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator;
 import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment;
 import org.hibernate.engine.jdbc.spi.SqlExceptionHelper;
 import org.hibernate.reactive.pool.ReactiveConnection;
 import org.hibernate.reactive.pool.ReactiveConnectionPool;
-import org.hibernate.reactive.provider.Settings;
 import org.hibernate.reactive.util.impl.CompletionStages;
 import org.hibernate.service.ServiceRegistry;
 import org.hibernate.service.spi.ServiceRegistryImplementor;
 
 import io.vertx.sqlclient.spi.DatabaseMetadata;
+import java.util.Map;
+import java.util.StringTokenizer;
+import java.util.concurrent.CompletionStage;
+import java.util.function.Function;
 
+import static java.lang.Integer.parseInt;
+import static java.util.Objects.requireNonNullElse;
 import static java.util.function.Function.identity;
 import static org.hibernate.reactive.util.impl.CompletionStages.completedFuture;
 
@@ -32,7 +36,8 @@
  * that provides an implementation of {@link JdbcEnvironment} that infers
  * the Hibernate {@link org.hibernate.dialect.Dialect} from the JDBC URL.
  */
-public class NoJdbcEnvironmentInitiator implements StandardServiceInitiator<JdbcEnvironment> {
+public class NoJdbcEnvironmentInitiator extends JdbcEnvironmentInitiator
+		implements StandardServiceInitiator<JdbcEnvironment> {
 
 	public static final NoJdbcEnvironmentInitiator INSTANCE = new NoJdbcEnvironmentInitiator();
 
@@ -42,14 +47,59 @@ public Class<JdbcEnvironment> getServiceInitiated() {
 	}
 
 	@Override
-	public JdbcEnvironment initiateService(Map<String, Object> configurationValues, ServiceRegistryImplementor registry) {
-		boolean explicitDialect = configurationValues.containsKey( Settings.DIALECT );
-		if ( explicitDialect ) {
-			DialectFactory dialectFactory = registry.getService( DialectFactory.class );
-			return new JdbcEnvironmentImpl( registry, dialectFactory.buildDialect( configurationValues, null ) );
-		}
+	protected void logConnectionInfo(DatabaseConnectionInfo databaseConnectionInfo) {
+		// Nothing to do we log the connection info somewhere else
+	}
+
+	@Override
+	protected JdbcEnvironmentImpl getJdbcEnvironmentWithExplicitConfiguration(
+			Map<String, Object> configurationValues,
+			ServiceRegistryImplementor registry,
+			DialectFactory dialectFactory,
+			DialectResolutionInfo dialectResolutionInfo) {
+		return super.getJdbcEnvironmentWithExplicitConfiguration(
+				configurationValues,
+				registry,
+				dialectFactory,
+				dialectResolutionInfo
+		);
+	}
 
-		return new JdbcEnvironmentImpl( registry, new DialectBuilder( configurationValues, registry ).build() );
+	@Override
+	protected JdbcEnvironmentImpl getJdbcEnvironmentWithDefaults(
+			Map<String, Object> configurationValues,
+			ServiceRegistryImplementor registry,
+			DialectFactory dialectFactory) {
+		return new JdbcEnvironmentImpl( registry, new DialectBuilder( configurationValues, registry )
+				.build( dialectFactory )
+		);
+	}
+
+	@Override
+	protected JdbcEnvironmentImpl getJdbcEnvironmentUsingJdbcMetadata(
+			Map<String, Object> configurationValues,
+			ServiceRegistryImplementor registry,
+			DialectFactory dialectFactory,
+			String explicitDatabaseName,
+			Integer explicitDatabaseMajorVersion,
+			Integer explicitDatabaseMinorVersion,
+			String explicitDatabaseVersion) {
+		try {
+			final Dialect dialect = new DialectBuilder( configurationValues, registry )
+					.build(
+							dialectFactory,
+							new ExplicitMetadata(
+									explicitDatabaseName,
+									explicitDatabaseMajorVersion,
+									explicitDatabaseMinorVersion,
+									explicitDatabaseVersion
+							)
+					);
+			return new JdbcEnvironmentImpl( registry, dialect );
+		}
+		catch (RuntimeException e) {
+			return getJdbcEnvironmentWithDefaults( configurationValues, registry, dialectFactory );
+		}
 	}
 
 	private static class DialectBuilder {
@@ -62,24 +112,40 @@ public DialectBuilder(Map<String, Object> configurationValues, ServiceRegistry r
 			this.registry = registry;
 		}
 
-		public Dialect build() {
-			DialectFactory dialectFactory = registry.getService( DialectFactory.class );
+		public Dialect build(DialectFactory dialectFactory) {
 			return dialectFactory.buildDialect( configurationValues, this::dialectResolutionInfo );
 		}
 
+		public Dialect build(DialectFactory dialectFactory, ExplicitMetadata explicitMetadata) {
+			return dialectFactory.buildDialect( configurationValues, () -> dialectResolutionInfo( explicitMetadata ) );
+		}
+
 		private DialectResolutionInfo dialectResolutionInfo() {
-			ReactiveConnectionPool connectionPool = registry.getService( ReactiveConnectionPool.class );
-			return connectionPool
+			return dialectResolutionInfo( DialectBuilder::buildResolutionInfo );
+		}
+
+		private DialectResolutionInfo dialectResolutionInfo(ExplicitMetadata explicitMetadata) {
+			return dialectResolutionInfo( reactiveConnection -> DialectBuilder
+					.buildResolutionInfo( reactiveConnection, explicitMetadata )
+			);
+		}
+
+		private DialectResolutionInfo dialectResolutionInfo(Function<ReactiveConnection, CompletionStage<ReactiveDialectResolutionInfo>> dialectResolutionFunction) {
+			return registry
+					.getService( ReactiveConnectionPool.class )
 					// The default SqlExceptionHelper in ORM requires the dialect, but we haven't created a dialect yet,
 					// so we need to override it at this stage, or we will have an exception.
 					.getConnection( new SqlExceptionHelper( true ) )
-					.thenCompose( DialectBuilder::buildResolutionInfo )
+					.thenCompose( dialectResolutionFunction )
 					.toCompletableFuture().join();
 		}
 
 		private static CompletionStage<ReactiveDialectResolutionInfo> buildResolutionInfo(ReactiveConnection connection) {
-			final DatabaseMetadata databaseMetadata = connection.getDatabaseMetadata();
-			return resolutionInfoStage( connection, databaseMetadata )
+			return buildResolutionInfo( connection, null );
+		}
+
+		private static CompletionStage<ReactiveDialectResolutionInfo> buildResolutionInfo(ReactiveConnection connection, ExplicitMetadata explicitMetadata) {
+			return resolutionInfoStage( connection, explicitMetadata )
 					.handle( CompletionStages::handle )
 					.thenCompose( handled -> {
 						if ( handled.hasFailed() ) {
@@ -96,19 +162,27 @@ private static CompletionStage<ReactiveDialectResolutionInfo> buildResolutionInf
 					} );
 		}
 
-		private static CompletionStage<ReactiveDialectResolutionInfo> resolutionInfoStage(ReactiveConnection connection, DatabaseMetadata databaseMetadata) {
-			if ( databaseMetadata.productName().equalsIgnoreCase( "PostgreSQL" ) ) {
-				// We need to check if the database is PostgreSQL or CockroachDB
-				// Hibernate ORM does it using a query, so we need to check in advance
+		/**
+		 * @see org.hibernate.dialect.Database#POSTGRESQL for recognizing CockroachDB
+		 */
+		private static CompletionStage<ReactiveDialectResolutionInfo> resolutionInfoStage(ReactiveConnection connection, ExplicitMetadata explicitMetadata) {
+			final DatabaseMetadata databaseMetadata = explicitMetadata != null
+					? new ReactiveDatabaseMetadata( connection.getDatabaseMetadata(), explicitMetadata )
+					: connection.getDatabaseMetadata();
+
+			// If the product name is explicitly set to Postgres, we are not going to override it
+			if ( ( explicitMetadata == null || explicitMetadata.productName == null )
+					&& databaseMetadata.productName().equalsIgnoreCase( "PostgreSQL" ) ) {
+				// CockroachDB returns "PostgreSQL" as product name in the metadata.
+				// So, we need to check if the database is PostgreSQL or CockroachDB
+				// We follow the same approach used by ORM: run a new query and check the full version metadata
 				// See org.hibernate.dialect.Database.POSTGRESQL#createDialect
 				return connection.select( "select version()" )
 						.thenApply( DialectBuilder::readFullVersion )
-						.thenApply( fullversion -> {
-							if ( fullversion.startsWith( "Cockroach" ) ) {
-								return new CockroachDatabaseMetadata( fullversion );
-							}
-							return databaseMetadata;
-						} )
+						.thenApply( fullVersion -> fullVersion.startsWith( "Cockroach" )
+								? new ReactiveDatabaseMetadata( "Cockroach", databaseMetadata )
+								: databaseMetadata
+						)
 						.thenApply( ReactiveDialectResolutionInfo::new );
 			}
 
@@ -122,32 +196,62 @@ private static String readFullVersion(ReactiveConnection.Result result) {
 		}
 	}
 
-	private static class CockroachDatabaseMetadata implements DatabaseMetadata {
+	/**
+	 * Utility class to pass around explicit metadata properties.
+	 * It's different from {@link DatabaseMetadata} because values can be null.
+	 */
+	private static class ExplicitMetadata {
+		private final String productName;
+		private final String fullVersion;
+		private final Integer majorVersion;
+		private final Integer minorVersion;
+
+		public ExplicitMetadata(String explicitDatabaseName, Integer explicitDatabaseMajorVersion, Integer explicitDatabaseMinorVersion, String explicitDatabaseVersion ) {
+			this.productName = explicitDatabaseName;
+			this.fullVersion = explicitDatabaseVersion;
+			this.majorVersion = explicitDatabaseMajorVersion;
+			this.minorVersion = explicitDatabaseMinorVersion;
+		}
+	}
 
-		private final String fullversion;
+	private static class ReactiveDatabaseMetadata implements DatabaseMetadata {
+		public final String productName;
+		public final String fullVersion;
+		public final int majorVersion;
+		public final int minorVersion;
+
+		public ReactiveDatabaseMetadata(String productName, DatabaseMetadata databaseMetadata) {
+			this.productName = productName;
+			this.fullVersion = databaseMetadata.productName();
+			this.majorVersion = databaseMetadata.majorVersion();
+			this.minorVersion = databaseMetadata.minorVersion();
+		}
 
-		public CockroachDatabaseMetadata(String fullversion) {
-			this.fullversion = fullversion;
+		public ReactiveDatabaseMetadata(DatabaseMetadata metadata, ExplicitMetadata explicitMetadata) {
+			productName = requireNonNullElse( explicitMetadata.productName, metadata.productName() );
+			fullVersion = requireNonNullElse( explicitMetadata.fullVersion, metadata.fullVersion() );
+			majorVersion = requireNonNullElse( explicitMetadata.majorVersion, metadata.majorVersion() );
+			minorVersion = requireNonNullElse( explicitMetadata.minorVersion, metadata.minorVersion() );
 		}
 
 		@Override
 		public String productName() {
-			return "CockroachDb";
+			return productName;
 		}
 
 		@Override
 		public String fullVersion() {
-			return fullversion;
+			return fullVersion;
 		}
 
 		@Override
 		public int majorVersion() {
-			return 0;
+			return majorVersion;
 		}
 
 		@Override
 		public int minorVersion() {
-			return 0;
+			return minorVersion;
 		}
 	}
 
@@ -179,6 +283,27 @@ public int getDatabaseMinorVersion() {
 			return metadata.minorVersion();
 		}
 
+		@Override
+		public int getDatabaseMicroVersion() {
+			return databaseMicroVersion( metadata.fullVersion(), metadata.majorVersion(), metadata.minorVersion() );
+		}
+
+		// We should move this in ORM and avoid duplicated code
+		private static int databaseMicroVersion(String version, int major, int minor) {
+			final String prefix = major + "." + minor + ".";
+			if ( version.startsWith( prefix ) ) {
+				try {
+					final String substring = version.substring( prefix.length() );
+					final String micro = new StringTokenizer( substring, " .,-:;/()[]" ).nextToken();
+					return parseInt( micro );
+				}
+				catch (NumberFormatException nfe) {
+					return 0;
+				}
+			}
+			return 0;
+		}
+
 		@Override
 		public String getDriverName() {
 			return getDatabaseName();
@@ -196,6 +321,7 @@ public int getDriverMinorVersion() {
 
 		@Override
 		public String getSQLKeywords() {
+			// Vert.x metadata doesn't have this info
 			return null;
 		}
 


Test patch:
diff --git a/hibernate-reactive-core/src/test/java/org/hibernate/reactive/BaseReactiveTest.java b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/BaseReactiveTest.java
index 1a5f51e3..07a79379 100644
--- a/hibernate-reactive-core/src/test/java/org/hibernate/reactive/BaseReactiveTest.java
+++ b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/BaseReactiveTest.java
@@ -92,6 +92,18 @@ protected void setProperties(Configuration configuration) {
 		setDefaultProperties( configuration );
 	}
 
+	/**
+	 * Set the properties related to the SQL logs.
+	 * Some tests won't extend this class, but we still want to apply the same rules for these kinds of properties.
+	 */
+	public static void setSqlLoggingProperties(Configuration configuration) {
+		// Use JAVA_TOOL_OPTIONS='-Dhibernate.show_sql=true'
+		// Keep the default to false, otherwise the log on CI becomes too big
+		configuration.setProperty( Settings.SHOW_SQL, System.getProperty( Settings.SHOW_SQL, "false" ) );
+		configuration.setProperty( Settings.FORMAT_SQL, System.getProperty( Settings.FORMAT_SQL, "false" ) );
+		configuration.setProperty( Settings.HIGHLIGHT_SQL, System.getProperty( Settings.HIGHLIGHT_SQL, "true" ) );
+	}
+
 	/**
 	 * Configure default properties common to most tests.
 	 */
@@ -102,13 +114,9 @@ public static void setDefaultProperties(Configuration configuration) {
 			configuration.setProperty( Settings.HBM2DDL_IMPORT_FILES, "/db2.sql" );
 			doneTablespace = true;
 		}
-		// Use JAVA_TOOL_OPTIONS='-Dhibernate.show_sql=true'
-		// Keep the default to false, otherwise the log on CI becomes too big
-		configuration.setProperty( Settings.SHOW_SQL, System.getProperty( Settings.SHOW_SQL, "false" ) );
-		configuration.setProperty( Settings.FORMAT_SQL, System.getProperty( Settings.FORMAT_SQL, "false" ) );
-		configuration.setProperty( Settings.HIGHLIGHT_SQL, System.getProperty( Settings.HIGHLIGHT_SQL, "true" ) );
 		configuration.setProperty( PersistentTableStrategy.DROP_ID_TABLES, "true" );
 		configuration.setProperty( GlobalTemporaryTableStrategy.DROP_ID_TABLES, "true" );
+		setSqlLoggingProperties( configuration );
 	}
 
 	public static final SessionFactoryManager factoryManager = new SessionFactoryManager();
diff --git a/hibernate-reactive-core/src/test/java/org/hibernate/reactive/MetadataAccessTest.java b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/MetadataAccessTest.java
new file mode 100644
index 00000000..da157804
--- /dev/null
+++ b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/MetadataAccessTest.java
@@ -0,0 +1,260 @@
+/* Hibernate, Relational Persistence for Idiomatic Java
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright: Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.reactive;
+
+import org.hibernate.HibernateException;
+import org.hibernate.boot.registry.StandardServiceInitiator;
+import org.hibernate.boot.registry.StandardServiceRegistry;
+import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
+import org.hibernate.cfg.Configuration;
+import org.hibernate.dialect.DatabaseVersion;
+import org.hibernate.dialect.Dialect;
+import org.hibernate.engine.jdbc.dialect.internal.DialectFactoryImpl;
+import org.hibernate.engine.jdbc.dialect.spi.DialectFactory;
+import org.hibernate.engine.jdbc.dialect.spi.DialectResolutionInfo;
+import org.hibernate.engine.jdbc.dialect.spi.DialectResolutionInfoSource;
+import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment;
+import org.hibernate.reactive.containers.DatabaseConfiguration;
+import org.hibernate.reactive.provider.ReactiveServiceRegistryBuilder;
+import org.hibernate.reactive.provider.Settings;
+import org.hibernate.reactive.testing.SqlStatementTracker;
+import org.hibernate.service.spi.ServiceRegistryImplementor;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.Map;
+import java.util.Properties;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hibernate.cfg.JdbcSettings.ALLOW_METADATA_ON_BOOT;
+import static org.hibernate.cfg.JdbcSettings.DIALECT;
+import static org.hibernate.cfg.JdbcSettings.JAKARTA_HBM2DDL_DB_NAME;
+import static org.hibernate.cfg.JdbcSettings.JAKARTA_JDBC_URL;
+import static org.hibernate.reactive.BaseReactiveTest.setSqlLoggingProperties;
+import static org.hibernate.reactive.containers.DatabaseConfiguration.dbType;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+/**
+ * Hibernate ORM allows starting up without access to the DB ("offline")
+ * when {@link Settings#ALLOW_METADATA_ON_BOOT} is set to false.
+ * <p>
+ * Inspired by the test
+ * {@code org.hibernate.orm.test.boot.database.metadata.MetadataAccessTests}
+ * in Hibernate ORM.
+ * </p>
+ */
+public class MetadataAccessTest {
+
+	private static SqlStatementTracker sqlTracker;
+
+	private static final int EXPECTED_MAJOR = 123;
+	private static final int EXPECTED_MINOR = 456;
+	private static final DatabaseVersion EXPECTED_VERSION = DatabaseVersion.make( EXPECTED_MAJOR, EXPECTED_MINOR );
+
+	private static Properties dialectMajorMinorProperties() {
+		Properties dbProperties = new Properties();
+		// Major and Minor should override the full version, so we keep them different
+		dbProperties.setProperty( Settings.DIALECT_DB_MAJOR_VERSION, String.valueOf( EXPECTED_MAJOR ) );
+		dbProperties.setProperty( Settings.DIALECT_DB_MINOR_VERSION, String.valueOf( EXPECTED_MINOR ) );
+		return dbProperties;
+	}
+
+	private static Properties jakartaMajorMinorProperties() {
+		Properties dbProperties = new Properties();
+		dbProperties.setProperty( Settings.JAKARTA_HBM2DDL_DB_MAJOR_VERSION, String.valueOf( EXPECTED_MAJOR ) );
+		dbProperties.setProperty( Settings.JAKARTA_HBM2DDL_DB_MINOR_VERSION, String.valueOf( EXPECTED_MINOR ) );
+		return dbProperties;
+	}
+
+	private static Properties jakartaFullDbVersion() {
+		Properties dbProperties = new Properties();
+		dbProperties.setProperty( Settings.JAKARTA_HBM2DDL_DB_VERSION, EXPECTED_MAJOR + "." + EXPECTED_MINOR );
+		return dbProperties;
+	}
+
+	private static Properties dialectFullDbVersion() {
+		Properties dbProperties = new Properties();
+		dbProperties.setProperty( Settings.DIALECT_DB_VERSION, EXPECTED_MAJOR + "." + EXPECTED_MINOR );
+		return dbProperties;
+	}
+
+	static Stream<Arguments> explicitVersionProperties() {
+		return Stream.of(
+				arguments( "Jakarta properties", jakartaMajorMinorProperties() ),
+				arguments( "Deprecated dialect properties", dialectMajorMinorProperties() ),
+				arguments( "Jakarta db version property", jakartaFullDbVersion() ),
+				arguments( "Deprecated dialect db version property", dialectFullDbVersion() )
+		);
+	}
+
+	@ParameterizedTest(name = "Test {0} with " + DIALECT)
+	@MethodSource("explicitVersionProperties")
+	public void testExplicitVersionWithDialect(String display, Properties dbProperties) {
+		dbProperties.setProperty( ALLOW_METADATA_ON_BOOT, "false" );
+		dbProperties.setProperty( DIALECT, dbType().getDialectClass().getName() );
+
+		try (StandardServiceRegistry serviceRegistry = createServiceRegistry( dbProperties )) {
+			final Dialect dialect = dialect( serviceRegistry );
+			assertThat( dialect ).isInstanceOf( dbType().getDialectClass() );
+			assertThat( dialect.getVersion() ).isEqualTo( EXPECTED_VERSION );
+		}
+
+		assertThat( sqlTracker.getLoggedQueries() )
+				.as( "No query should be executed at start up" )
+				.isEmpty();
+	}
+
+	@ParameterizedTest(name = "Test {0} with " + JAKARTA_HBM2DDL_DB_NAME)
+	@MethodSource("explicitVersionProperties")
+	public void testExplicitVersionWithJakartaDbName(String display, Properties dbProperties) {
+		dbProperties.setProperty( ALLOW_METADATA_ON_BOOT, "false" );
+		dbProperties.setProperty( JAKARTA_HBM2DDL_DB_NAME, dbType().getProductName() );
+
+		try (StandardServiceRegistry serviceRegistry = createServiceRegistry( dbProperties )) {
+			final Dialect dialect = dialect( serviceRegistry );
+			assertThat( dialect ).isInstanceOf( dbType().getDialectClass() );
+			assertThat( dialect.getVersion() ).isEqualTo( EXPECTED_VERSION );
+		}
+
+		assertThat( sqlTracker.getLoggedQueries() )
+				.as( "No query should be executed at start up" )
+				.isEmpty();
+	}
+
+	@Test
+	public void testMinimumDatabaseVersionWithDialect() {
+		final Properties dbProperties = new Properties();
+		dbProperties.setProperty( ALLOW_METADATA_ON_BOOT, "false" );
+		dbProperties.setProperty( DIALECT, dbType().getDialectClass().getName() );
+
+		try (StandardServiceRegistry serviceRegistry = createServiceRegistry( dbProperties )) {
+			final Dialect dialect = dialect( serviceRegistry );
+			assertThat( dialect ).isInstanceOf( dbType().getDialectClass() );
+			assertThat( dialect.getVersion() ).isEqualTo( dbType().getMinimumVersion() );
+		}
+
+		assertThat( sqlTracker.getLoggedQueries() )
+				.as( "No query should be executed at start up" )
+				.isEmpty();
+	}
+
+	@Test
+	public void testMinimumDatabaseVersionWithJakartaDbName() {
+		final Properties dbProperties = new Properties();
+		dbProperties.setProperty( ALLOW_METADATA_ON_BOOT, "false" );
+		dbProperties.setProperty( JAKARTA_HBM2DDL_DB_NAME, dbType().getProductName() );
+
+		try (StandardServiceRegistry serviceRegistry = createServiceRegistry( dbProperties )) {
+			final Dialect dialect = dialect( serviceRegistry );
+			assertThat( dialect ).isInstanceOf( dbType().getDialectClass() );
+			assertThat( dialect.getVersion() ).isEqualTo( dbType().getMinimumVersion() );
+		}
+
+		assertThat( sqlTracker.getLoggedQueries() )
+				.as( "No query should be executed at start up" )
+				.isEmpty();
+	}
+
+	@Test
+	public void testDeterminedVersion() {
+		final Properties disabledProperties = new Properties();
+		disabledProperties.setProperty( ALLOW_METADATA_ON_BOOT, "false" );
+		disabledProperties.setProperty( DIALECT, dbType().getDialectClass().getName() );
+		// The dialect when ALLOW_METADATA_ON_BOOT si set to false
+		final Dialect metadataDisabledDialect;
+		try (StandardServiceRegistry serviceRegistry = createServiceRegistry( disabledProperties )) {
+			metadataDisabledDialect = dialect( serviceRegistry );
+			// We didn't set the version anywhere else, so we expect it to be the minimum version
+			assertThat( metadataDisabledDialect.getVersion() ).isEqualTo( dbType().getMinimumVersion() );
+		}
+
+		assertThat( sqlTracker.getLoggedQueries() )
+				.as( "No query should be executed at start up" )
+				.isEmpty();
+
+		final Properties enabledProperties = new Properties();
+		enabledProperties.setProperty( ALLOW_METADATA_ON_BOOT, "true" );
+		enabledProperties.setProperty( JAKARTA_JDBC_URL, DatabaseConfiguration.getJdbcUrl() );
+		try (StandardServiceRegistry serviceRegistry = createServiceRegistry( enabledProperties )) {
+			final Dialect metadataEnabledDialect = dialect( serviceRegistry );
+
+			// We expect determineDatabaseVersion(), when called on metadataAccessDisabledDialect,
+			// to return the version that would have been returned,
+			// had we booted up with auto-detection of version (metadata access allowed).
+			DatabaseVersion determinedDatabaseVersion = metadataDisabledDialect
+					.determineDatabaseVersion( dialectResolutionInfo( serviceRegistry ) );
+
+			// Whatever the version, we don't expect the minimum one
+			assertThat( determinedDatabaseVersion ).isNotEqualTo( dbType().getMinimumVersion() );
+
+			assertThat( determinedDatabaseVersion ).isEqualTo( metadataEnabledDialect.getVersion() );
+			assertThat( determinedDatabaseVersion.getMajor() ).isEqualTo( metadataEnabledDialect.getVersion().getMajor() );
+			assertThat( determinedDatabaseVersion.getMinor() ).isEqualTo( metadataEnabledDialect.getVersion().getMinor() );
+			assertThat( determinedDatabaseVersion.getMicro() ).isEqualTo( metadataEnabledDialect.getVersion().getMicro() );
+		}
+	}
+
+	private Configuration constructConfiguration(Properties properties) {
+		Configuration configuration = new Configuration();
+		setSqlLoggingProperties( configuration );
+		configuration.addProperties( properties );
+
+		// Construct a tracker that collects query statements via the SqlStatementLogger framework.
+		// Pass in configuration properties to hand off any actual logging properties
+		sqlTracker = new SqlStatementTracker( s -> true, configuration.getProperties() );
+		return configuration;
+	}
+
+	private StandardServiceRegistry createServiceRegistry(Properties properties) {
+		Configuration configuration = constructConfiguration( properties );
+		StandardServiceRegistryBuilder builder = new ReactiveServiceRegistryBuilder();
+		// We will set these properties when needed
+		assertThat( builder.getSettings() ).doesNotContainKeys( DIALECT, JAKARTA_HBM2DDL_DB_NAME );
+		builder.applySettings( configuration.getProperties() );
+
+		builder.addInitiator( new CapturingDialectFactory.Initiator() );
+		sqlTracker.registerService( builder );
+		return builder.enableAutoClose().build();
+	}
+
+	private static Dialect dialect(StandardServiceRegistry registry) {
+		return registry.getService( JdbcEnvironment.class ).getDialect();
+	}
+
+	private static DialectResolutionInfo dialectResolutionInfo(StandardServiceRegistry registry) {
+		return ( (CapturingDialectFactory) registry.getService( DialectFactory.class ) )
+				.capturedDialectResolutionInfoSource.getDialectResolutionInfo();
+	}
+
+	// A hack to easily retrieve DialectResolutionInfo exactly as it would be constructed by Hibernate ORM
+	private static class CapturingDialectFactory extends DialectFactoryImpl {
+
+		static class Initiator implements StandardServiceInitiator<DialectFactory> {
+			@Override
+			public Class<DialectFactory> getServiceInitiated() {
+				return DialectFactory.class;
+			}
+
+			@Override
+			public DialectFactory initiateService(Map<String, Object> configurationValues, ServiceRegistryImplementor registry) {
+				return new CapturingDialectFactory();
+			}
+		}
+
+		DialectResolutionInfoSource capturedDialectResolutionInfoSource;
+
+		@Override
+		public Dialect buildDialect(Map<String, Object> configValues, DialectResolutionInfoSource resolutionInfoSource)
+				throws HibernateException {
+			this.capturedDialectResolutionInfoSource = resolutionInfoSource;
+			return super.buildDialect( configValues, resolutionInfoSource );
+		}
+	}
+}
diff --git a/hibernate-reactive-core/src/test/java/org/hibernate/reactive/StandAloneReactiveTest.java b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/StandAloneReactiveTest.java
deleted file mode 100644
index 29ef9de8..00000000
--- a/hibernate-reactive-core/src/test/java/org/hibernate/reactive/StandAloneReactiveTest.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/* Hibernate, Relational Persistence for Idiomatic Java
- *
- * SPDX-License-Identifier: Apache-2.0
- * Copyright: Red Hat Inc. and Hibernate Authors
- */
-package org.hibernate.reactive;
-
-import org.hibernate.boot.MetadataSources;
-import org.hibernate.boot.registry.StandardServiceRegistry;
-import org.hibernate.dialect.PostgreSQLDialect;
-import org.hibernate.reactive.provider.Settings;
-import org.hibernate.reactive.provider.ReactiveServiceRegistryBuilder;
-import org.hibernate.reactive.stage.Stage;
-import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-public class StandAloneReactiveTest {
-
-	@Test
-	public void createReactiveSessionFactory() {
-		StandardServiceRegistry registry = new ReactiveServiceRegistryBuilder()
-				.applySetting( Settings.TRANSACTION_COORDINATOR_STRATEGY, "jta" )
-				.applySetting( Settings.DIALECT, PostgreSQLDialect.class.getName() )
-				.applySetting( Settings.URL, "jdbc:postgresql://localhost/hreact?user=none" )
-				.build();
-
-		Stage.SessionFactory factory = new MetadataSources( registry )
-				.buildMetadata()
-				.getSessionFactoryBuilder()
-				.build()
-				.unwrap( Stage.SessionFactory.class );
-
-		assertThat( factory ).isNotNull();
-	}
-}
diff --git a/hibernate-reactive-core/src/test/java/org/hibernate/reactive/containers/DatabaseConfiguration.java b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/containers/DatabaseConfiguration.java
index f0d497ee..c0a08112 100644
--- a/hibernate-reactive-core/src/test/java/org/hibernate/reactive/containers/DatabaseConfiguration.java
+++ b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/containers/DatabaseConfiguration.java
@@ -5,6 +5,7 @@
  */
 package org.hibernate.reactive.containers;
 
+import java.lang.reflect.Field;
 import java.util.Arrays;
 import java.util.Map;
 import java.util.Objects;
@@ -12,6 +13,7 @@
 
 import org.hibernate.dialect.CockroachDialect;
 import org.hibernate.dialect.DB2Dialect;
+import org.hibernate.dialect.DatabaseVersion;
 import org.hibernate.dialect.Dialect;
 import org.hibernate.dialect.MariaDBDialect;
 import org.hibernate.dialect.MySQLDialect;
@@ -28,15 +30,16 @@ public class DatabaseConfiguration {
 	public static final boolean USE_DOCKER = Boolean.getBoolean("docker");
 
 	public enum DBType {
-		DB2( DB2Database.INSTANCE, 50000, "com.ibm.db2.jcc.DB2Driver", DB2Dialect.class ),
-		MYSQL( MySQLDatabase.INSTANCE, 3306, "com.mysql.cj.jdbc.Driver", MySQLDialect.class ),
-		MARIA( MariaDatabase.INSTANCE, 3306, "org.mariadb.jdbc.Driver", MariaDBDialect.class, "mariadb" ),
-		POSTGRESQL( PostgreSQLDatabase.INSTANCE, 5432, "org.postgresql.Driver", PostgreSQLDialect.class, "POSTGRES", "PG" ),
-		COCKROACHDB( CockroachDBDatabase.INSTANCE, 26257, "org.postgresql.Driver", CockroachDialect.class, "COCKROACH" ),
-		SQLSERVER( MSSQLServerDatabase.INSTANCE, 1433, "com.microsoft.sqlserver.jdbc.SQLServerDriver", SQLServerDialect.class, "MSSQL", "MSSQLSERVER" ),
-		ORACLE( OracleDatabase.INSTANCE, 1521, "oracle.jdbc.OracleDriver", OracleDialect.class );
+		DB2( DB2Database.INSTANCE, "DB2", 50000, "com.ibm.db2.jcc.DB2Driver", DB2Dialect.class ),
+		MYSQL( MySQLDatabase.INSTANCE, "MySQL",3306, "com.mysql.cj.jdbc.Driver", MySQLDialect.class ),
+		MARIA( MariaDatabase.INSTANCE, "MariaDB",3306, "org.mariadb.jdbc.Driver", MariaDBDialect.class, "mariadb" ),
+		POSTGRESQL( PostgreSQLDatabase.INSTANCE, "PostgreSQL", 5432, "org.postgresql.Driver", PostgreSQLDialect.class, "POSTGRES", "PG" ),
+		COCKROACHDB( CockroachDBDatabase.INSTANCE, "CockroachDb", 26257, "org.postgresql.Driver", CockroachDialect.class, "COCKROACH" ),
+		SQLSERVER( MSSQLServerDatabase.INSTANCE, "Microsoft SQL Server", 1433, "com.microsoft.sqlserver.jdbc.SQLServerDriver", SQLServerDialect.class, "MSSQL", "MSSQLSERVER" ),
+		ORACLE( OracleDatabase.INSTANCE, "Oracle", 1521, "oracle.jdbc.OracleDriver", OracleDialect.class );
 
 		private final TestableDatabase configuration;
+		private final String productName;
 		private final int defaultPort;
 
 		// A list of alternative names that can be used to select the db
@@ -47,8 +50,9 @@ public enum DBType {
 
 		private final Class<? extends Dialect> dialect;
 
-		DBType(TestableDatabase configuration, int defaultPort, String jdbcDriver, Class<? extends Dialect> dialect, String... aliases) {
+		DBType(TestableDatabase configuration, String productName, int defaultPort, String jdbcDriver, Class<? extends Dialect> dialect, String... aliases) {
 			this.configuration = configuration;
+			this.productName = productName;
 			this.defaultPort = defaultPort;
 			this.aliases = aliases;
 			this.dialect = dialect;
@@ -81,6 +85,10 @@ public static DBType fromString(String dbName) {
 					.toString( DBType.values() ) );
 		}
 
+		public String getProductName() {
+			return productName;
+		}
+
 		public int getDefaultPort() {
 			return defaultPort;
 		}
@@ -92,6 +100,24 @@ public String getJdbcDriver() {
 		public Class<? extends Dialect> getDialectClass() {
 			return dialect;
 		}
+
+		/**
+		 * The minimum version of the database supported by the dialect.
+		 * <p>
+		 * We use reflection because it's not accessible from the tests.
+		 * Copied from MetadataAccessTests in Hibernate ORM.
+		 * </p>
+		 */
+		public DatabaseVersion getMinimumVersion() {
+			try {
+				Field field = dialect.getDeclaredField( "MINIMUM_VERSION" );
+				field.setAccessible( true );
+				return (DatabaseVersion) field.get( null );
+			}
+			catch (IllegalAccessException | NoSuchFieldException e) {
+				throw new RuntimeException( "Error extracting 'MINIMUM_VERSION' from '" + dialect + "'", e );
+			}
+		}
 	}
 
 	public static final String USERNAME = "hreact";
@@ -101,7 +127,7 @@ public Class<? extends Dialect> getDialectClass() {
 	private static DBType dbType;
 
 	public static DBType dbType() {
-		if (dbType == null) {
+		if ( dbType == null ) {
 			String dbTypeString = System.getProperty( "db", DBType.POSTGRESQL.name() );
 			dbType = DBType.fromString( dbTypeString );
 			System.out.println( "Using database type: " + dbType.name() );
@@ -131,5 +157,4 @@ public static String expectedDatatype(Class<?> dataType) {
 
 	private DatabaseConfiguration() {
 	}
-
 }