From 4cfc16325326a72d07afdf6d8115ea8e111c26da Mon Sep 17 00:00:00 2001 From: Nathan Xu Date: Thu, 12 Jun 2025 13:45:04 -0400 Subject: [PATCH 1/5] showcase we can forbid empty struct --- .../EmptyStructIntegrationTests.java | 48 +++++++++++++++++++ .../MongoAdditionalMappingContributor.java | 5 ++ 2 files changed, 53 insertions(+) create mode 100644 src/integrationTest/java/com/mongodb/hibernate/embeddable/EmptyStructIntegrationTests.java diff --git a/src/integrationTest/java/com/mongodb/hibernate/embeddable/EmptyStructIntegrationTests.java b/src/integrationTest/java/com/mongodb/hibernate/embeddable/EmptyStructIntegrationTests.java new file mode 100644 index 00000000..b5b57bc8 --- /dev/null +++ b/src/integrationTest/java/com/mongodb/hibernate/embeddable/EmptyStructIntegrationTests.java @@ -0,0 +1,48 @@ +package com.mongodb.hibernate.embeddable; + +import com.mongodb.hibernate.junit.MongoExtension; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.Struct; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@SessionFactory(exportSchema = false) +@DomainModel( + annotatedClasses = { + EmptyStructIntegrationTests.StructHolder.class, + EmptyStructIntegrationTests.EmptyStruct.class + }) +@ExtendWith(MongoExtension.class) +class EmptyStructIntegrationTests { + + + @Test + void test(SessionFactoryScope scope) { + scope.inTransaction(session -> { + var holder = new StructHolder(); + holder.id = 1; + holder.emptyStruct = new EmptyStruct(); + session.persist(holder); + }); + } + + @Entity + @Table(name = "collection") + static class StructHolder { + @Id + int id; + EmptyStruct emptyStruct; + } + + @Embeddable + @Struct(name = "EmptyStruct") + static class EmptyStruct { + // No fields + } +} diff --git a/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java b/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java index f0147ecb..bfeddcb0 100644 --- a/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java +++ b/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java @@ -63,6 +63,11 @@ public void contribute( forbidStructIdentifier(persistentClass); setIdentifierColumnName(persistentClass); }); + metadata.visitRegisteredComponents(component -> { + if (component.getStructName() != null && component.getProperties().isEmpty()) { + throw new FeatureNotSupportedException(format("empty struct: %s, are you kidding me?", component.getComponentClass().getName())); + } + }); } private static void forbidDynamicInsert(PersistentClass persistentClass) { From 4ea6f3ee841411f340d9247fb4dbdbf592cc6f9c Mon Sep 17 00:00:00 2001 From: Nathan Xu Date: Thu, 12 Jun 2025 14:52:42 -0400 Subject: [PATCH 2/5] showcase @DynamicInsert won't impact Struct --- ...hStructWithNullValuesIntegrationTests.java | 71 +++++++++++++++++++ .../MongoAdditionalMappingContributor.java | 7 -- .../internal/type/MongoStructJdbcType.java | 4 -- .../internal/type/ValueConversions.java | 8 ++- 4 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 src/integrationTest/java/com/mongodb/hibernate/embeddable/DynamicInsertWithStructWithNullValuesIntegrationTests.java diff --git a/src/integrationTest/java/com/mongodb/hibernate/embeddable/DynamicInsertWithStructWithNullValuesIntegrationTests.java b/src/integrationTest/java/com/mongodb/hibernate/embeddable/DynamicInsertWithStructWithNullValuesIntegrationTests.java new file mode 100644 index 00000000..ce7aeb63 --- /dev/null +++ b/src/integrationTest/java/com/mongodb/hibernate/embeddable/DynamicInsertWithStructWithNullValuesIntegrationTests.java @@ -0,0 +1,71 @@ +package com.mongodb.hibernate.embeddable; + +import com.mongodb.client.MongoCollection; +import com.mongodb.hibernate.junit.InjectMongoCollection; +import com.mongodb.hibernate.junit.MongoExtension; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.bson.BsonDocument; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.Struct; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.assertj.core.api.Assertions.assertThat; + +@SessionFactory(exportSchema = false) +@DomainModel( + annotatedClasses = { + DynamicInsertWithStructWithNullValuesIntegrationTests.Book.class, + DynamicInsertWithStructWithNullValuesIntegrationTests.Author.class + }) +@ExtendWith(MongoExtension.class) +class DynamicInsertWithStructWithNullValuesIntegrationTests { + + @InjectMongoCollection("books") + private static MongoCollection mongoCollection; + + @Test + void test(SessionFactoryScope scope) { + scope.inTransaction(session -> { + var book = new Book(); + book.id = 1; + book.author = new Author(); + session.persist(book); + }); + assertThat(mongoCollection.find()).containsExactly( + BsonDocument.parse( + """ + { + _id: 1, + author: { + firstName: null, + lastName: null + } + } + """) + ); + } + + + @Entity + @DynamicInsert + @Table(name = "books") + static class Book { + @Id + int id; + Author author; + } + + @Embeddable + @Struct(name = "Author") + static class Author { + String firstName; + String lastName; + } +} diff --git a/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java b/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java index bfeddcb0..91107c87 100644 --- a/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java +++ b/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java @@ -58,7 +58,6 @@ public void contribute( ResourceStreamLocator resourceStreamLocator, MetadataBuildingContext buildingContext) { metadata.getEntityBindings().forEach(persistentClass -> { - forbidDynamicInsert(persistentClass); checkColumnNames(persistentClass); forbidStructIdentifier(persistentClass); setIdentifierColumnName(persistentClass); @@ -70,12 +69,6 @@ public void contribute( }); } - private static void forbidDynamicInsert(PersistentClass persistentClass) { - if (persistentClass.useDynamicInsert()) { - throw new FeatureNotSupportedException(format("%s is not supported", DynamicInsert.class.getSimpleName())); - } - } - private static void checkColumnNames(PersistentClass persistentClass) { persistentClass .getTable() diff --git a/src/main/java/com/mongodb/hibernate/internal/type/MongoStructJdbcType.java b/src/main/java/com/mongodb/hibernate/internal/type/MongoStructJdbcType.java index ae8a573d..5bb6ca46 100644 --- a/src/main/java/com/mongodb/hibernate/internal/type/MongoStructJdbcType.java +++ b/src/main/java/com/mongodb/hibernate/internal/type/MongoStructJdbcType.java @@ -110,10 +110,6 @@ public BsonDocument createJdbcValue(Object domainValue, WrapperOptions options) } var fieldName = jdbcValueSelectable.getSelectableName(); var value = embeddableMappingType.getValue(domainValue, columnIndex); - if (value == null) { - throw new FeatureNotSupportedException( - "TODO-HIBERNATE-48 https://jira.mongodb.org/browse/HIBERNATE-48"); - } BsonValue bsonValue; var jdbcMapping = jdbcValueSelectable.getJdbcMapping(); if (jdbcMapping.getJdbcType().getJdbcTypeCode() == JDBC_TYPE.getVendorTypeNumber()) { diff --git a/src/main/java/com/mongodb/hibernate/internal/type/ValueConversions.java b/src/main/java/com/mongodb/hibernate/internal/type/ValueConversions.java index e74c7a29..af26f1b3 100644 --- a/src/main/java/com/mongodb/hibernate/internal/type/ValueConversions.java +++ b/src/main/java/com/mongodb/hibernate/internal/type/ValueConversions.java @@ -29,11 +29,13 @@ import org.bson.BsonDouble; import org.bson.BsonInt32; import org.bson.BsonInt64; +import org.bson.BsonNull; import org.bson.BsonObjectId; import org.bson.BsonString; import org.bson.BsonValue; import org.bson.types.Decimal128; import org.bson.types.ObjectId; +import org.jspecify.annotations.Nullable; /** * Provides conversion methods between {@link BsonValue}s, which our {@link PreparedStatement}/{@link ResultSet} @@ -43,8 +45,10 @@ public final class ValueConversions { private ValueConversions() {} - public static BsonValue toBsonValue(Object value) throws SQLFeatureNotSupportedException { - assertNotNull(value); + public static BsonValue toBsonValue(@Nullable Object value) throws SQLFeatureNotSupportedException { + if (value == null) { + return BsonNull.VALUE; + } if (value instanceof Boolean v) { return toBsonValue(v.booleanValue()); } else if (value instanceof Integer v) { From b6021ecfed12ad459f7648f0cfe9d189cc87396a Mon Sep 17 00:00:00 2001 From: Nathan Xu Date: Tue, 17 Jun 2025 13:20:07 -0400 Subject: [PATCH 3/5] showcase 'compact' insertion and updating (avoid null fields in favour of missing fields) --- build.gradle.kts | 4 +- .../hibernate/BasicCrudIntegrationTests.java | 109 +++++++++++++++++- ...hStructWithNullValuesIntegrationTests.java | 50 +++++--- .../EmptyStructIntegrationTests.java | 22 +++- .../hibernate/internal/MongoConstants.java | 2 + .../MongoAdditionalMappingContributor.java | 5 +- .../jdbc/MongoPreparedStatement.java | 4 +- .../hibernate/jdbc/MongoStatement.java | 41 +++++++ 8 files changed, 209 insertions(+), 28 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index f5c12545..3b7b2285 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,9 +35,7 @@ java { withSourcesJar() } -tasks.withType { - exclude("/com/mongodb/hibernate/internal/**") -} +tasks.withType { exclude("/com/mongodb/hibernate/internal/**") } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Integration Test diff --git a/src/integrationTest/java/com/mongodb/hibernate/BasicCrudIntegrationTests.java b/src/integrationTest/java/com/mongodb/hibernate/BasicCrudIntegrationTests.java index 953a1a34..151744ea 100644 --- a/src/integrationTest/java/com/mongodb/hibernate/BasicCrudIntegrationTests.java +++ b/src/integrationTest/java/com/mongodb/hibernate/BasicCrudIntegrationTests.java @@ -25,12 +25,16 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; +import org.assertj.core.api.InstanceOfAssertFactories; import org.bson.BsonDocument; import org.hibernate.annotations.DynamicUpdate; import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.ServiceRegistryScope; +import org.hibernate.testing.orm.junit.ServiceRegistryScopeAware; import org.hibernate.testing.orm.junit.SessionFactory; import org.hibernate.testing.orm.junit.SessionFactoryScope; import org.hibernate.testing.orm.junit.SessionFactoryScopeAware; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -40,18 +44,30 @@ annotatedClasses = {BasicCrudIntegrationTests.Book.class, BasicCrudIntegrationTests.BookDynamicallyUpdated.class }) @ExtendWith(MongoExtension.class) -class BasicCrudIntegrationTests implements SessionFactoryScopeAware { +class BasicCrudIntegrationTests implements SessionFactoryScopeAware, ServiceRegistryScopeAware { @InjectMongoCollection("books") private static MongoCollection mongoCollection; private SessionFactoryScope sessionFactoryScope; + private TestCommandListener testCommandListener; + + @Override + public void injectServiceRegistryScope(ServiceRegistryScope serviceRegistryScope) { + this.testCommandListener = serviceRegistryScope.getRegistry().requireService(TestCommandListener.class); + } + @Override public void injectSessionFactoryScope(SessionFactoryScope sessionFactoryScope) { this.sessionFactoryScope = sessionFactoryScope; } + @BeforeEach + void beforeEach() { + testCommandListener.clear(); + } + @Nested class InsertTests { @Test @@ -101,6 +117,48 @@ void testEntityWithNullFieldValueInsertion() { .formatted(author)); assertCollectionContainsExactly(expectedDocument); } + + @Test + void testIgnoreFieldWhenNull() { + sessionFactoryScope.inTransaction(session -> { + var book = new Book(); + book.id = 1; + book.title = "War and Peace"; + book.author = null; // This field should be ignored when null + book.publishYear = 1867; + session.persist(book); + + session.flush(); + + var capturedCommands = testCommandListener.getStartedCommands(); + + assertThat(capturedCommands) + .singleElement() + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsAllEntriesOf( + BsonDocument.parse( + """ + { + "insert": "books", + "documents": [ + { + "_id": 1, + "title": "War and Peace", + "publishYear": 1867 + } + ] + } + """)); + }); + var expectedDocument = BsonDocument.parse( + """ + { + _id: 1, + title: "War and Peace", + publishYear: 1867 + }"""); + assertCollectionContainsExactly(expectedDocument); + } } @Nested @@ -174,6 +232,55 @@ void testDynamicUpdate() { {"_id": 1, "author": "Leo Tolstoy", "publishYear": 1867, "title": "War and Peace"}\ """)); } + + @Test + void testDeleteFieldWhenSetToNull() { + sessionFactoryScope.inTransaction(session -> { + var book = new Book(); + book.id = 1; + book.title = "War and Peace"; + book.author = "Leo Tolstoy"; + book.publishYear = 1869; + session.persist(book); + session.flush(); + + book.publishYear = 1867; // Ensure the field is set before deletion + book.author = null; // This field should be deleted when set to null + + session.flush(); + + var capturedCommands = testCommandListener.getStartedCommands(); + + var lastCommand = capturedCommands.get(capturedCommands.size() - 1); + assertThat(lastCommand) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsAllEntriesOf( + BsonDocument.parse( + """ + { + "update": "books", + "updates": [ + { + "q": {"_id": {"$eq": 1}}, + "u": { + "$set": { + "publishYear": 1867, + "title": "War and Peace" + } + "$unset": {"author": ""}}, + "multi": true + } + ] + } + """)); + }); + + assertCollectionContainsExactly( + BsonDocument.parse( + """ + {"_id": 1, "publishYear": 1867, "title": "War and Peace"}\ + """)); + } } @Nested diff --git a/src/integrationTest/java/com/mongodb/hibernate/embeddable/DynamicInsertWithStructWithNullValuesIntegrationTests.java b/src/integrationTest/java/com/mongodb/hibernate/embeddable/DynamicInsertWithStructWithNullValuesIntegrationTests.java index ce7aeb63..d35ef3ff 100644 --- a/src/integrationTest/java/com/mongodb/hibernate/embeddable/DynamicInsertWithStructWithNullValuesIntegrationTests.java +++ b/src/integrationTest/java/com/mongodb/hibernate/embeddable/DynamicInsertWithStructWithNullValuesIntegrationTests.java @@ -1,5 +1,23 @@ +/* + * Copyright 2025-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.embeddable; +import static org.assertj.core.api.Assertions.assertThat; + import com.mongodb.client.MongoCollection; import com.mongodb.hibernate.junit.InjectMongoCollection; import com.mongodb.hibernate.junit.MongoExtension; @@ -16,13 +34,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import static org.assertj.core.api.Assertions.assertThat; - @SessionFactory(exportSchema = false) @DomainModel( annotatedClasses = { - DynamicInsertWithStructWithNullValuesIntegrationTests.Book.class, - DynamicInsertWithStructWithNullValuesIntegrationTests.Author.class + DynamicInsertWithStructWithNullValuesIntegrationTests.Book.class, + DynamicInsertWithStructWithNullValuesIntegrationTests.Author.class }) @ExtendWith(MongoExtension.class) class DynamicInsertWithStructWithNullValuesIntegrationTests { @@ -38,27 +54,27 @@ void test(SessionFactoryScope scope) { book.author = new Author(); session.persist(book); }); - assertThat(mongoCollection.find()).containsExactly( - BsonDocument.parse( - """ - { - _id: 1, - author: { - firstName: null, - lastName: null - } - } - """) - ); + assertThat(mongoCollection.find()) + .containsExactly( + BsonDocument.parse( + """ + { + _id: 1, + author: { + firstName: null, + lastName: null + } + } + """)); } - @Entity @DynamicInsert @Table(name = "books") static class Book { @Id int id; + Author author; } diff --git a/src/integrationTest/java/com/mongodb/hibernate/embeddable/EmptyStructIntegrationTests.java b/src/integrationTest/java/com/mongodb/hibernate/embeddable/EmptyStructIntegrationTests.java index b5b57bc8..c99fb700 100644 --- a/src/integrationTest/java/com/mongodb/hibernate/embeddable/EmptyStructIntegrationTests.java +++ b/src/integrationTest/java/com/mongodb/hibernate/embeddable/EmptyStructIntegrationTests.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025-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.embeddable; import com.mongodb.hibernate.junit.MongoExtension; @@ -15,13 +31,12 @@ @SessionFactory(exportSchema = false) @DomainModel( annotatedClasses = { - EmptyStructIntegrationTests.StructHolder.class, - EmptyStructIntegrationTests.EmptyStruct.class + EmptyStructIntegrationTests.StructHolder.class, + EmptyStructIntegrationTests.EmptyStruct.class }) @ExtendWith(MongoExtension.class) class EmptyStructIntegrationTests { - @Test void test(SessionFactoryScope scope) { scope.inTransaction(session -> { @@ -37,6 +52,7 @@ void test(SessionFactoryScope scope) { static class StructHolder { @Id int id; + EmptyStruct emptyStruct; } diff --git a/src/main/java/com/mongodb/hibernate/internal/MongoConstants.java b/src/main/java/com/mongodb/hibernate/internal/MongoConstants.java index 444709cd..341b4810 100644 --- a/src/main/java/com/mongodb/hibernate/internal/MongoConstants.java +++ b/src/main/java/com/mongodb/hibernate/internal/MongoConstants.java @@ -16,6 +16,7 @@ package com.mongodb.hibernate.internal; +import org.bson.BsonString; import org.bson.json.JsonMode; import org.bson.json.JsonWriterSettings; @@ -29,4 +30,5 @@ private MongoConstants() {} public static final String MONGO_DBMS_NAME = "MongoDB"; public static final String MONGO_JDBC_DRIVER_NAME = "MongoDB Java Driver JDBC Adapter"; public static final String ID_FIELD_NAME = "_id"; + public static final BsonString MONGO_EMPTY_STRING = new BsonString(""); } diff --git a/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java b/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java index 91107c87..72070523 100644 --- a/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java +++ b/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java @@ -26,7 +26,6 @@ import jakarta.persistence.Embeddable; import java.util.Collection; import java.util.Set; -import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.Struct; import org.hibernate.boot.ResourceStreamLocator; import org.hibernate.boot.spi.AdditionalMappingContributions; @@ -64,7 +63,9 @@ public void contribute( }); metadata.visitRegisteredComponents(component -> { if (component.getStructName() != null && component.getProperties().isEmpty()) { - throw new FeatureNotSupportedException(format("empty struct: %s, are you kidding me?", component.getComponentClass().getName())); + throw new FeatureNotSupportedException(format( + "empty struct: %s, are you kidding me?", + component.getComponentClass().getName())); } }); } diff --git a/src/main/java/com/mongodb/hibernate/jdbc/MongoPreparedStatement.java b/src/main/java/com/mongodb/hibernate/jdbc/MongoPreparedStatement.java index 349acd01..bea43c56 100644 --- a/src/main/java/com/mongodb/hibernate/jdbc/MongoPreparedStatement.java +++ b/src/main/java/com/mongodb/hibernate/jdbc/MongoPreparedStatement.java @@ -42,6 +42,7 @@ import java.util.function.Consumer; import org.bson.BsonArray; import org.bson.BsonDocument; +import org.bson.BsonNull; import org.bson.BsonType; import org.bson.BsonValue; import org.bson.types.ObjectId; @@ -106,8 +107,7 @@ public void setNull(int parameterIndex, int sqlType) throws SQLException { throw new SQLFeatureNotSupportedException( "Unsupported SQL type: " + JDBCType.valueOf(sqlType).getName()); } - throw new SQLFeatureNotSupportedException( - "TODO-HIBERNATE-74 https://jira.mongodb.org/browse/HIBERNATE-74, TODO-HIBERNATE-48 https://jira.mongodb.org/browse/HIBERNATE-48"); + setParameter(parameterIndex, BsonNull.VALUE); } @Override diff --git a/src/main/java/com/mongodb/hibernate/jdbc/MongoStatement.java b/src/main/java/com/mongodb/hibernate/jdbc/MongoStatement.java index 2442a4e8..fbf6d054 100644 --- a/src/main/java/com/mongodb/hibernate/jdbc/MongoStatement.java +++ b/src/main/java/com/mongodb/hibernate/jdbc/MongoStatement.java @@ -17,6 +17,7 @@ package com.mongodb.hibernate.jdbc; import static com.mongodb.hibernate.internal.MongoConstants.ID_FIELD_NAME; +import static com.mongodb.hibernate.internal.MongoConstants.MONGO_EMPTY_STRING; import static com.mongodb.hibernate.internal.VisibleForTesting.AccessModifier.PRIVATE; import static java.lang.String.format; import static java.util.stream.Collectors.toCollection; @@ -127,6 +128,7 @@ public int executeUpdate(String mql) throws SQLException { int executeUpdateCommand(BsonDocument command) throws SQLException { try { + postProcessUpdateCommand(command); startTransactionIfNeeded(); return mongoDatabase.runCommand(clientSession, command).getInteger("n"); } catch (RuntimeException e) { @@ -236,6 +238,45 @@ static BsonDocument parse(String mql) throws SQLSyntaxErrorException { } } + private static void postProcessUpdateCommand(BsonDocument command) { + switch (command.getFirstKey()) { + case "insert": + for (var document : command.getArray("documents")) { + var doc = document.asDocument(); + doc.entrySet().removeIf(entry -> entry.getValue().isNull()); + } + break; + case "update": + for (var update : command.getArray("updates")) { + var u = update.asDocument().getDocument("u"); + var set = u.asDocument().getDocument("$set"); + var unsetFields = new ArrayList(); + if (set != null) { + var iterator = set.entrySet().iterator(); + while (iterator.hasNext()) { + var entry = iterator.next(); + if (entry.getValue().isNull()) { + unsetFields.add(entry.getKey()); + iterator.remove(); + } + } + } + if (!unsetFields.isEmpty()) { + var unset = u.asDocument().getDocument("$unset", new BsonDocument()); + for (var unsetField : unsetFields) { + unset.put(unsetField, MONGO_EMPTY_STRING); + } + if (!u.asDocument().containsKey("$unset")) { + u.asDocument().append("$unset", unset); + } + } + } + break; + default: + break; + } + } + /** * Starts transaction for the first {@link java.sql.Statement} executing if * {@linkplain MongoConnection#getAutoCommit() auto-commit} is disabled. From 7032c20b14d0dd12bd1ce24e71676ff676e078fc Mon Sep 17 00:00:00 2001 From: Nathan Xu Date: Mon, 23 Jun 2025 12:38:33 -0400 Subject: [PATCH 4/5] showcase empty struct could be fetched intact (not null) --- ...uctAggregateRetrievalIntegrationTests.java | 53 +++++++++++++++++++ .../resources/hibernate.properties | 3 +- .../MongoAdditionalMappingContributor.java | 11 ++++ .../internal/type/ValueConversions.java | 4 +- 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 src/integrationTest/java/com/mongodb/hibernate/embeddable/EmptyStructAggregateRetrievalIntegrationTests.java diff --git a/src/integrationTest/java/com/mongodb/hibernate/embeddable/EmptyStructAggregateRetrievalIntegrationTests.java b/src/integrationTest/java/com/mongodb/hibernate/embeddable/EmptyStructAggregateRetrievalIntegrationTests.java new file mode 100644 index 00000000..5761c1df --- /dev/null +++ b/src/integrationTest/java/com/mongodb/hibernate/embeddable/EmptyStructAggregateRetrievalIntegrationTests.java @@ -0,0 +1,53 @@ +package com.mongodb.hibernate.embeddable; + + +import com.mongodb.hibernate.junit.MongoExtension; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.Struct; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.ServiceRegistry; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.hibernate.testing.orm.junit.Setting; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static com.mongodb.hibernate.MongoTestAssertions.assertEq; + +@SessionFactory(exportSchema = false) +@DomainModel(annotatedClasses = { + EmptyStructAggregateRetrievalIntegrationTests.Book.class, + EmptyStructAggregateRetrievalIntegrationTests.Author.class +}) +@ExtendWith(MongoExtension.class) +class EmptyStructAggregateRetrievalIntegrationTests { + + @Test + void testEmptyStructAggregateRetriedAsNonNull(SessionFactoryScope scope) { + var book = new Book(); + book.id = 1; + book.author = new Author(); + + scope.inTransaction(session -> session.persist(book)); + + var retrievedBook = scope.fromTransaction(session -> session.get(Book.class, 1)); + assertEq(book, retrievedBook); + } + + @Entity + @Table(name = "books") + static class Book { + @Id int id; + Author author; + } + + @Embeddable + @Struct(name = "Author") + static class Author { + String firstName; + String lastName; + } +} diff --git a/src/integrationTest/resources/hibernate.properties b/src/integrationTest/resources/hibernate.properties index b59c4af4..27c0a768 100644 --- a/src/integrationTest/resources/hibernate.properties +++ b/src/integrationTest/resources/hibernate.properties @@ -1,4 +1,5 @@ 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 -hibernate.query.plan_cache_enabled=false #make tests more isolated from each other \ No newline at end of file +hibernate.query.plan_cache_enabled=false #make tests more isolated from each other +hibernate.create_empty_composites.enabled=true \ No newline at end of file diff --git a/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java b/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java index 72070523..0ad970fe 100644 --- a/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java +++ b/src/main/java/com/mongodb/hibernate/internal/extension/MongoAdditionalMappingContributor.java @@ -32,9 +32,12 @@ import org.hibernate.boot.spi.AdditionalMappingContributor; import org.hibernate.boot.spi.InFlightMetadataCollector; import org.hibernate.boot.spi.MetadataBuildingContext; +import org.hibernate.cfg.MappingSettings; +import org.hibernate.engine.config.spi.ConfigurationService; import org.hibernate.mapping.Component; import org.hibernate.mapping.PersistentClass; +@SuppressWarnings("deprecation") public final class MongoAdditionalMappingContributor implements AdditionalMappingContributor { /** * We do not support these characters because BSON fields with names containing them must be handled specially as @@ -68,6 +71,14 @@ public void contribute( component.getComponentClass().getName())); } }); + var serviceRegistry = buildingContext.getBootstrapContext().getServiceRegistry(); + var configurationService = serviceRegistry.getService(ConfigurationService.class); + assertTrue(configurationService != null); + var emptyCompositesEnabled = Boolean.valueOf((String) configurationService.getSettings().getOrDefault(MappingSettings.CREATE_EMPTY_COMPOSITES_ENABLED, "false")); + if (!emptyCompositesEnabled) { + throw new FeatureNotSupportedException("empty composites are not supported, you may want to set " + + MappingSettings.CREATE_EMPTY_COMPOSITES_ENABLED + " to true in your configuration"); + } } private static void checkColumnNames(PersistentClass persistentClass) { diff --git a/src/main/java/com/mongodb/hibernate/internal/type/ValueConversions.java b/src/main/java/com/mongodb/hibernate/internal/type/ValueConversions.java index af26f1b3..1e617827 100644 --- a/src/main/java/com/mongodb/hibernate/internal/type/ValueConversions.java +++ b/src/main/java/com/mongodb/hibernate/internal/type/ValueConversions.java @@ -104,7 +104,7 @@ public static BsonObjectId toBsonValue(ObjectId value) { return new BsonObjectId(value); } - static Object toDomainValue(BsonValue value) throws SQLFeatureNotSupportedException { + static @Nullable Object toDomainValue(BsonValue value) throws SQLFeatureNotSupportedException { assertNotNull(value); if (value instanceof BsonBoolean v) { return toDomainValue(v); @@ -122,6 +122,8 @@ static Object toDomainValue(BsonValue value) throws SQLFeatureNotSupportedExcept return toDomainValue(v); } else if (value instanceof BsonObjectId v) { return toDomainValue(v); + } else if (value instanceof BsonNull) { + return null; } else { throw new SQLFeatureNotSupportedException(format( "Value [%s] of type [%s] is not supported", From 514fb78400bedb14897fecdc3da6413e2f35630e Mon Sep 17 00:00:00 2001 From: Nathan Xu Date: Thu, 26 Jun 2025 17:00:14 -0400 Subject: [PATCH 5/5] showcase compact struct persistence (leaving out null fields) is possible --- .../CompactStructIntegrationTests.java | 83 +++++++++++++++++++ .../internal/type/MongoStructJdbcType.java | 31 +++++-- 2 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 src/integrationTest/java/com/mongodb/hibernate/embeddable/CompactStructIntegrationTests.java diff --git a/src/integrationTest/java/com/mongodb/hibernate/embeddable/CompactStructIntegrationTests.java b/src/integrationTest/java/com/mongodb/hibernate/embeddable/CompactStructIntegrationTests.java new file mode 100644 index 00000000..1b2481b9 --- /dev/null +++ b/src/integrationTest/java/com/mongodb/hibernate/embeddable/CompactStructIntegrationTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2025-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.embeddable; + +import static com.mongodb.hibernate.MongoTestAssertions.assertEq; +import static org.assertj.core.api.Assertions.assertThat; + +import com.mongodb.client.MongoCollection; +import com.mongodb.hibernate.junit.InjectMongoCollection; +import com.mongodb.hibernate.junit.MongoExtension; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.bson.BsonDocument; +import org.bson.BsonInt32; +import org.bson.BsonString; +import org.hibernate.annotations.Struct; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@SessionFactory(exportSchema = false) +@DomainModel( + annotatedClasses = { + CompactStructIntegrationTests.StructHolder.class, + CompactStructIntegrationTests.CompactStruct.class + }) +@ExtendWith(MongoExtension.class) +class CompactStructIntegrationTests { + + @InjectMongoCollection("items") + private static MongoCollection mongoCollection; + + @Test + void test(SessionFactoryScope scope) { + var holder = new StructHolder(); + holder.id = 1; + holder.compactStruct = new CompactStruct(); + holder.compactStruct.field1 = null; + holder.compactStruct.field2 = "value2"; + scope.inTransaction(session -> session.persist(holder)); + + assertThat(mongoCollection.find()) + .containsExactly(new BsonDocument("_id", new BsonInt32(1)) + .append("compactStruct", new BsonDocument("field2", new BsonString("value2")))); + + var loadedHolder = scope.fromTransaction(session -> session.find(StructHolder.class, 1)); + assertEq(holder, loadedHolder); + } + + @Entity(name = "StructHolder") + @Table(name = "items") + static class StructHolder { + @Id + int id; + + CompactStruct compactStruct; + } + + @Embeddable + @Struct(name = "CompactStruct") + static class CompactStruct { + String field1; + String field2; + } +} diff --git a/src/main/java/com/mongodb/hibernate/internal/type/MongoStructJdbcType.java b/src/main/java/com/mongodb/hibernate/internal/type/MongoStructJdbcType.java index 5bb6ca46..67427e96 100644 --- a/src/main/java/com/mongodb/hibernate/internal/type/MongoStructJdbcType.java +++ b/src/main/java/com/mongodb/hibernate/internal/type/MongoStructJdbcType.java @@ -35,6 +35,7 @@ import org.bson.BsonValue; import org.hibernate.annotations.Struct; import org.hibernate.metamodel.mapping.EmbeddableMappingType; +import org.hibernate.metamodel.mapping.internal.EmbeddedAttributeMapping; import org.hibernate.metamodel.spi.RuntimeModelCreationContext; import org.hibernate.type.descriptor.ValueBinder; import org.hibernate.type.descriptor.ValueExtractor; @@ -110,6 +111,9 @@ public BsonDocument createJdbcValue(Object domainValue, WrapperOptions options) } var fieldName = jdbcValueSelectable.getSelectableName(); var value = embeddableMappingType.getValue(domainValue, columnIndex); + if (value == null) { + continue; + } BsonValue bsonValue; var jdbcMapping = jdbcValueSelectable.getJdbcMapping(); if (jdbcMapping.getJdbcType().getJdbcTypeCode() == JDBC_TYPE.getVendorTypeNumber()) { @@ -133,12 +137,27 @@ public Object[] extractJdbcValues(Object rawJdbcValue, WrapperOptions options) t if (!(rawJdbcValue instanceof BsonDocument bsonDocument)) { throw fail(); } - var result = new Object[bsonDocument.size()]; - var elementIdx = 0; - for (var value : bsonDocument.values()) { - assertNotNull(value); - result[elementIdx++] = - value instanceof BsonDocument ? extractJdbcValues(value, options) : toDomainValue(value); + return doExtractJdbcValues(bsonDocument, getEmbeddableMappingType(), options); + } + + private Object[] doExtractJdbcValues( + BsonDocument bsonDocument, EmbeddableMappingType embeddableMappingType, WrapperOptions options) + throws SQLException { + var attributeMappings = embeddableMappingType.getAttributeMappings(); + var result = new Object[attributeMappings.size()]; + for (int i = 0; i < attributeMappings.size(); i++) { + var attributeMapping = attributeMappings.get(i); + var attributeValue = bsonDocument.get(attributeMapping.getAttributeName()); + if (attributeValue == null) { + result[i] = null; + continue; + } + if (attributeMapping instanceof EmbeddedAttributeMapping embeddedAttributeMapping) { + result[i] = doExtractJdbcValues( + (BsonDocument) attributeValue, embeddedAttributeMapping.getMappedType(), options); + } else { + result[i] = toDomainValue(attributeValue); + } } return result; }