diff --git a/src/integrationTest/java/com/mongodb/hibernate/BasicCrudIntegrationTests.java b/src/integrationTest/java/com/mongodb/hibernate/BasicCrudIntegrationTests.java index 1a36ac86..379fc596 100644 --- a/src/integrationTest/java/com/mongodb/hibernate/BasicCrudIntegrationTests.java +++ b/src/integrationTest/java/com/mongodb/hibernate/BasicCrudIntegrationTests.java @@ -31,6 +31,7 @@ import java.util.ArrayList; import java.util.List; import org.bson.BsonDocument; +import org.hibernate.annotations.DynamicUpdate; import org.hibernate.testing.orm.junit.DomainModel; import org.hibernate.testing.orm.junit.SessionFactory; import org.hibernate.testing.orm.junit.SessionFactoryScope; @@ -41,7 +42,10 @@ @SessionFactory(exportSchema = false) @DomainModel( - annotatedClasses = {BasicCrudIntegrationTests.Book.class, BasicCrudIntegrationTests.BookWithEmbeddedField.class + annotatedClasses = { + BasicCrudIntegrationTests.Book.class, + BasicCrudIntegrationTests.BookWithEmbeddedField.class, + BasicCrudIntegrationTests.BookDynamicallyUpdated.class }) @ExtendWith(MongoExtension.class) class BasicCrudIntegrationTests implements SessionFactoryScopeAware { @@ -151,17 +155,64 @@ void testSimpleDeletion() { } } - private List getCollectionDocuments() { + @Nested + class UpdateTests { + + @Test + void testSimpleUpdate() { + sessionFactoryScope.inTransaction(session -> { + var book = new Book(); + book.id = 1; + book.title = "War and Peace"; + book.author = "Leo Tolstoy"; + book.publishYear = 1867; + session.persist(book); + session.flush(); + + book.title = "Resurrection"; + book.publishYear = 1899; + }); + + assertCollectionContainsOnly( + BsonDocument.parse( + """ + {"_id": 1, "author": "Leo Tolstoy", "publishYear": 1899, "title": "Resurrection"}\ + """)); + } + + @Test + void testDynamicUpdate() { + sessionFactoryScope.inTransaction(session -> { + var book = new BookDynamicallyUpdated(); + book.id = 1; + book.title = "War and Peace"; + book.author = "Leo Tolstoy"; + book.publishYear = 1899; + session.persist(book); + session.flush(); + + book.publishYear = 1867; + }); + + assertCollectionContainsOnly( + BsonDocument.parse( + """ + {"_id": 1, "author": "Leo Tolstoy", "publishYear": 1867, "title": "War and Peace"}\ + """)); + } + } + + private static List getCollectionDocuments() { var documents = new ArrayList(); collection.find().sort(Sorts.ascending("_id")).into(documents); return documents; } - private void assertCollectionContainsOnly(BsonDocument expectedDoc) { + private static void assertCollectionContainsOnly(BsonDocument expectedDoc) { assertThat(getCollectionDocuments()).asInstanceOf(LIST).singleElement().isEqualTo(expectedDoc); } - @Entity(name = "Book") + @Entity @Table(name = "books") static class Book { @Id @@ -175,7 +226,22 @@ static class Book { int publishYear; } - @Entity(name = "BookWithEmbeddedField") + @Entity + @Table(name = "books") + @DynamicUpdate + static class BookDynamicallyUpdated { + @Id + @Column(name = "_id") + int id; + + String title; + + String author; + + int publishYear; + } + + @Entity @Table(name = "books") static class BookWithEmbeddedField { @Id diff --git a/src/main/java/com/mongodb/hibernate/internal/translate/AbstractMqlTranslator.java b/src/main/java/com/mongodb/hibernate/internal/translate/AbstractMqlTranslator.java index 496a242f..5aef9f25 100644 --- a/src/main/java/com/mongodb/hibernate/internal/translate/AbstractMqlTranslator.java +++ b/src/main/java/com/mongodb/hibernate/internal/translate/AbstractMqlTranslator.java @@ -26,12 +26,15 @@ import com.mongodb.hibernate.internal.service.StandardServiceRegistryScopedState; import com.mongodb.hibernate.internal.translate.mongoast.AstDocument; import com.mongodb.hibernate.internal.translate.mongoast.AstElement; +import com.mongodb.hibernate.internal.translate.mongoast.AstFieldUpdate; import com.mongodb.hibernate.internal.translate.mongoast.AstNode; import com.mongodb.hibernate.internal.translate.mongoast.AstParameterMarker; import com.mongodb.hibernate.internal.translate.mongoast.command.AstDeleteCommand; import com.mongodb.hibernate.internal.translate.mongoast.command.AstInsertCommand; +import com.mongodb.hibernate.internal.translate.mongoast.command.AstUpdateCommand; import com.mongodb.hibernate.internal.translate.mongoast.filter.AstComparisonFilterOperation; import com.mongodb.hibernate.internal.translate.mongoast.filter.AstFieldOperationFilter; +import com.mongodb.hibernate.internal.translate.mongoast.filter.AstFilter; import com.mongodb.hibernate.internal.translate.mongoast.filter.AstFilterFieldPath; import java.io.IOException; import java.io.StringWriter; @@ -117,6 +120,8 @@ import org.hibernate.sql.ast.tree.update.UpdateStatement; import org.hibernate.sql.exec.spi.JdbcOperation; import org.hibernate.sql.exec.spi.JdbcParameterBinder; +import org.hibernate.sql.model.MutationOperation; +import org.hibernate.sql.model.ast.AbstractRestrictedTableMutation; import org.hibernate.sql.model.ast.ColumnWriteFragment; import org.hibernate.sql.model.internal.OptionalTableUpdate; import org.hibernate.sql.model.internal.TableDeleteCustomSql; @@ -200,10 +205,13 @@ R acceptAndYield(SqlAstNode node, AstVisitorValueDescriptor< } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // Table Mutation: insertion + // Table Mutation: insert @Override public void visitStandardTableInsert(TableInsertStandard tableInsert) { + if (tableInsert.getNumberOfReturningColumns() > 0) { + throw new FeatureNotSupportedException(); + } var tableName = tableInsert.getTableName(); var astElements = new ArrayList(tableInsert.getNumberOfValueBindings()); for (var columnValueBinding : tableInsert.getValueBindings()) { @@ -229,30 +237,57 @@ public void visitColumnWriteFragment(ColumnWriteFragment columnWriteFragment) { } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // Table Mutation: deletion + // Table Mutation: delete @Override public void visitStandardTableDelete(TableDeleteStandard tableDelete) { if (tableDelete.getWhereFragment() != null) { throw new FeatureNotSupportedException(); } + var keyFilter = getKeyFilter(tableDelete); + astVisitorValueHolder.yield( + COLLECTION_MUTATION, + new AstDeleteCommand(tableDelete.getMutatingTable().getTableName(), keyFilter)); + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // Table Mutation: update + + @Override + public void visitStandardTableUpdate(TableUpdateStandard tableUpdate) { + if (tableUpdate.getNumberOfReturningColumns() > 0) { + throw new FeatureNotSupportedException(); + } + if (tableUpdate.getWhereFragment() != null) { + throw new FeatureNotSupportedException(); + } + var keyFilter = getKeyFilter(tableUpdate); + var updates = new ArrayList(tableUpdate.getNumberOfValueBindings()); + for (var valueBinding : tableUpdate.getValueBindings()) { + var columnExpression = valueBinding.getColumnReference().getColumnExpression(); + var astValue = acceptAndYield(valueBinding.getValueExpression(), FIELD_VALUE); + updates.add(new AstFieldUpdate(columnExpression, astValue)); + } + astVisitorValueHolder.yield( + COLLECTION_MUTATION, + new AstUpdateCommand(tableUpdate.getMutatingTable().getTableName(), keyFilter, updates)); + } - if (tableDelete.getNumberOfOptimisticLockBindings() > 0) { + private AstFilter getKeyFilter(AbstractRestrictedTableMutation tableMutation) { + if (tableMutation.getNumberOfOptimisticLockBindings() > 0) { throw new FeatureNotSupportedException("TODO-HIBERNATE-51 https://jira.mongodb.org/browse/HIBERNATE-51"); } - if (tableDelete.getNumberOfKeyBindings() > 1) { + if (tableMutation.getNumberOfKeyBindings() > 1) { throw new FeatureNotSupportedException("MongoDB doesn't support '_id' spanning multiple columns"); } - assertTrue(tableDelete.getNumberOfKeyBindings() == 1); - var keyBinding = tableDelete.getKeyBindings().get(0); + assertTrue(tableMutation.getNumberOfKeyBindings() == 1); + var keyBinding = tableMutation.getKeyBindings().get(0); - var tableName = tableDelete.getMutatingTable().getTableName(); var astFilterFieldPath = new AstFilterFieldPath(keyBinding.getColumnReference().getColumnExpression()); var astValue = acceptAndYield(keyBinding.getValueExpression(), FIELD_VALUE); - var keyFilter = new AstFieldOperationFilter(astFilterFieldPath, new AstComparisonFilterOperation(EQ, astValue)); - astVisitorValueHolder.yield(COLLECTION_MUTATION, new AstDeleteCommand(tableName, keyFilter)); + return new AstFieldOperationFilter(astFilterFieldPath, new AstComparisonFilterOperation(EQ, astValue)); } @Override @@ -606,11 +641,6 @@ public void visitCustomTableDelete(TableDeleteCustomSql tableDeleteCustomSql) { throw new FeatureNotSupportedException(); } - @Override - public void visitStandardTableUpdate(TableUpdateStandard tableUpdateStandard) { - throw new FeatureNotSupportedException("TODO-HIBERNATE-19 https://jira.mongodb.org/browse/HIBERNATE-19"); - } - @Override public void visitOptionalTableUpdate(OptionalTableUpdate optionalTableUpdate) { throw new FeatureNotSupportedException(); diff --git a/src/main/java/com/mongodb/hibernate/internal/translate/NoopJdbcMutationOperation.java b/src/main/java/com/mongodb/hibernate/internal/translate/NoopJdbcMutationOperation.java deleted file mode 100644 index 75a7e7a6..00000000 --- a/src/main/java/com/mongodb/hibernate/internal/translate/NoopJdbcMutationOperation.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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.internal.translate; - -import java.util.List; -import org.hibernate.engine.jdbc.mutation.ParameterUsage; -import org.hibernate.jdbc.Expectation; -import org.hibernate.sql.exec.spi.JdbcParameterBinder; -import org.hibernate.sql.model.MutationTarget; -import org.hibernate.sql.model.MutationType; -import org.hibernate.sql.model.TableMapping; -import org.hibernate.sql.model.jdbc.JdbcMutationOperation; -import org.hibernate.sql.model.jdbc.JdbcValueDescriptor; -import org.jspecify.annotations.NullUnmarked; - -@NullUnmarked -final class NoopJdbcMutationOperation implements JdbcMutationOperation { - - NoopJdbcMutationOperation() {} - - @Override - public String getSqlString() { - return ""; - } - - @Override - public List getParameterBinders() { - return List.of(); - } - - @Override - public boolean isCallable() { - return false; - } - - @Override - public Expectation getExpectation() { - return null; - } - - @Override - public MutationType getMutationType() { - return null; - } - - @Override - public MutationTarget getMutationTarget() { - return null; - } - - @Override - public TableMapping getTableDetails() { - return null; - } - - @Override - public JdbcValueDescriptor findValueDescriptor(String columnName, ParameterUsage usage) { - return null; - } -} diff --git a/src/main/java/com/mongodb/hibernate/internal/translate/TableMutationMqlTranslator.java b/src/main/java/com/mongodb/hibernate/internal/translate/TableMutationMqlTranslator.java index 838313e6..7b9ecc27 100644 --- a/src/main/java/com/mongodb/hibernate/internal/translate/TableMutationMqlTranslator.java +++ b/src/main/java/com/mongodb/hibernate/internal/translate/TableMutationMqlTranslator.java @@ -22,8 +22,6 @@ import org.hibernate.engine.spi.SessionFactoryImplementor; import org.hibernate.query.spi.QueryOptions; import org.hibernate.sql.exec.spi.JdbcParameterBindings; -import org.hibernate.sql.model.ast.TableDelete; -import org.hibernate.sql.model.ast.TableInsert; import org.hibernate.sql.model.ast.TableMutation; import org.hibernate.sql.model.jdbc.JdbcMutationOperation; import org.jspecify.annotations.Nullable; @@ -38,20 +36,10 @@ final class TableMutationMqlTranslator extends } @Override - @SuppressWarnings("unchecked") public O translate(@Nullable JdbcParameterBindings jdbcParameterBindings, QueryOptions queryOptions) { assertNull(jdbcParameterBindings); // QueryOptions class is not applicable to table mutation so a dummy value is always passed in - if (tableMutation instanceof TableInsert || tableMutation instanceof TableDelete) { - return translateTableMutation(); - } else { - // TODO-HIBERNATE-19 https://jira.mongodb.org/browse/HIBERNATE-19 - return (O) new NoopJdbcMutationOperation(); - } - } - - private O translateTableMutation() { var rootAstNode = acceptAndYield(tableMutation, COLLECTION_MUTATION); return tableMutation.createMutationOperation(renderMongoAstNode(rootAstNode), getParameterBinders()); } diff --git a/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/AstDocument.java b/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/AstDocument.java index 82e20331..cc285426 100644 --- a/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/AstDocument.java +++ b/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/AstDocument.java @@ -19,7 +19,7 @@ import java.util.List; import org.bson.BsonWriter; -public record AstDocument(List elements) implements AstValue { +public record AstDocument(List elements) implements AstValue { @Override public void render(BsonWriter writer) { writer.writeStartDocument(); diff --git a/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/AstFieldUpdate.java b/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/AstFieldUpdate.java new file mode 100644 index 00000000..39ca2e1f --- /dev/null +++ b/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/AstFieldUpdate.java @@ -0,0 +1,27 @@ +/* + * 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.internal.translate.mongoast; + +import org.bson.BsonWriter; + +public record AstFieldUpdate(String name, AstValue value) implements AstNode { + @Override + public void render(BsonWriter writer) { + writer.writeName(name); + value.render(writer); + } +} diff --git a/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/command/AstUpdateCommand.java b/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/command/AstUpdateCommand.java new file mode 100644 index 00000000..51967163 --- /dev/null +++ b/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/command/AstUpdateCommand.java @@ -0,0 +1,57 @@ +/* + * 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.internal.translate.mongoast.command; + +import com.mongodb.hibernate.internal.translate.mongoast.AstFieldUpdate; +import com.mongodb.hibernate.internal.translate.mongoast.AstNode; +import com.mongodb.hibernate.internal.translate.mongoast.filter.AstFilter; +import java.util.List; +import org.bson.BsonWriter; + +public record AstUpdateCommand(String collection, AstFilter filter, List updates) implements AstNode { + @Override + public void render(BsonWriter writer) { + writer.writeStartDocument(); + { + writer.writeString("update", collection); + writer.writeName("updates"); + writer.writeStartArray(); + { + writer.writeStartDocument(); + { + writer.writeName("q"); + filter.render(writer); + writer.writeName("u"); + writer.writeStartDocument(); + { + writer.writeName("$set"); + writer.writeStartDocument(); + { + updates.forEach(update -> update.render(writer)); + } + writer.writeEndDocument(); + } + writer.writeEndDocument(); + writer.writeBoolean("multi", true); + } + writer.writeEndDocument(); + } + writer.writeEndArray(); + } + writer.writeEndDocument(); + } +} diff --git a/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/command/AstUpdateCommandTests.java b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/command/AstUpdateCommandTests.java new file mode 100644 index 00000000..fd083785 --- /dev/null +++ b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/command/AstUpdateCommandTests.java @@ -0,0 +1,56 @@ +/* + * 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.internal.translate.mongoast.command; + +import static com.mongodb.hibernate.internal.translate.mongoast.AstNodeAssertions.assertRender; + +import com.mongodb.hibernate.internal.translate.mongoast.AstFieldUpdate; +import com.mongodb.hibernate.internal.translate.mongoast.AstLiteralValue; +import com.mongodb.hibernate.internal.translate.mongoast.filter.AstComparisonFilterOperation; +import com.mongodb.hibernate.internal.translate.mongoast.filter.AstComparisonFilterOperator; +import com.mongodb.hibernate.internal.translate.mongoast.filter.AstFieldOperationFilter; +import com.mongodb.hibernate.internal.translate.mongoast.filter.AstFilter; +import com.mongodb.hibernate.internal.translate.mongoast.filter.AstFilterFieldPath; +import java.util.List; +import org.bson.BsonInt64; +import org.bson.BsonString; +import org.junit.jupiter.api.Test; + +class AstUpdateCommandTests { + + @Test + void testRendering() { + + var collection = "books"; + var astFieldUpdate1 = new AstFieldUpdate("title", new AstLiteralValue(new BsonString("War and Peace"))); + var astFieldUpdate2 = new AstFieldUpdate("author", new AstLiteralValue(new BsonString("Leo Tolstoy"))); + + final AstFilter filter; + filter = new AstFieldOperationFilter( + new AstFilterFieldPath("_id"), + new AstComparisonFilterOperation( + AstComparisonFilterOperator.EQ, new AstLiteralValue(new BsonInt64(12345L)))); + + var updateCommand = new AstUpdateCommand(collection, filter, List.of(astFieldUpdate1, astFieldUpdate2)); + + final String expectedJson = + """ + {"update": "books", "updates": [{"q": {"_id": {"$eq": 12345}}, "u": {"$set": {"title": "War and Peace", "author": "Leo Tolstoy"}}, "multi": true}]}\ + """; + assertRender(expectedJson, updateCommand); + } +}