diff --git a/build.gradle.kts b/build.gradle.kts index 891550c..bf7723b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,19 +17,16 @@ import net.ltgt.gradle.errorprone.errorprone plugins { - // Apply the java-library plugin for API and implementation separation. `java-library` alias(libs.plugins.spotless) alias(libs.plugins.errorprone) } repositories { - // Use Maven Central for resolving dependencies. mavenCentral() } dependencies { - // Use JUnit Jupiter for testing. testImplementation(libs.junit.jupiter) testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -38,9 +35,11 @@ dependencies { api(libs.jspecify) errorprone(libs.google.errorprone.core) + + implementation(libs.hibernate.core) + implementation(libs.mongo.java.driver.sync) } -// Apply a specific Java toolchain to ease working on different environments. java { toolchain { languageVersion = JavaLanguageVersion.of(17) @@ -48,7 +47,6 @@ java { } tasks.named("test") { - // Use JUnit Platform for unit tests. useJUnitPlatform() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fe1218a..592f17d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,12 +9,16 @@ errorprone = "4.0.1" google-errorprone-core = "2.9.0" nullaway = "0.10.26" jspecify = "0.3.0" +hibernate-core = "6.6.1.Final" +mongo-java-driver-sync = "5.2.0" [libraries] junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } nullaway = { module = "com.uber.nullaway:nullaway", version.ref = "nullaway" } jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" } google-errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "google-errorprone-core" } +hibernate-core = { module = "org.hibernate.orm:hibernate-core", version.ref = "hibernate-core" } +mongo-java-driver-sync = { module = "org.mongodb:mongodb-driver-sync", version.ref = "mongo-java-driver-sync" } [plugins] spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/src/main/java/com/mongodb/hibernate/cfg/ConfigurationHelper.java b/src/main/java/com/mongodb/hibernate/cfg/ConfigurationHelper.java new file mode 100644 index 0000000..c6f3c9c --- /dev/null +++ b/src/main/java/com/mongodb/hibernate/cfg/ConfigurationHelper.java @@ -0,0 +1,49 @@ +/* + * Copyright 2024-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.hibernate.cfg; + +import static org.hibernate.internal.util.NullnessUtil.castNonNull; + +import com.mongodb.hibernate.exception.ConfigurationException; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** Collection of helper methods for dealing with configuration settings. */ +public final class ConfigurationHelper { + + private ConfigurationHelper() {} + + public static String getRequiredConfiguration(Map configurationValues, String property) { + return castNonNull(doGetConfiguration(configurationValues, property, true)); + } + + public static @Nullable String getOptionalConfiguration(Map configurationValues, String property) { + return doGetConfiguration(configurationValues, property, false); + } + + private static @Nullable String doGetConfiguration( + Map configurationValues, String property, boolean required) { + var configuration = configurationValues.get(property); + if (configuration == null && required) { + throw new ConfigurationException(property, "value required"); + } + if (configuration != null && !(configuration instanceof String)) { + throw new ConfigurationException(property, "value is not of string type"); + } + return (String) configuration; + } +} diff --git a/src/main/java/com/mongodb/hibernate/dialect/MongoDialect.java b/src/main/java/com/mongodb/hibernate/dialect/MongoDialect.java new file mode 100644 index 0000000..c478a4d --- /dev/null +++ b/src/main/java/com/mongodb/hibernate/dialect/MongoDialect.java @@ -0,0 +1,40 @@ +/* + * Copyright 2024-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.hibernate.dialect; + +import org.hibernate.dialect.DatabaseVersion; +import org.hibernate.dialect.Dialect; +import org.hibernate.engine.jdbc.dialect.spi.DialectResolutionInfo; + +public class MongoDialect extends Dialect { + public static final int MINIMUM_MONGODB_MAJOR_VERSION_SUPPORTED = 5; + + private static final DatabaseVersion MINIMUM_VERSION = + DatabaseVersion.make(MINIMUM_MONGODB_MAJOR_VERSION_SUPPORTED); + + public MongoDialect() { + this(MINIMUM_VERSION); + } + + public MongoDialect(final DatabaseVersion version) { + super(version); + } + + public MongoDialect(final DialectResolutionInfo dialectResolutionInfo) { + super(dialectResolutionInfo); + } +} diff --git a/src/main/java/com/mongodb/hibernate/exception/ConfigurationException.java b/src/main/java/com/mongodb/hibernate/exception/ConfigurationException.java new file mode 100644 index 0000000..3fd0fdf --- /dev/null +++ b/src/main/java/com/mongodb/hibernate/exception/ConfigurationException.java @@ -0,0 +1,37 @@ +/* + * Copyright 2024-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.hibernate.exception; + +public class ConfigurationException extends RuntimeException { + + private final String property; + + public ConfigurationException(String property, String message) { + super(message); + this.property = property; + } + + public ConfigurationException(String property, String message, Throwable cause) { + super(message, cause); + this.property = property; + } + + @Override + public String getMessage() { + return String.format("Invalid '%s' configuration: %s", property, super.getMessage()); + } +} diff --git a/src/main/java/com/mongodb/hibernate/exception/NotYetImplementedException.java b/src/main/java/com/mongodb/hibernate/exception/NotYetImplementedException.java new file mode 100644 index 0000000..5baedba --- /dev/null +++ b/src/main/java/com/mongodb/hibernate/exception/NotYetImplementedException.java @@ -0,0 +1,26 @@ +/* + * Copyright 2024-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.hibernate.exception; + +public class NotYetImplementedException extends RuntimeException { + + public NotYetImplementedException() {} + + public NotYetImplementedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/mongodb/hibernate/jdbc/MongoConnectionProvider.java b/src/main/java/com/mongodb/hibernate/jdbc/MongoConnectionProvider.java new file mode 100644 index 0000000..fa43ed0 --- /dev/null +++ b/src/main/java/com/mongodb/hibernate/jdbc/MongoConnectionProvider.java @@ -0,0 +1,149 @@ +/* + * Copyright 2024-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.hibernate.jdbc; + +import static org.bson.codecs.configuration.CodecRegistries.fromRegistries; +import static org.hibernate.cfg.JdbcSettings.*; +import static org.hibernate.internal.util.NullnessUtil.castNonNull; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.MongoCredential; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.hibernate.cfg.ConfigurationHelper; +import com.mongodb.hibernate.exception.ConfigurationException; +import com.mongodb.hibernate.exception.NotYetImplementedException; +import java.sql.Connection; +import java.util.Map; +import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider; +import org.hibernate.service.UnknownUnwrapTypeException; +import org.hibernate.service.spi.Configurable; +import org.hibernate.service.spi.Startable; +import org.hibernate.service.spi.Stoppable; +import org.jspecify.annotations.Nullable; + +/** + * MongoDB dialect's customized JDBC {@link ConnectionProvider} spi implementation, whose class name is supposed to be + * provided as the following Hibernate property to kick off MongoDB dialect's JDBC flow: + * + *
    + *
  • hibernate.connection.provider_class + *
+ * + *

The following Hibernate JDBC properties will be relied upon by Hibernate's {@link Configurable} spi mechanism: + * + *

    + *
  • jakarta.persistence.jdbc.url + *
  • jakarta.persistence.jdbc.user + *
  • jakarta.persistence.jdbc.password + *
+ * + *

jakarta.persistence.jdbc.url property is mandatory and it maps to MongoDB's {@link ConnectionString}, + * in which database name must be provided to align with JDBC URL's convention. The other two JDBC properties are + * optional. + * + * @see ConnectionProvider + * @see Configurable + */ +public class MongoConnectionProvider implements ConnectionProvider, Configurable, Startable, Stoppable { + + // non-null after configure(Map) method is invoked successfully + private @Nullable ConnectionString connectionString; + private @Nullable String database; + + // non-null after start() method is invoked successfully + private @Nullable MongoClient mongoClient; + + private @Nullable String user; + private @Nullable String password; + + @Override + public Connection getConnection() { + throw new NotYetImplementedException(); + } + + @Override + public void closeConnection(Connection connection) { + throw new NotYetImplementedException(); + } + + @Override + public boolean supportsAggressiveRelease() { + return false; // won't be used in container + } + + @Override + public boolean isUnwrappableAs(Class unwrapType) { + return ConnectionProvider.class.equals(unwrapType) + || MongoConnectionProvider.class.isAssignableFrom(unwrapType); + } + + @Override + public T unwrap(Class unwrapType) { + if (isUnwrappableAs(unwrapType)) { + return unwrapType.cast(this); + } else { + throw new UnknownUnwrapTypeException(unwrapType); + } + } + + @Override + public void configure(Map configurationValues) { + var jdbcUrl = ConfigurationHelper.getRequiredConfiguration(configurationValues, JAKARTA_JDBC_URL); + try { + this.connectionString = new ConnectionString(jdbcUrl); + } catch (IllegalArgumentException iae) { + throw new ConfigurationException(JAKARTA_JDBC_URL, "invalid MongoDB connection string", iae); + } + var database = this.connectionString.getDatabase(); + if (database == null) { + throw new ConfigurationException(JAKARTA_JDBC_URL, "database must be provided"); + } + this.database = database; + this.user = ConfigurationHelper.getOptionalConfiguration(configurationValues, JAKARTA_JDBC_USER); + this.password = ConfigurationHelper.getOptionalConfiguration(configurationValues, JAKARTA_JDBC_PASSWORD); + } + + @Override + public void start() { + // connectionString and database are set as mandatory values in the above configure method + // if either is unset, exception would have been thrown and this method invocation would have be skipped + castNonNull(this.connectionString); + castNonNull(this.database); + + var clientSettingsBuilder = MongoClientSettings.builder().applyConnectionString(connectionString); + if (this.user != null) { + var password = this.password == null ? null : this.password.toCharArray(); + var credential = MongoCredential.createCredential(this.user, this.database, password); + clientSettingsBuilder.credential(credential); + } + + var codecRegistry = fromRegistries(MongoClientSettings.getDefaultCodecRegistry()); + clientSettingsBuilder.codecRegistry(codecRegistry); + + var clientSettings = clientSettingsBuilder.build(); + this.mongoClient = MongoClients.create(clientSettings); + } + + @Override + public void stop() { + if (this.mongoClient != null) { + this.mongoClient.close(); + } + } +} diff --git a/src/main/java/com/mongodb/hibernate/package-info.java b/src/main/java/com/mongodb/hibernate/package-info.java new file mode 100644 index 0000000..bcc0257 --- /dev/null +++ b/src/main/java/com/mongodb/hibernate/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.mongodb.hibernate; + +import org.jspecify.annotations.NullMarked; diff --git a/src/test/java/com/mongodb/hibernate/jdbc/SessionFactoryTests.java b/src/test/java/com/mongodb/hibernate/jdbc/SessionFactoryTests.java new file mode 100644 index 0000000..18b469e --- /dev/null +++ b/src/test/java/com/mongodb/hibernate/jdbc/SessionFactoryTests.java @@ -0,0 +1,58 @@ +/* + * Copyright 2024-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.hibernate.jdbc; + +import static org.hibernate.cfg.JdbcSettings.JAKARTA_JDBC_URL; +import static org.junit.jupiter.api.Assertions.*; + +import com.mongodb.hibernate.exception.ConfigurationException; +import java.util.Map; +import org.hibernate.SessionFactory; +import org.hibernate.cfg.Configuration; +import org.hibernate.service.spi.ServiceException; +import org.junit.jupiter.api.Test; + +class SessionFactoryTests { + + @Test + void test_success() { + buildSessionFactory(Map.of(JAKARTA_JDBC_URL, "mongodb://localhost/test")); + } + + @Test + void test_invalid_connection_String() { + var exception = assertThrows( + ServiceException.class, + () -> buildSessionFactory(Map.of(JAKARTA_JDBC_URL, "jdbc:postgresql://localhost/test"))); + assertInstanceOf(ConfigurationException.class, exception.getCause()); + } + + @Test + void test_when_database_absent() { + var exception = assertThrows( + ServiceException.class, () -> buildSessionFactory(Map.of(JAKARTA_JDBC_URL, "mongodb://localhost"))); + assertInstanceOf(ConfigurationException.class, exception.getCause()); + } + + private void buildSessionFactory(Map configurationValues) throws ServiceException { + var cfg = new Configuration(); // default properties will be loaded from conventional resource + configurationValues.forEach(cfg::setProperty); // override + try (SessionFactory ignored = cfg.buildSessionFactory()) { + // no-op + } + } +} diff --git a/src/test/java/com/mongodb/hibernate/package-info.java b/src/test/java/com/mongodb/hibernate/package-info.java new file mode 100644 index 0000000..bcc0257 --- /dev/null +++ b/src/test/java/com/mongodb/hibernate/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.mongodb.hibernate; + +import org.jspecify.annotations.NullMarked; diff --git a/src/test/resources/hibernate.properties b/src/test/resources/hibernate.properties new file mode 100644 index 0000000..bd4bcdc --- /dev/null +++ b/src/test/resources/hibernate.properties @@ -0,0 +1,3 @@ +jakarta.persistence.jdbc.url=mongodb://localhost/test +hibernate.dialect=com.mongodb.hibernate.dialect.MongoDialect +hibernate.connection.provider_class=com.mongodb.hibernate.jdbc.MongoConnectionProvider