diff --git a/src/integrationTest/java/com/mongodb/hibernate/BasicCrudIntegrationTests.java b/src/integrationTest/java/com/mongodb/hibernate/BasicCrudIntegrationTests.java index cd115ecc..ccda9e95 100644 --- a/src/integrationTest/java/com/mongodb/hibernate/BasicCrudIntegrationTests.java +++ b/src/integrationTest/java/com/mongodb/hibernate/BasicCrudIntegrationTests.java @@ -82,10 +82,16 @@ void testSimpleEntityInsertion() { @Test void testEntityWithNullFieldValueInsertion() { + var author = + """ + TODO-HIBERNATE-74 https://jira.mongodb.org/browse/HIBERNATE-74, + TODO-HIBERNATE-48 https://jira.mongodb.org/browse/HIBERNATE-48 Make sure `book.author` + is set to `null` when we implement `MongoPreparedStatement.setNull` properly."""; sessionFactoryScope.inTransaction(session -> { var book = new Book(); book.id = 1; book.title = "War and Peace"; + book.author = author; book.publishYear = 1867; session.persist(book); }); @@ -94,9 +100,10 @@ void testEntityWithNullFieldValueInsertion() { { _id: 1, title: "War and Peace", - author: null, + author: "%s", publishYear: 1867 - }"""); + }""" + .formatted(author)); assertCollectionContainsExactly(expectedDocument); } @@ -221,6 +228,11 @@ void testGetByPrimaryKeyWithNullValueField() { var book = new Book(); book.id = 1; book.title = "Brave New World"; + book.author = + """ + TODO-HIBERNATE-74 https://jira.mongodb.org/browse/HIBERNATE-74, + TODO-HIBERNATE-48 https://jira.mongodb.org/browse/HIBERNATE-48 Make sure `book.author` + is set to `null` when we implement `MongoPreparedStatement.setNull` properly."""; book.publishYear = 1932; sessionFactoryScope.inTransaction(session -> session.persist(book)); diff --git a/src/integrationTest/java/com/mongodb/hibernate/TestCommandListener.java b/src/integrationTest/java/com/mongodb/hibernate/TestCommandListener.java new file mode 100644 index 00000000..23378d7e --- /dev/null +++ b/src/integrationTest/java/com/mongodb/hibernate/TestCommandListener.java @@ -0,0 +1,49 @@ +/* + * 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; + +import com.mongodb.event.CommandListener; +import com.mongodb.event.CommandStartedEvent; +import java.io.Serial; +import java.util.ArrayList; +import java.util.List; +import org.bson.BsonDocument; +import org.hibernate.service.Service; + +public final class TestCommandListener implements CommandListener, Service { + @Serial + private static final long serialVersionUID = 1L; + + static TestCommandListener INSTANCE = new TestCommandListener(); + + private final List startedCommands = new ArrayList<>(); + + private TestCommandListener() {} + + @Override + public synchronized void commandStarted(CommandStartedEvent event) { + startedCommands.add(event.getCommand().clone()); + } + + public synchronized List getStartedCommands() { + return List.copyOf(startedCommands); + } + + public synchronized void clear() { + startedCommands.clear(); + } +} diff --git a/src/integrationTest/java/com/mongodb/hibernate/TestServiceContributor.java b/src/integrationTest/java/com/mongodb/hibernate/TestServiceContributor.java new file mode 100644 index 00000000..4729d458 --- /dev/null +++ b/src/integrationTest/java/com/mongodb/hibernate/TestServiceContributor.java @@ -0,0 +1,35 @@ +/* + * 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; + +import com.mongodb.hibernate.service.spi.MongoConfigurationContributor; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.service.spi.ServiceContributor; + +public final class TestServiceContributor implements ServiceContributor { + + public TestServiceContributor() {} + + @Override + public void contribute(StandardServiceRegistryBuilder serviceRegistryBuilder) { + serviceRegistryBuilder.addService( + MongoConfigurationContributor.class, + configurator -> configurator.applyToMongoClientSettings( + builder -> builder.addCommandListener(TestCommandListener.INSTANCE))); + serviceRegistryBuilder.addService(TestCommandListener.class, TestCommandListener.INSTANCE); + } +} diff --git a/src/integrationTest/java/com/mongodb/hibernate/query/select/Book.java b/src/integrationTest/java/com/mongodb/hibernate/query/select/Book.java new file mode 100644 index 00000000..82e8ef62 --- /dev/null +++ b/src/integrationTest/java/com/mongodb/hibernate/query/select/Book.java @@ -0,0 +1,41 @@ +/* + * 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.query.select; + +import com.mongodb.hibernate.annotations.ObjectIdGenerator; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import org.bson.types.ObjectId; + +@Entity(name = "Book") +@Table(name = "books") +public class Book { + @Id + @ObjectIdGenerator + ObjectId id; + + public Book() {} + + String title; + Boolean outOfStock; + Integer publishYear; + Long isbn13; + Double discount; + BigDecimal price; +} diff --git a/src/integrationTest/java/com/mongodb/hibernate/query/select/SimpleSelectQueryIntegrationTests.java b/src/integrationTest/java/com/mongodb/hibernate/query/select/SimpleSelectQueryIntegrationTests.java new file mode 100644 index 00000000..2eede1df --- /dev/null +++ b/src/integrationTest/java/com/mongodb/hibernate/query/select/SimpleSelectQueryIntegrationTests.java @@ -0,0 +1,478 @@ +/* + * 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.query.select; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.mongodb.hibernate.TestCommandListener; +import com.mongodb.hibernate.internal.FeatureNotSupportedException; +import com.mongodb.hibernate.junit.MongoExtension; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.bson.BsonDocument; +import org.hibernate.query.SelectionQuery; +import org.hibernate.query.SemanticException; +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; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@SessionFactory(exportSchema = false) +@DomainModel(annotatedClasses = {SimpleSelectQueryIntegrationTests.Contact.class, Book.class}) +@ExtendWith(MongoExtension.class) +class SimpleSelectQueryIntegrationTests implements SessionFactoryScopeAware, ServiceRegistryScopeAware { + + private SessionFactoryScope sessionFactoryScope; + + private TestCommandListener testCommandListener; + + @Override + public void injectSessionFactoryScope(SessionFactoryScope sessionFactoryScope) { + this.sessionFactoryScope = sessionFactoryScope; + } + + @Override + public void injectServiceRegistryScope(ServiceRegistryScope serviceRegistryScope) { + this.testCommandListener = serviceRegistryScope.getRegistry().requireService(TestCommandListener.class); + } + + @Nested + class QueryTests { + + private static final List testingContacts = List.of( + new Contact(1, "Bob", 18, Country.USA), + new Contact(2, "Mary", 35, Country.CANADA), + new Contact(3, "Dylan", 7, Country.CANADA), + new Contact(4, "Lucy", 78, Country.CANADA), + new Contact(5, "John", 25, Country.USA)); + + private static List getTestingContacts(int... ids) { + return Arrays.stream(ids) + .mapToObj(id -> testingContacts.stream() + .filter(c -> c.id == id) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("id not exists: " + id))) + .toList(); + } + + @BeforeEach + void beforeEach() { + sessionFactoryScope.inTransaction(session -> testingContacts.forEach(session::persist)); + testCommandListener.clear(); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testComparisonByEq(boolean fieldAsLhs) { + assertSelectionQuery( + "from Contact where " + (fieldAsLhs ? "country = :country" : ":country = country"), + Contact.class, + q -> q.setParameter("country", Country.USA.name()), + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'country': {'$eq': 'USA'}}}, {'$project': {'_id': true, 'age': true, 'country': true, 'name': true}}]}", + getTestingContacts(1, 5)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testComparisonByNe(boolean fieldAsLhs) { + assertSelectionQuery( + "from Contact where " + (fieldAsLhs ? "country != ?1" : "?1 != country"), + Contact.class, + q -> q.setParameter(1, Country.USA.name()), + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'country': {'$ne': 'USA'}}}, {'$project': {'_id': true, 'age': true, 'country': true, 'name': true}}]}", + getTestingContacts(2, 3, 4)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testComparisonByLt(boolean fieldAsLhs) { + assertSelectionQuery( + "from Contact where " + (fieldAsLhs ? "age < :age" : ":age > age"), + Contact.class, + q -> q.setParameter("age", 35), + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'age': {'$lt': 35}}}, {'$project': {'_id': true, 'age': true, 'country': true, 'name': true}}]}", + getTestingContacts(1, 3, 5)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testComparisonByLte(boolean fieldAsLhs) { + assertSelectionQuery( + "from Contact where " + (fieldAsLhs ? "age <= ?1" : "?1 >= age"), + Contact.class, + q -> q.setParameter(1, 35), + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'age': {'$lte': 35}}}, {'$project': {'_id': true, 'age': true, 'country': true, 'name': true}}]}", + getTestingContacts(1, 2, 3, 5)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testComparisonByGt(boolean fieldAsLhs) { + assertSelectionQuery( + "from Contact where " + (fieldAsLhs ? "age > :age" : ":age < age"), + Contact.class, + q -> q.setParameter("age", 18), + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'age': {'$gt': 18}}}, {'$project': {'_id': true, 'age': true, 'country': true, 'name': true}}]}", + getTestingContacts(2, 4, 5)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testComparisonByGte(boolean fieldAsLhs) { + assertSelectionQuery( + "from Contact where " + (fieldAsLhs ? "age >= :age" : ":age <= age"), + Contact.class, + q -> q.setParameter("age", 18), + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'age': {'$gte': 18}}}, {'$project': {'_id': true, 'age': true, 'country': true, 'name': true}}]}", + getTestingContacts(1, 2, 4, 5)); + } + + @Test + void testAndFilter() { + assertSelectionQuery( + "from Contact where country = ?1 and age > ?2", + Contact.class, + q -> q.setParameter(1, Country.CANADA.name()).setParameter(2, 18), + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'$and': [{'country': {'$eq': 'CANADA'}}, {'age': {'$gt': 18}}]}}, {'$project': {'_id': true, 'age': true, 'country': true, 'name': true}}]}", + getTestingContacts(2, 4)); + } + + @Test + void testOrFilter() { + assertSelectionQuery( + "from Contact where country = :country or age > :age", + Contact.class, + q -> q.setParameter("country", Country.CANADA.name()).setParameter("age", 18), + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'$or': [{'country': {'$eq': 'CANADA'}}, {'age': {'$gt': 18}}]}}, {'$project': {'_id': true, 'age': true, 'country': true, 'name': true}}]}", + getTestingContacts(2, 3, 4, 5)); + } + + @Test + void testSingleNegation() { + assertSelectionQuery( + "from Contact where age > 18 and not (country = 'USA')", + Contact.class, + null, + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'$and': [{'age': {'$gt': 18}}, {'$nor': [{'country': {'$eq': 'USA'}}]}]}}, {'$project': {'_id': true, 'age': true, 'country': true, 'name': true}}]}", + getTestingContacts(2, 4)); + } + + @Test + void testSingleNegationWithAnd() { + assertSelectionQuery( + "from Contact where not (country = 'USA' and age > 18)", + Contact.class, + null, + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'$nor': [{'$and': [{'country': {'$eq': 'USA'}}, {'age': {'$gt': {'$numberInt': '18'}}}]}]}}, {'$project': {'_id': true, 'age': true, 'country': true, 'name': true}}]}", + getTestingContacts(1, 2, 3, 4)); + } + + @Test + void testSingleNegationWithOr() { + assertSelectionQuery( + "from Contact where not (country = 'USA' or age > 18)", + Contact.class, + null, + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'$nor': [{'$or': [{'country': {'$eq': 'USA'}}, {'age': {'$gt': {'$numberInt': '18'}}}]}]}}, {'$project': {'_id': true, 'age': true, 'country': true, 'name': true}}]}", + getTestingContacts(3)); + } + + @Test + void testSingleNegationWithAndOr() { + assertSelectionQuery( + "from Contact where not (country = 'USA' and age > 18 or age < 25)", + Contact.class, + null, + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'$nor': [{'$or': [{'$and': [{'country': {'$eq': 'USA'}}, {'age': {'$gt': {'$numberInt': '18'}}}]}," + + " {'age': {'$lt': {'$numberInt': '25'}}}]}]}}, {'$project': {'_id': true, 'age': true, 'country': true, 'name': true}}]}", + getTestingContacts(2, 4)); + } + + @Test + void testDoubleNegation() { + assertSelectionQuery( + "from Contact where age > 18 and not ( not (country = 'USA') )", + Contact.class, + null, + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'$and': [{'age': {'$gt': 18}}, {'$nor': [{'$nor': [{'country': {'$eq': 'USA'}}]}]}]}}, {'$project': {'_id': true, 'age': true, 'country': true, 'name': true}}]}", + getTestingContacts(5)); + } + + @Test + void testProjectWithoutAlias() { + assertSelectionQuery( + "select name, age from Contact where country = :country", + Object[].class, + q -> q.setParameter("country", Country.CANADA.name()), + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'country': {'$eq': 'CANADA'}}}, {'$project': {'name': true, 'age': true}}]}", + List.of(new Object[] {"Mary", 35}, new Object[] {"Dylan", 7}, new Object[] {"Lucy", 78})); + } + + @Test + void testProjectUsingAlias() { + assertSelectionQuery( + "select c.name, c.age from Contact as c where c.country = :country", + Object[].class, + q -> q.setParameter("country", Country.CANADA.name()), + "{'aggregate': 'contacts', 'pipeline': [{'$match': {'country': {'$eq': 'CANADA'}}}, {'$project': {'name': true, 'age': true}}]}", + List.of(new Object[] {"Mary", 35}, new Object[] {"Dylan", 7}, new Object[] {"Lucy", 78})); + } + + @Test + void testProjectUsingWrongAlias() { + assertSelectQueryFailure( + "select k.name, c.age from Contact as c where c.country = :country", + Contact.class, + null, + SemanticException.class, + "Could not interpret path expression '%s'", + "k.name"); + } + } + + @Nested + class FeatureNotSupportedTests { + @Test + void testComparisonBetweenFieldAndNonValueNotSupported1() { + assertSelectQueryFailure( + "from Contact as c where c.age = c.id + 1", + Contact.class, + null, + FeatureNotSupportedException.class, + "Only the following comparisons are supported: field vs literal, field vs parameter"); + } + + @Test + void testComparisonBetweenValuesNotSupported() { + assertSelectQueryFailure( + "from Contact where 1 = 1", + Contact.class, + null, + FeatureNotSupportedException.class, + "Only the following comparisons are supported: field vs literal, field vs parameter"); + } + + @Test + void testComparisonBetweenFieldsNotSupported() { + assertSelectQueryFailure( + "from Contact where age = id", + Contact.class, + null, + FeatureNotSupportedException.class, + "Only the following comparisons are supported: field vs literal, field vs parameter"); + } + + @Test + void testComparisonBetweenParameterAndValueNotSupported() { + assertSelectQueryFailure( + "from Contact where :param = 1", + Contact.class, + q -> q.setParameter("param", 1), + FeatureNotSupportedException.class, + "Only the following comparisons are supported: field vs literal, field vs parameter"); + } + + @Test + void testComparisonBetweenParametersNotSupported() { + assertSelectQueryFailure( + "from Contact where :param = :param", + Contact.class, + q -> q.setParameter("param", 1), + FeatureNotSupportedException.class, + "Only the following comparisons are supported: field vs literal, field vs parameter"); + } + + @Test + void testNullParameterNotSupported() { + assertSelectQueryFailure( + "from Contact where country = :country", + Contact.class, + q -> q.setParameter("country", null), + FeatureNotSupportedException.class, + "TODO-HIBERNATE-74 https://jira.mongodb.org/browse/HIBERNATE-74"); + } + } + + @Nested + class QueryLiteralTests { + + private Book testingBook; + + @BeforeEach + void beforeEach() { + testingBook = new Book(); + testingBook.title = "Holy Bible"; + testingBook.outOfStock = true; + testingBook.publishYear = 1995; + testingBook.isbn13 = 9780310904168L; + testingBook.discount = 0.25; + testingBook.price = new BigDecimal("123.50"); + sessionFactoryScope.inTransaction(session -> session.persist(testingBook)); + + testCommandListener.clear(); + } + + @Test + void testBoolean() { + assertSelectionQuery( + "from Book where outOfStock = true", + Book.class, + null, + "{'aggregate': 'books', 'pipeline': [{'$match': {'outOfStock': {'$eq': true}}}, {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}", + singletonList(testingBook)); + } + + @Test + void testInteger() { + assertSelectionQuery( + "from Book where publishYear = 1995", + Book.class, + null, + "{'aggregate': 'books', 'pipeline': [{'$match': {'publishYear': {'$eq': 1995}}}, {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}", + singletonList(testingBook)); + } + + @Test + void testLong() { + assertSelectionQuery( + "from Book where isbn13 = 9780310904168L", + Book.class, + null, + "{'aggregate': 'books', 'pipeline': [{'$match': {'isbn13': {'$eq': 9780310904168}}}, {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}", + singletonList(testingBook)); + } + + @Test + void testDouble() { + assertSelectionQuery( + "from Book where discount = 0.25D", + Book.class, + null, + "{'aggregate': 'books', 'pipeline': [{'$match': {'discount': {'$eq': 0.25}}}, {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}", + singletonList(testingBook)); + } + + @Test + void testString() { + assertSelectionQuery( + "from Book where title = 'Holy Bible'", + Book.class, + null, + "{'aggregate': 'books', 'pipeline': [{'$match': {'title': {'$eq': 'Holy Bible'}}}, {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}", + singletonList(testingBook)); + } + + @Test + void testBigDecimal() { + assertSelectionQuery( + "from Book where price = 123.50BD", + Book.class, + null, + "{'aggregate': 'books', 'pipeline': [{'$match': {'price': {'$eq': {'$numberDecimal': '123.50'}}}}, {'$project': {'_id': true, 'discount': true, 'isbn13': true, 'outOfStock': true, 'price': true, 'publishYear': true, 'title': true}}]}", + singletonList(testingBook)); + } + } + + private void assertSelectionQuery( + String hql, + Class resultType, + Consumer> queryPostProcessor, + String expectedMql, + List expectedResultList) { + sessionFactoryScope.inTransaction(session -> { + var selectionQuery = session.createSelectionQuery(hql, resultType); + if (queryPostProcessor != null) { + queryPostProcessor.accept(selectionQuery); + } + var resultList = selectionQuery.getResultList(); + + assertActualCommand(BsonDocument.parse(expectedMql)); + + assertThat(resultList) + .usingRecursiveFieldByFieldElementComparator() + .containsExactlyElementsOf(expectedResultList); + }); + } + + private void assertSelectQueryFailure( + String hql, + Class resultType, + Consumer> queryPostProcessor, + Class expectedExceptionType, + String expectedExceptionMessage, + Object... expectedExceptionMessageParameters) { + sessionFactoryScope.inTransaction(session -> assertThatThrownBy(() -> { + var selectionQuery = session.createSelectionQuery(hql, resultType); + if (queryPostProcessor != null) { + queryPostProcessor.accept(selectionQuery); + } + selectionQuery.getResultList(); + }) + .isInstanceOf(expectedExceptionType) + .hasMessage(expectedExceptionMessage, expectedExceptionMessageParameters)); + } + + private void assertActualCommand(BsonDocument expectedCommand) { + var capturedCommands = testCommandListener.getStartedCommands(); + + assertThat(capturedCommands) + .singleElement() + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsAllEntriesOf(expectedCommand); + } + + @Entity(name = "Contact") + @Table(name = "contacts") + static class Contact { + @Id + int id; + + String name; + int age; + String country; + + Contact() {} + + Contact(int id, String name, int age, Country country) { + this.id = id; + this.name = name; + this.age = age; + this.country = country.name(); + } + } + + enum Country { + USA, + CANADA + } +} diff --git a/src/integrationTest/java/com/mongodb/hibernate/type/ObjectIdIntegrationTests.java b/src/integrationTest/java/com/mongodb/hibernate/type/ObjectIdIntegrationTests.java index d6019c13..b56e4e69 100644 --- a/src/integrationTest/java/com/mongodb/hibernate/type/ObjectIdIntegrationTests.java +++ b/src/integrationTest/java/com/mongodb/hibernate/type/ObjectIdIntegrationTests.java @@ -32,7 +32,6 @@ import jakarta.persistence.Table; import org.bson.BsonDocument; import org.bson.BsonInt32; -import org.bson.BsonNull; import org.bson.BsonObjectId; import org.bson.types.ObjectId; import org.hibernate.annotations.JavaType; @@ -64,13 +63,17 @@ void insert() { var item = new Item(); item.id = 1; item.v = new ObjectId(1, 0); + // TODO-HIBERNATE-74 https://jira.mongodb.org/browse/HIBERNATE-74, + // TODO-HIBERNATE-48 https://jira.mongodb.org/browse/HIBERNATE-48 Make sure `item.vNull` is set to `null` + // when we implement `MongoPreparedStatement.setNull` properly. + item.vNull = new ObjectId(); item.vExplicitlyAnnotatedNotForThePublic = new ObjectId(2, 3); sessionFactoryScope.inTransaction(session -> session.persist(item)); assertThat(mongoCollection.find()) .containsExactly(new BsonDocument() .append(ID_FIELD_NAME, new BsonInt32(1)) .append("v", new BsonObjectId(item.v)) - .append("vNull", BsonNull.VALUE) + .append("vNull", new BsonObjectId(item.vNull)) .append( "vExplicitlyAnnotatedNotForThePublic", new BsonObjectId(item.vExplicitlyAnnotatedNotForThePublic))); @@ -81,6 +84,11 @@ void getById() { var item = new Item(); item.id = 1; item.v = new ObjectId(2, 0); + // TODO-HIBERNATE-74 https://jira.mongodb.org/browse/HIBERNATE-74, + // TODO-HIBERNATE-48 https://jira.mongodb.org/browse/HIBERNATE-48 Make sure `item.vNull` is set to `null` + // when we implement `MongoPreparedStatement.setNull` properly. + item.vNull = new ObjectId(); + item.vExplicitlyAnnotatedNotForThePublic = new ObjectId(3, 4); sessionFactoryScope.inTransaction(session -> session.persist(item)); var loadedItem = sessionFactoryScope.fromTransaction(session -> session.get(Item.class, item.id)); assertEquals(item, loadedItem); diff --git a/src/integrationTest/resources/META-INF/services/org.hibernate.service.spi.ServiceContributor b/src/integrationTest/resources/META-INF/services/org.hibernate.service.spi.ServiceContributor new file mode 100644 index 00000000..8dfbbf99 --- /dev/null +++ b/src/integrationTest/resources/META-INF/services/org.hibernate.service.spi.ServiceContributor @@ -0,0 +1 @@ +com.mongodb.hibernate.TestServiceContributor \ No newline at end of file diff --git a/src/integrationTest/resources/hibernate.properties b/src/integrationTest/resources/hibernate.properties index c28b08b7..b59c4af4 100644 --- a/src/integrationTest/resources/hibernate.properties +++ b/src/integrationTest/resources/hibernate.properties @@ -1,3 +1,4 @@ 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 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 61f1d716..8656104f 100644 --- a/src/main/java/com/mongodb/hibernate/internal/translate/AbstractMqlTranslator.java +++ b/src/main/java/com/mongodb/hibernate/internal/translate/AbstractMqlTranslator.java @@ -28,6 +28,14 @@ import static com.mongodb.hibernate.internal.translate.AstVisitorValueDescriptor.FILTER; import static com.mongodb.hibernate.internal.translate.AstVisitorValueDescriptor.PROJECT_STAGE_SPECIFICATIONS; import static com.mongodb.hibernate.internal.translate.mongoast.filter.AstComparisonFilterOperator.EQ; +import static com.mongodb.hibernate.internal.translate.mongoast.filter.AstComparisonFilterOperator.GT; +import static com.mongodb.hibernate.internal.translate.mongoast.filter.AstComparisonFilterOperator.GTE; +import static com.mongodb.hibernate.internal.translate.mongoast.filter.AstComparisonFilterOperator.LT; +import static com.mongodb.hibernate.internal.translate.mongoast.filter.AstComparisonFilterOperator.LTE; +import static com.mongodb.hibernate.internal.translate.mongoast.filter.AstComparisonFilterOperator.NE; +import static com.mongodb.hibernate.internal.translate.mongoast.filter.AstLogicalFilterOperator.AND; +import static com.mongodb.hibernate.internal.translate.mongoast.filter.AstLogicalFilterOperator.NOR; +import static com.mongodb.hibernate.internal.translate.mongoast.filter.AstLogicalFilterOperator.OR; import static java.lang.String.format; import com.mongodb.hibernate.internal.FeatureNotSupportedException; @@ -35,6 +43,7 @@ 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.AstLiteralValue; import com.mongodb.hibernate.internal.translate.mongoast.AstNode; import com.mongodb.hibernate.internal.translate.mongoast.AstParameterMarker; import com.mongodb.hibernate.internal.translate.mongoast.command.AstCommand; @@ -51,21 +60,33 @@ 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 com.mongodb.hibernate.internal.translate.mongoast.filter.AstLogicalFilter; import java.io.IOException; import java.io.StringWriter; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; +import org.bson.BsonBoolean; +import org.bson.BsonDecimal128; +import org.bson.BsonDouble; +import org.bson.BsonInt32; +import org.bson.BsonInt64; +import org.bson.BsonString; +import org.bson.BsonValue; import org.bson.json.JsonMode; import org.bson.json.JsonWriter; import org.bson.json.JsonWriterSettings; +import org.bson.types.Decimal128; import org.hibernate.engine.spi.SessionFactoryImplementor; import org.hibernate.internal.util.collections.Stack; import org.hibernate.persister.entity.EntityPersister; import org.hibernate.persister.internal.SqlFragmentPredicate; import org.hibernate.query.spi.QueryOptions; import org.hibernate.query.sqm.ComparisonOperator; +import org.hibernate.query.sqm.sql.internal.BasicValuedPathInterpretation; +import org.hibernate.query.sqm.sql.internal.SqmParameterInterpretation; import org.hibernate.query.sqm.tree.expression.Conversion; import org.hibernate.sql.ast.Clause; import org.hibernate.sql.ast.SqlAstNodeRenderingMode; @@ -88,10 +109,12 @@ import org.hibernate.sql.ast.tree.expression.EmbeddableTypeLiteral; import org.hibernate.sql.ast.tree.expression.EntityTypeLiteral; import org.hibernate.sql.ast.tree.expression.Every; +import org.hibernate.sql.ast.tree.expression.Expression; import org.hibernate.sql.ast.tree.expression.ExtractUnit; import org.hibernate.sql.ast.tree.expression.Format; import org.hibernate.sql.ast.tree.expression.JdbcLiteral; import org.hibernate.sql.ast.tree.expression.JdbcParameter; +import org.hibernate.sql.ast.tree.expression.Literal; import org.hibernate.sql.ast.tree.expression.ModifiedSubQueryExpression; import org.hibernate.sql.ast.tree.expression.NestedColumnReference; import org.hibernate.sql.ast.tree.expression.Over; @@ -127,6 +150,7 @@ import org.hibernate.sql.ast.tree.predicate.LikePredicate; import org.hibernate.sql.ast.tree.predicate.NegatedPredicate; import org.hibernate.sql.ast.tree.predicate.NullnessPredicate; +import org.hibernate.sql.ast.tree.predicate.Predicate; import org.hibernate.sql.ast.tree.predicate.SelfRenderingPredicate; import org.hibernate.sql.ast.tree.predicate.ThruthnessPredicate; import org.hibernate.sql.ast.tree.select.QueryGroup; @@ -139,6 +163,7 @@ 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.exec.spi.JdbcParameterBindings; import org.hibernate.sql.model.MutationOperation; import org.hibernate.sql.model.ast.AbstractRestrictedTableMutation; import org.hibernate.sql.model.ast.ColumnWriteFragment; @@ -149,6 +174,7 @@ import org.hibernate.sql.model.internal.TableInsertStandard; import org.hibernate.sql.model.internal.TableUpdateCustomSql; import org.hibernate.sql.model.internal.TableUpdateStandard; +import org.jspecify.annotations.Nullable; abstract class AbstractMqlTranslator implements SqlAstTranslator { private static final JsonWriterSettings JSON_WRITER_SETTINGS = @@ -379,22 +405,42 @@ public void visitNamedTableReference(NamedTableReference namedTableReference) { @Override public void visitRelationalPredicate(ComparisonPredicate comparisonPredicate) { - var astComparisonFilterOperator = getAstComparisonFilterOperator(comparisonPredicate.getOperator()); + if (!isComparingFieldWithValue(comparisonPredicate)) { + throw new FeatureNotSupportedException( + "Only the following comparisons are supported: field vs literal, field vs parameter"); + } + + var lhs = comparisonPredicate.getLeftHandExpression(); + var rhs = comparisonPredicate.getRightHandExpression(); + + var isFieldOnLeftHandSide = isFieldPathExpression(lhs); + if (!isFieldOnLeftHandSide) { + assertTrue(isFieldPathExpression(rhs)); + } + + var fieldPath = acceptAndYield((isFieldOnLeftHandSide ? lhs : rhs), FIELD_PATH); + var comparisonValue = acceptAndYield((isFieldOnLeftHandSide ? rhs : lhs), FIELD_VALUE); - var fieldPath = acceptAndYield(comparisonPredicate.getLeftHandExpression(), FIELD_PATH); - var fieldValue = acceptAndYield(comparisonPredicate.getRightHandExpression(), FIELD_VALUE); + var operator = isFieldOnLeftHandSide + ? comparisonPredicate.getOperator() + : comparisonPredicate.getOperator().invert(); + var astComparisonFilterOperator = getAstComparisonFilterOperator(operator); - var filter = new AstFieldOperationFilter( - new AstFilterFieldPath(fieldPath), - new AstComparisonFilterOperation(astComparisonFilterOperator, fieldValue)); + var astFilterOperation = new AstComparisonFilterOperation(astComparisonFilterOperator, comparisonValue); + var filter = new AstFieldOperationFilter(new AstFilterFieldPath(fieldPath), astFilterOperation); astVisitorValueHolder.yield(FILTER, filter); } - private static AstComparisonFilterOperator getAstComparisonFilterOperator(ComparisonOperator operator) { - return switch (operator) { - case EQUAL -> EQ; - default -> throw new FeatureNotSupportedException("Unsupported operator: " + operator.name()); - }; + @Override + public void visitNegatedPredicate(NegatedPredicate negatedPredicate) { + var filter = acceptAndYield(negatedPredicate.getPredicate(), FILTER); + astVisitorValueHolder.yield(FILTER, new AstLogicalFilter(NOR, List.of(filter))); + } + + @Override + public void visitGroupedPredicate(GroupedPredicate groupedPredicate) { + var filter = acceptAndYield(groupedPredicate.getSubPredicate(), FILTER); + astVisitorValueHolder.yield(FILTER, filter); } @Override @@ -426,6 +472,32 @@ public void visitColumnReference(ColumnReference columnReference) { astVisitorValueHolder.yield(FIELD_PATH, columnReference.getColumnExpression()); } + @Override + public void visitQueryLiteral(QueryLiteral queryLiteral) { + var bsonValue = toBsonValue(queryLiteral.getLiteralValue()); + astVisitorValueHolder.yield(FIELD_VALUE, new AstLiteralValue(bsonValue)); + } + + @Override + public void visitJunction(Junction junction) { + var subFilters = new ArrayList(junction.getPredicates().size()); + for (Predicate predicate : junction.getPredicates()) { + subFilters.add(acceptAndYield(predicate, FILTER)); + } + var junctionFilter = + switch (junction.getNature()) { + case DISJUNCTION -> new AstLogicalFilter(OR, subFilters); + case CONJUNCTION -> new AstLogicalFilter(AND, subFilters); + }; + astVisitorValueHolder.yield(FILTER, junctionFilter); + } + + @Override + public void visitUnparsedNumericLiteral(UnparsedNumericLiteral unparsedNumericLiteral) { + astVisitorValueHolder.yield( + FIELD_VALUE, new AstLiteralValue(toBsonValue(unparsedNumericLiteral.getLiteralValue()))); + } + @Override public void visitDeleteStatement(DeleteStatement deleteStatement) { throw new FeatureNotSupportedException("TODO-HIBERNATE-46 https://jira.mongodb.org/browse/HIBERNATE-46"); @@ -611,16 +683,6 @@ public void visitJdbcLiteral(JdbcLiteral jdbcLiteral) { throw new FeatureNotSupportedException(); } - @Override - public void visitQueryLiteral(QueryLiteral queryLiteral) { - throw new FeatureNotSupportedException(); - } - - @Override - public void visitUnparsedNumericLiteral(UnparsedNumericLiteral unparsedNumericLiteral) { - throw new FeatureNotSupportedException(); - } - @Override public void visitUnaryOperationExpression(UnaryOperation unaryOperation) { throw new FeatureNotSupportedException(); @@ -656,11 +718,6 @@ public void visitSqlFragmentPredicate(SqlFragmentPredicate sqlFragmentPredicate) throw new FeatureNotSupportedException(); } - @Override - public void visitGroupedPredicate(GroupedPredicate groupedPredicate) { - throw new FeatureNotSupportedException(); - } - @Override public void visitInListPredicate(InListPredicate inListPredicate) { throw new FeatureNotSupportedException(); @@ -681,21 +738,11 @@ public void visitExistsPredicate(ExistsPredicate existsPredicate) { throw new FeatureNotSupportedException(); } - @Override - public void visitJunction(Junction junction) { - throw new FeatureNotSupportedException(); - } - @Override public void visitLikePredicate(LikePredicate likePredicate) { throw new FeatureNotSupportedException(); } - @Override - public void visitNegatedPredicate(NegatedPredicate negatedPredicate) { - throw new FeatureNotSupportedException(); - } - @Override public void visitNullnessPredicate(NullnessPredicate nullnessPredicate) { throw new FeatureNotSupportedException(); @@ -746,7 +793,18 @@ public void visitCustomTableUpdate(TableUpdateCustomSql tableUpdateCustomSql) { throw new FeatureNotSupportedException(); } - void checkQueryOptionsSupportability(QueryOptions queryOptions) { + static void checkJdbcParameterBindingsSupportability(@Nullable JdbcParameterBindings jdbcParameterBindings) { + if (jdbcParameterBindings != null) { + for (var jdbcParameterBinding : jdbcParameterBindings.getBindings()) { + if (jdbcParameterBinding.getBindValue() == null) { + throw new FeatureNotSupportedException( + "TODO-HIBERNATE-74 https://jira.mongodb.org/browse/HIBERNATE-74"); + } + } + } + } + + static void checkQueryOptionsSupportability(QueryOptions queryOptions) { if (queryOptions.getTimeout() != null) { throw new FeatureNotSupportedException("'timeout' inQueryOptions not supported"); } @@ -756,7 +814,8 @@ void checkQueryOptionsSupportability(QueryOptions queryOptions) { if (Boolean.TRUE.equals(queryOptions.isReadOnly())) { throw new FeatureNotSupportedException("'readOnly' in QueryOptions not supported"); } - if (queryOptions.getAppliedGraph() != null) { + if (queryOptions.getAppliedGraph() != null + && queryOptions.getAppliedGraph().getGraph() != null) { throw new FeatureNotSupportedException("'appliedGraph' in QueryOptions not supported"); } if (queryOptions.getTupleTransformer() != null) { @@ -783,9 +842,6 @@ void checkQueryOptionsSupportability(QueryOptions queryOptions) { && !queryOptions.getLockOptions().isEmpty()) { throw new FeatureNotSupportedException("'lockOptions' in QueryOptions not supported"); } - if (queryOptions.getComment() != null) { - throw new FeatureNotSupportedException("TODO-HIBERNATE-53 https://jira.mongodb.org/browse/HIBERNATE-53"); - } if (queryOptions.getDatabaseHints() != null && !queryOptions.getDatabaseHints().isEmpty()) { throw new FeatureNotSupportedException("'databaseHints' in QueryOptions not supported"); @@ -797,4 +853,58 @@ void checkQueryOptionsSupportability(QueryOptions queryOptions) { throw new FeatureNotSupportedException("TODO-HIBERNATE-70 https://jira.mongodb.org/browse/HIBERNATE-70"); } } + + private static AstComparisonFilterOperator getAstComparisonFilterOperator(ComparisonOperator operator) { + return switch (operator) { + case EQUAL -> EQ; + case NOT_EQUAL -> NE; + case LESS_THAN -> LT; + case LESS_THAN_OR_EQUAL -> LTE; + case GREATER_THAN -> GT; + case GREATER_THAN_OR_EQUAL -> GTE; + default -> throw new FeatureNotSupportedException("Unsupported comparison operator: " + operator.name()); + }; + } + + private static boolean isFieldPathExpression(Expression expression) { + return expression instanceof ColumnReference || expression instanceof BasicValuedPathInterpretation; + } + + private static boolean isValueExpression(Expression expression) { + return expression instanceof Literal + || expression instanceof JdbcParameter + || expression instanceof SqmParameterInterpretation; + } + + private static boolean isComparingFieldWithValue(ComparisonPredicate comparisonPredicate) { + var lhs = comparisonPredicate.getLeftHandExpression(); + var rhs = comparisonPredicate.getRightHandExpression(); + return (isFieldPathExpression(lhs) && isValueExpression(rhs)) + || (isFieldPathExpression(rhs) && isValueExpression(lhs)); + } + + private static BsonValue toBsonValue(@Nullable Object queryLiteral) { + if (queryLiteral == null) { + throw new FeatureNotSupportedException("TODO-HIBERNATE-74 https://jira.mongodb.org/browse/HIBERNATE-74"); + } + if (queryLiteral instanceof Boolean boolValue) { + return BsonBoolean.valueOf(boolValue); + } + if (queryLiteral instanceof Integer intValue) { + return new BsonInt32(intValue); + } + if (queryLiteral instanceof Long longValue) { + return new BsonInt64(longValue); + } + if (queryLiteral instanceof Double doubleValue) { + return new BsonDouble(doubleValue); + } + if (queryLiteral instanceof BigDecimal bigDecimalValue) { + return new BsonDecimal128(new Decimal128(bigDecimalValue)); + } + if (queryLiteral instanceof String stringValue) { + return new BsonString(stringValue); + } + throw new FeatureNotSupportedException("Unsupported Java type: " + queryLiteral.getClass()); + } } diff --git a/src/main/java/com/mongodb/hibernate/internal/translate/SelectMqlTranslator.java b/src/main/java/com/mongodb/hibernate/internal/translate/SelectMqlTranslator.java index dfa6e131..3a745ac2 100644 --- a/src/main/java/com/mongodb/hibernate/internal/translate/SelectMqlTranslator.java +++ b/src/main/java/com/mongodb/hibernate/internal/translate/SelectMqlTranslator.java @@ -19,7 +19,6 @@ import static com.mongodb.hibernate.internal.translate.AstVisitorValueDescriptor.COLLECTION_AGGREGATE; import static org.hibernate.sql.ast.SqlTreePrinter.logSqlAst; -import com.mongodb.hibernate.internal.FeatureNotSupportedException; import org.hibernate.engine.spi.SessionFactoryImplementor; import org.hibernate.query.spi.QueryOptions; import org.hibernate.sql.ast.tree.Statement; @@ -47,9 +46,7 @@ public JdbcOperationQuerySelect translate( logSqlAst(selectStatement); - if (jdbcParameterBindings != null) { - throw new FeatureNotSupportedException(); - } + checkJdbcParameterBindingsSupportability(jdbcParameterBindings); checkQueryOptionsSupportability(queryOptions); var aggregateCommand = acceptAndYield((Statement) selectStatement, COLLECTION_AGGREGATE); diff --git a/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstComparisonFilterOperator.java b/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstComparisonFilterOperator.java index b74ba86c..01f6c5d9 100644 --- a/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstComparisonFilterOperator.java +++ b/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstComparisonFilterOperator.java @@ -17,7 +17,12 @@ package com.mongodb.hibernate.internal.translate.mongoast.filter; public enum AstComparisonFilterOperator { - EQ("$eq"); + EQ("$eq"), + GT("$gt"), + GTE("$gte"), + LT("$lt"), + LTE("$lte"), + NE("$ne"); AstComparisonFilterOperator(String operatorName) { this.operatorName = operatorName; diff --git a/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstLogicalFilter.java b/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstLogicalFilter.java new file mode 100644 index 00000000..08a1e64c --- /dev/null +++ b/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstLogicalFilter.java @@ -0,0 +1,44 @@ +/* + * 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.filter; + +import static com.mongodb.hibernate.internal.MongoAssertions.assertFalse; + +import java.util.List; +import org.bson.BsonWriter; + +public record AstLogicalFilter(AstLogicalFilterOperator operator, List filters) + implements AstFilter { + + public AstLogicalFilter { + assertFalse(filters.isEmpty()); + } + + @Override + public void render(BsonWriter writer) { + writer.writeStartDocument(); + { + writer.writeName(operator.getOperatorName()); + writer.writeStartArray(); + { + filters.forEach(filter -> filter.render(writer)); + } + writer.writeEndArray(); + } + writer.writeEndDocument(); + } +} diff --git a/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstLogicalFilterOperator.java b/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstLogicalFilterOperator.java new file mode 100644 index 00000000..17927300 --- /dev/null +++ b/src/main/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstLogicalFilterOperator.java @@ -0,0 +1,33 @@ +/* + * 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.filter; + +public enum AstLogicalFilterOperator { + AND("$and"), + OR("$or"), + NOR("$nor"); + + AstLogicalFilterOperator(String operatorName) { + this.operatorName = operatorName; + } + + String getOperatorName() { + return operatorName; + } + + private final String operatorName; +} diff --git a/src/main/java/com/mongodb/hibernate/jdbc/MongoPreparedStatement.java b/src/main/java/com/mongodb/hibernate/jdbc/MongoPreparedStatement.java index 200f9303..264f8acb 100644 --- a/src/main/java/com/mongodb/hibernate/jdbc/MongoPreparedStatement.java +++ b/src/main/java/com/mongodb/hibernate/jdbc/MongoPreparedStatement.java @@ -46,7 +46,6 @@ 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.BsonType; @@ -114,7 +113,8 @@ public void setNull(int parameterIndex, int sqlType) throws SQLException { throw new SQLFeatureNotSupportedException( "Unsupported sql type: " + JDBCType.valueOf(sqlType).getName()); } - setParameter(parameterIndex, BsonNull.VALUE); + throw new SQLFeatureNotSupportedException( + "TODO-HIBERNATE-74 https://jira.mongodb.org/browse/HIBERNATE-74, TODO-HIBERNATE-48 https://jira.mongodb.org/browse/HIBERNATE-48"); } @Override diff --git a/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstComparisonFilterOperationTests.java b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstComparisonFilterOperationTests.java index b4156e35..d3d9a478 100644 --- a/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstComparisonFilterOperationTests.java +++ b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstComparisonFilterOperationTests.java @@ -17,22 +17,23 @@ package com.mongodb.hibernate.internal.translate.mongoast.filter; import static com.mongodb.hibernate.internal.translate.mongoast.AstNodeAssertions.assertRender; -import static com.mongodb.hibernate.internal.translate.mongoast.filter.AstComparisonFilterOperator.EQ; import com.mongodb.hibernate.internal.translate.mongoast.AstLiteralValue; import org.bson.BsonInt32; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; class AstComparisonFilterOperationTests { - @Test - void testRendering() { - - var astComparisonFilterOperation = new AstComparisonFilterOperation(EQ, new AstLiteralValue(new BsonInt32(1))); + @ParameterizedTest + @EnumSource(AstComparisonFilterOperator.class) + void testRendering(AstComparisonFilterOperator operator) { + var operation = new AstComparisonFilterOperation(operator, new AstLiteralValue(new BsonInt32(1))); var expectedJson = """ - {"$eq": 1}\ - """; - assertRender(expectedJson, astComparisonFilterOperation); + {"%s": 1}\ + """ + .formatted(operator.getOperatorName()); + assertRender(expectedJson, operation); } } diff --git a/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstComparisonFilterOperatorTests.java b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstComparisonFilterOperatorTests.java new file mode 100644 index 00000000..947a252a --- /dev/null +++ b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstComparisonFilterOperatorTests.java @@ -0,0 +1,39 @@ +/* + * 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.filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class AstComparisonFilterOperatorTests { + + @ParameterizedTest + @CsvSource({ + "EQ,$eq", + "GT,$gt", + "GTE,$gte", + "LT,$lt", + "LTE,$lte", + "NE,$ne", + }) + void testRendering(String operatorValue, String expectedRennderResult) { + var operator = AstComparisonFilterOperator.valueOf(operatorValue); + assertEquals(expectedRennderResult, operator.getOperatorName()); + } +} diff --git a/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstFieldOperationFilterTests.java b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstFieldOperationFilterTests.java index 02f4dc4c..eb2476fa 100644 --- a/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstFieldOperationFilterTests.java +++ b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstFieldOperationFilterTests.java @@ -18,8 +18,8 @@ import static com.mongodb.hibernate.internal.translate.mongoast.AstNodeAssertions.assertRender; import static com.mongodb.hibernate.internal.translate.mongoast.filter.AstComparisonFilterOperator.EQ; +import static com.mongodb.hibernate.internal.translate.mongoast.filter.FilterTestUtils.createFieldOperationFilter; -import com.mongodb.hibernate.internal.translate.mongoast.AstLiteralValue; import org.bson.BsonInt32; import org.junit.jupiter.api.Test; @@ -28,9 +28,7 @@ class AstFieldOperationFilterTests { @Test void testRendering() { - var astFilterFieldPath = new AstFilterFieldPath("fieldName"); - var astComparisonFilterOperation = new AstComparisonFilterOperation(EQ, new AstLiteralValue(new BsonInt32(1))); - var astFieldOperationFilter = new AstFieldOperationFilter(astFilterFieldPath, astComparisonFilterOperation); + var astFieldOperationFilter = createFieldOperationFilter("fieldName", EQ, new BsonInt32(1)); var expectedJson = """ {"fieldName": {"$eq": 1}}\ diff --git a/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstLogicalFilterOperatorTests.java b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstLogicalFilterOperatorTests.java new file mode 100644 index 00000000..0f6a34a4 --- /dev/null +++ b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstLogicalFilterOperatorTests.java @@ -0,0 +1,36 @@ +/* + * 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.filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class AstLogicalFilterOperatorTests { + + @ParameterizedTest + @CsvSource({ + "AND,$and", + "OR,$or", + "NOR,$nor", + }) + void testRendering(String operatorValue, String expectedRenderResult) { + var operator = AstLogicalFilterOperator.valueOf(operatorValue); + assertEquals(expectedRenderResult, operator.getOperatorName()); + } +} diff --git a/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstLogicalFilterTests.java b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstLogicalFilterTests.java new file mode 100644 index 00000000..5eb10d93 --- /dev/null +++ b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/AstLogicalFilterTests.java @@ -0,0 +1,46 @@ +/* + * 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.filter; + +import static com.mongodb.hibernate.internal.translate.mongoast.AstNodeAssertions.assertRender; +import static com.mongodb.hibernate.internal.translate.mongoast.filter.AstComparisonFilterOperator.EQ; +import static com.mongodb.hibernate.internal.translate.mongoast.filter.FilterTestUtils.createFieldOperationFilter; + +import java.util.List; +import org.bson.BsonInt32; +import org.bson.BsonString; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class AstLogicalFilterTests { + @ParameterizedTest + @EnumSource(AstLogicalFilterOperator.class) + void testRendering(AstLogicalFilterOperator operator) { + var astLogicalFilter = new AstLogicalFilter( + operator, + List.of( + createFieldOperationFilter("field1", EQ, new BsonInt32(1)), + createFieldOperationFilter("field2", EQ, new BsonString("1")))); + + var expectedJson = + """ + {"%s": [{"field1": {"$eq": 1}}, {"field2": {"$eq": "1"}}]}\ + """ + .formatted(operator.getOperatorName()); + assertRender(expectedJson, astLogicalFilter); + } +} diff --git a/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/FilterTestUtils.java b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/FilterTestUtils.java new file mode 100644 index 00000000..917abdaa --- /dev/null +++ b/src/test/java/com/mongodb/hibernate/internal/translate/mongoast/filter/FilterTestUtils.java @@ -0,0 +1,32 @@ +/* + * 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.filter; + +import com.mongodb.hibernate.internal.translate.mongoast.AstLiteralValue; +import org.bson.BsonValue; + +final class FilterTestUtils { + + private FilterTestUtils() {} + + static AstFieldOperationFilter createFieldOperationFilter( + String fieldPath, AstComparisonFilterOperator operator, BsonValue value) { + var filterFieldPath = new AstFilterFieldPath(fieldPath); + var filterOperation = new AstComparisonFilterOperation(operator, new AstLiteralValue(value)); + return new AstFieldOperationFilter(filterFieldPath, filterOperation); + } +}