Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Design, implement and document how an application can configure the product #31

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,13 @@ buildConfig {

dependencies {
testImplementation(libs.junit.jupiter)
testImplementation(libs.assertj)
testImplementation(libs.logback.classic)
testImplementation(libs.mockito.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher)

integrationTestImplementation(libs.junit.jupiter)
integrationTestImplementation(libs.assertj)
integrationTestImplementation(libs.logback.classic)
integrationTestRuntimeOnly(libs.junit.platform.launcher)

Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

[versions]
junit-jupiter = "5.11.4"
assertj = "3.27.3"
spotless = "7.0.2"
palantir = "2.50.0"
errorprone = "4.1.0"
Expand All @@ -19,6 +20,7 @@ buildconfig = "5.5.1"
[libraries]
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" }
NathanQingyangXu marked this conversation as resolved.
Show resolved Hide resolved
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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

package com.mongodb.hibernate;

import static org.hibernate.cfg.JdbcSettings.JAKARTA_JDBC_URL;
import static org.hibernate.cfg.AvailableSettings.JAKARTA_JDBC_URL;
NathanQingyangXu marked this conversation as resolved.
Show resolved Hide resolved
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
Expand Down
2 changes: 1 addition & 1 deletion src/integrationTest/resources/hibernate.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
jakarta.persistence.jdbc.url=mongodb://localhost/mongo-hibernate-test?directConnection=false
hibernate.dialect=com.mongodb.hibernate.dialect.MongoDialect
hibernate.connection.provider_class=com.mongodb.hibernate.jdbc.MongoConnectionProvider
jakarta.persistence.jdbc.url=mongodb://localhost/mongo-hibernate-test?directConnection=false
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,21 @@

package com.mongodb.hibernate.dialect;

import com.mongodb.hibernate.jdbc.MongoConnectionProvider;
import org.hibernate.dialect.DatabaseVersion;
import org.hibernate.dialect.Dialect;
import org.hibernate.engine.jdbc.dialect.spi.DialectResolutionInfo;

/**
* A MongoDB {@link Dialect} for {@linkplain #getMinimumSupportedVersion() version 6.0 and above}.
* A MongoDB {@link Dialect} for {@linkplain #getMinimumSupportedVersion() version 6.0 and above}. Must be used together
* with {@link MongoConnectionProvider}.
Copy link
Contributor

Choose a reason for hiding this comment

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

not sure why we need to emphasize the combination. To me the two classes are not tightly coupled. Traditionally Dialect is used to customize programmatically (as opposed to hibernate.properties config file). ConnectionProvider customization is in the scope of properties file without corresponding Dialect method to be overridden.

But it is ok to retain it. Now I think your point is the two mandatory entries in hibernate.properties:

hibernate.dialect=com.mongodb.hibernate.dialect.MongoDialect
hibernate.connection.provider_class=com.mongodb.hibernate.jdbc.MongoConnectionProvider

*
* <p>Usually Hibernate dialect represents some SQL RDBMS and speaks SQL with vendor-specific difference. MongoDB is a
* document DB and speaks <i>MQL</i> (MongoDB Query Language), but it is still possible to integrate with Hibernate by
* creating a JDBC adaptor on top of <a href="https://www.mongodb.com/docs/drivers/java/sync/current/">MongoDB Java
* Driver</a>.
*
* @see MongoDialectSettings
*/
public final class MongoDialect extends Dialect {
private static final DatabaseVersion MINIMUM_VERSION = DatabaseVersion.make(6);
Expand Down
293 changes: 293 additions & 0 deletions src/main/java/com/mongodb/hibernate/dialect/MongoDialectSettings.java
Copy link
Member Author

Choose a reason for hiding this comment

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

If #27 is merged before this PR, then this PR should replace the usage of new Configuration() in BasicInsertionTests with the usage of MongoDialectSettings.

Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
/*
* 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 static com.mongodb.hibernate.internal.MongoChecks.notNull;
import static java.lang.String.format;
import static org.hibernate.cfg.AvailableSettings.AUTOCOMMIT;
import static org.hibernate.cfg.AvailableSettings.JAKARTA_JDBC_URL;

import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
import com.mongodb.client.MongoClient;
import com.mongodb.hibernate.jdbc.MongoConnectionProvider;
import com.mongodb.hibernate.service.MongoDialectConfigurator;
import java.lang.reflect.Type;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.util.Arrays;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.service.spi.Configurable;
import org.jspecify.annotations.Nullable;

/**
* The configuration of {@link MongoDialect}, {@link MongoConnectionProvider}, the internal {@link MongoClient}.
*
* <table>
* <caption>Supported configuration properties</caption>
* <thead>
* <tr>
* <th>Method</th>
* <th>Has default</th>
* <th>Related {@linkplain Configurable#configure(Map) configuration property} name</th>
* <th>Supported value types of the configuration property</th>
* <th>Value, unless overridden via {@link Builder}</th>
* </tr>
* </thead>
* <tbody>
* <tr>
* <td>{@link #getMongoClientSettings()}</td>
* <td>✓</td>
* <td>{@value AvailableSettings#JAKARTA_JDBC_URL}</td>
* <td>
* <ul>
* <li>{@link String}</li>
* <li>{@link ConnectionString}</li>
* </ul>
* </td>
* <td>Is {@linkplain MongoClientSettings.Builder#applyConnectionString(ConnectionString) based} on
* the {@link ConnectionString} {@linkplain ConnectionString#ConnectionString(String) constructed} from
* {@value AvailableSettings#JAKARTA_JDBC_URL}, if the latter is configured; otherwise a {@link MongoClientSettings}
* instance with its defaults.</td>
* </tr>
* <tr>
* <td>{@link #getDatabaseName()}</td>
* <td>✗</td>
* <td>{@value AvailableSettings#JAKARTA_JDBC_URL}</td>
* <td>
* <ul>
* <li>{@link String}</li>
* <li>{@link ConnectionString}</li>
* </ul>
* </td>
* <td>The MongoDB database name from {@value AvailableSettings#JAKARTA_JDBC_URL},
* if {@linkplain ConnectionString#getDatabase() configured};
* otherwise there is no default value, and a value must be configured via {@link Builder#databaseName(String)}.</td>
* </tr>
* <tr>
* <td>{@link #isAutoCommit()}</td>
* <td>✓</td>
* <td>{@value AvailableSettings#AUTOCOMMIT}</td>
* <td>
* <ul>
* <li>{@link String}, only {@code "true"}, {@code "false"} are allowed</li>
* <li>{@link Boolean}</li>
* </ul>
* </td>
* <td>{@value AvailableSettings#AUTOCOMMIT}, if configured; otherwise {@code false}.</td>
* </tr>
* </tbody>
* </table>
*
* @see MongoDialectConfigurator
*/
public final class MongoDialectSettings {
Copy link
Member Author

Choose a reason for hiding this comment

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

This API is named and designed after MongoClientSettings. Given that it allows an application to access MongoClientSettings.Builder, this approach seems reasonable to me.

Copy link
Member Author

Choose a reason for hiding this comment

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

With the design as is, we don't need to expose neither MongoDialectSettings nor MongoDialectSettings.builder as part of our API. However, hiding them requires taking an approach different from that of the MongoClientSettings. I think, I am fine with the approach I propose in this PR (because I am trying to make MongoDialectSettings similar to MongoClientSettings here). But if there are concerns about exposing MongoDialectSettings, please bring them up.

private final MongoClientSettings mongoClientSettings;
private final String databaseName;
private final boolean autoCommit;
Copy link
Member Author

Choose a reason for hiding this comment

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

We may trivially support configuring an external MongoClient via MongoDialectSettings, as opposed to creating an internal one, enalbing applications to reuse clients.

Copy link
Contributor

@NathanQingyangXu NathanQingyangXu Feb 4, 2025

Choose a reason for hiding this comment

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

I am not sure autoCommit is a good candidate for it seems an open question now. In JDBC, regardless of whether commit is auto, commit happens all the time. Currently we only commit when autoCommit value is false (per MongoStatement implementation for now).

Copy link
Contributor

Choose a reason for hiding this comment

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

I doubt whether we need to include autoCommit() for it is internal Hibernate implememntatoin detail and its business logic might be complex that "we set a Connection's autoCommit status after it was created". That part might interfere with Hibernate's internal working. For instance, without our doing anything, the Connection would be set autoCommit as expected.


private MongoDialectSettings(Builder builder) {
this.mongoClientSettings = builder.mongoClientSettingsBuilder.build();
this.databaseName = notNull("databaseName", builder.databaseName);
this.autoCommit = builder.autoCommit;
}

/**
* Gets the {@link MongoClientSettings} to be used when creating the internal {@link MongoClient}.
*
* @return The {@link MongoClientSettings}.
* @see Builder#applyToMongoClientSettings(Consumer)
*/
public MongoClientSettings getMongoClientSettings() {
return mongoClientSettings;
}

/**
* Gets the name of a MongoDB database used as the {@linkplain Connection#getSchema() JDBC schema} of a
* {@linkplain Connection connection} {@linkplain MongoConnectionProvider#getConnection() obtained} from
* {@link MongoConnectionProvider}.
*
* @return The name of the default MongoDB database.
* @see Builder#databaseName(String)
* @see DatabaseMetaData#getSchemaTerm()
*/
public String getDatabaseName() {
return databaseName;
}

/**
* The {@linkplain Connection#getAutoCommit() auto-commit mode} of {@linkplain Connection connections}
* {@linkplain MongoConnectionProvider#getConnection() obtained} from {@link MongoConnectionProvider}.
*
* @return The default auto-commit mode.
* @see Builder#autoCommit(boolean)
*/
public boolean isAutoCommit() {
return autoCommit;
}

/**
* Creates a new {@link MongoDialectSettings.Builder} based on {@code configProperties}.
*
* @param configProperties The {@linkplain Configurable#configure(Map) configuration properties}.
* @return A new {@link MongoDialectSettings.Builder}.
*/
public static MongoDialectSettings.Builder builder(Map<String, Object> configProperties) {
return new MongoDialectSettings.Builder(notNull("configProperties", configProperties));
Copy link
Contributor

Choose a reason for hiding this comment

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

I think given configProperties is not marked as @Nullable, we could save the usage of notNull() and trust NullAway to cover our asses (in theory at least any usage inconsistent with the jspecify annotation would be treated as bug during compiling phase). This is one of improvements in this project compared to Java driver project.

}

/** A builder for {@code MongoDialectSettings}. */
public static final class Builder {
private final MongoClientSettings.Builder mongoClientSettingsBuilder;
private @Nullable String databaseName;
private boolean autoCommit;

private Builder(Map<String, Object> configProperties) {
Copy link
Contributor

Choose a reason for hiding this comment

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

In Hibernate codebase, usually the naming of the map is configurationValues or configValues for Hibernate does allow for ad-hoc value to be used as value, not restrictive to java Properties.

Copy link
Contributor

Choose a reason for hiding this comment

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

Here is an ad-hoc config usage in Hibernate ORM codebase: https://github.com/hibernate/hibernate-orm/blob/main/hibernate-core/src/main/java/org/hibernate/boot/cfgxml/internal/CfgXmlAccessServiceImpl.java

public class CfgXmlAccessServiceImpl implements CfgXmlAccessService {
	private final LoadedConfig aggregatedCfgXml;

	public CfgXmlAccessServiceImpl(Map<?,?> configurationValues) {
		aggregatedCfgXml = (LoadedConfig) configurationValues.get( LOADED_CONFIG_KEY );
	}

	@Override
	public LoadedConfig getAggregatedConfig() {
		return aggregatedCfgXml;
	}
}

configProperties is fine but it might mislead reader into thinking that it comes from hibernate.properties file exclusively. Actually it could be populated in ad-hoc way during Hibernate bootstrapping.

mongoClientSettingsBuilder = MongoClientSettings.builder();
var connectionString = ConfigPropertiesParser.getConnectionString(configProperties);
if (connectionString != null) {
mongoClientSettingsBuilder.applyConnectionString(connectionString);
databaseName = connectionString.getDatabase();
}
var autoCommit = ConfigPropertiesParser.getAutoCommit(configProperties);
if (autoCommit != null) {
this.autoCommit = autoCommit;
}
}

/**
* Configures {@link MongoDialectSettings}.
*
* <p>Note that if you {@link MongoClientSettings.Builder#applyConnectionString(ConnectionString)} with
* {@linkplain ConnectionString#getDatabase() database name} configured, you still must configure the database
* name via {@link Builder#databaseName(String)}, as there is no way for that to happen automatically.
NathanQingyangXu marked this conversation as resolved.
Show resolved Hide resolved
*
* @param configurator The {@link Consumer} of the {@link MongoClientSettings.Builder}.
* @return {@code this}.
* @see MongoDialectSettings#getMongoClientSettings()
*/
public MongoDialectSettings.Builder applyToMongoClientSettings(
final Consumer<MongoClientSettings.Builder> configurator) {
notNull("configurator", configurator).accept(mongoClientSettingsBuilder);
return this;
}

/**
* Sets the name of a MongoDB database used as the {@linkplain Connection#getSchema() JDBC schema} of a
* {@linkplain Connection connection} {@linkplain MongoConnectionProvider#getConnection() obtained} from
* {@link MongoConnectionProvider}.
Copy link
Contributor

Choose a reason for hiding this comment

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

I understand the introduction of JDBC schema is to pave the way for future ticket, but this linkage is confusing for me and seems not compelling. Our end user might simply wants a database name without understanding why JDBC schema is involved.

*
* @param databaseName The name of the default MongoDB database.
* @return {@code this}.
* @see MongoDialectSettings#getDatabaseName()
* @see DatabaseMetaData#getSchemaTerm()
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to emphasize the confusing DatabaseMetadata#getSchemaTerm()? Even in Hibernate ORM codebase this method is never used at all.

*/
public MongoDialectSettings.Builder databaseName(String databaseName) {
this.databaseName = notNull("databaseName", databaseName);
Copy link
Contributor

Choose a reason for hiding this comment

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

seems unnecessary if we trust NullAway's protection.

return this;
}

/**
* The {@linkplain Connection#getAutoCommit() auto-commit mode} of {@linkplain Connection connections}
* {@linkplain MongoConnectionProvider#getConnection() obtained} from {@link MongoConnectionProvider}.
*
* @param autoCommit The auto-commit mode.
* @return {@code this}.
* @see MongoDialectSettings#isAutoCommit()
*/
public MongoDialectSettings.Builder autoCommit(boolean autoCommit) {
this.autoCommit = autoCommit;
return this;
}

/**
* Creates a new {@link MongoDialectSettings}.
*
* @return A new {@link MongoDialectSettings}.
*/
public MongoDialectSettings build() {
return new MongoDialectSettings(this);
}

private static final class ConfigPropertiesParser {
static @Nullable ConnectionString getConnectionString(Map<String, Object> configProperties) {
var jdbcUrl = configProperties.get(JAKARTA_JDBC_URL);
if (jdbcUrl == null) {
return null;
}
if (jdbcUrl instanceof String jdbcUrlText) {
return parseConnectionString(JAKARTA_JDBC_URL, jdbcUrlText);
} else if (jdbcUrl instanceof ConnectionString jdbcUrlConnectionString) {
return jdbcUrlConnectionString;
} else {
throw Exceptions.unsupportedType(JAKARTA_JDBC_URL, jdbcUrl, String.class, ConnectionString.class);
}
}

static @Nullable Boolean getAutoCommit(Map<String, Object> configProperties) {
var autoCommit = configProperties.get(AUTOCOMMIT);
if (autoCommit == null) {
return null;
}
if (autoCommit instanceof String autoCommitText) {
return parseBooleanString(AUTOCOMMIT, autoCommitText);
} else if (autoCommit instanceof Boolean autoCommitBoolean) {
return autoCommitBoolean;
} else {
throw Exceptions.unsupportedType(AUTOCOMMIT, autoCommit, String.class, Boolean.class);
}
}

private static ConnectionString parseConnectionString(String propertyName, String propertyValue) {
try {
return new ConnectionString(propertyValue);
} catch (RuntimeException e) {
throw Exceptions.failedToParse(propertyName, propertyValue, ConnectionString.class);
}
}

private static boolean parseBooleanString(String propertyName, String propertyValue) {
return switch (propertyValue) {
case "true" -> true;
case "false" -> false;
default -> throw Exceptions.failedToParse(propertyName, propertyValue, boolean.class);
};
}

private static final class Exceptions {
static RuntimeException unsupportedType(
String propertyName, Object propertyValue, Type... expectedTypes) {
return new RuntimeException(format(
"Type %s of configuration property [%s] with value [%s] must be one of %s",
propertyValue.getClass().getTypeName(),
propertyName,
propertyValue,
Arrays.stream(expectedTypes).map(Type::getTypeName).collect(Collectors.joining(", "))));
}

static RuntimeException failedToParse(String propertyName, String propertyValue, Type type) {
return new RuntimeException(format(
"Failed to get %s from configuration property [%s] with value [%s]",
type.getTypeName(), propertyName, propertyValue));
}
}
}
}
Copy link
Contributor

@NathanQingyangXu NathanQingyangXu Feb 4, 2025

Choose a reason for hiding this comment

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

Hibernate has internal configuration parsing helper class: https://github.com/hibernate/hibernate-orm/blob/main/hibernate-core/src/main/java/org/hibernate/internal/util/config/ConfigurationHelper.java

Seems it will throw a specific exception: ConfigurationException. It might be an idea to copy such util class in our internal package for reuse in other scenarios in the future. But if we decided to centralize all configs in this MongoDialectSettings, current design is fine.

}
Loading