From 4804b77e10410d9928ba65083791624c4f30aeb0 Mon Sep 17 00:00:00 2001 From: backend-choijunhyeong Date: Sun, 18 Jan 2026 18:34:19 +0900 Subject: [PATCH] Support `IsEmpty` and `IsNotEmpty` keywords in derived queries. Add IS_EMPTY and IS_NOT_EMPTY case handling to MongoQueryCreator. For String properties, compare with empty string using $eq/$ne. For Collection properties, use $size operator. For Map and other types, compare with empty document. Signed-off-by: backend-choijunhyeong --- .../repository/query/MongoQueryCreator.java | 60 +++++++++ ...tractPersonRepositoryIntegrationTests.java | 116 ++++++++++++++++++ .../data/mongodb/repository/Person.java | 12 ++ .../mongodb/repository/PersonRepository.java | 25 ++++ .../query/MongoQueryCreatorUnitTests.java | 85 +++++++++++++ 5 files changed, 298 insertions(+) diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryCreator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryCreator.java index 2b75ab7a4..bbdbf2018 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryCreator.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryCreator.java @@ -26,6 +26,7 @@ import java.util.regex.Pattern; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.bson.BsonRegularExpression; +import org.bson.Document; import org.jspecify.annotations.Nullable; import org.springframework.data.core.PropertyPath; @@ -65,6 +66,7 @@ import org.springframework.util.ObjectUtils; * @author Thomas Darimont * @author Christoph Strobl * @author Edward Prentice + * @author Junhyeong Choi */ public class MongoQueryCreator extends AbstractQueryCreator { @@ -218,6 +220,10 @@ public class MongoQueryCreator extends AbstractQueryCreator { return criteria.is(true); case FALSE: return criteria.is(false); + case IS_EMPTY: + return createIsEmptyCriteria(property, criteria); + case IS_NOT_EMPTY: + return createIsNotEmptyCriteria(property, criteria); case NEAR: return createNearCriteria(property, criteria, parameters); case WITHIN: @@ -254,6 +260,60 @@ public class MongoQueryCreator extends AbstractQueryCreator { return criteria.exists((Boolean) param); } + /** + * Creates a criterion for the {@literal IS_EMPTY} keyword. For {@link Collection} properties, checks if the + * collection size is 0. For {@link String} properties, checks if the value equals an empty string. For {@link Map} + * and other types, checks if the value equals an empty document. + * + * @param property the property to check. + * @param criteria the criteria to extend. + * @return the extended criteria. + * @since 5.1 + */ + protected Criteria createIsEmptyCriteria(MongoPersistentProperty property, Criteria criteria) { + + if (property.isCollectionLike()) { + return criteria.size(0); + } + + if (property.isMap()) { + return criteria.is(new Document()); + } + + if (property.getType() == String.class) { + return criteria.is(""); + } + + return criteria.is(new Document()); + } + + /** + * Creates a criterion for the {@literal IS_NOT_EMPTY} keyword. For {@link Collection} properties, checks if the + * collection size is not 0. For {@link String} properties, checks if the value is not an empty string. For + * {@link Map} and other types, checks if the value is not an empty document. + * + * @param property the property to check. + * @param criteria the criteria to extend. + * @return the extended criteria. + * @since 5.1 + */ + protected Criteria createIsNotEmptyCriteria(MongoPersistentProperty property, Criteria criteria) { + + if (property.isCollectionLike()) { + return criteria.not().size(0); + } + + if (property.isMap()) { + return criteria.ne(new Document()); + } + + if (property.getType() == String.class) { + return criteria.ne(""); + } + + return criteria.ne(new Document()); + } + private Criteria createNearCriteria(MongoPersistentProperty property, Criteria criteria, Iterator parameters) { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/AbstractPersonRepositoryIntegrationTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/AbstractPersonRepositoryIntegrationTests.java index b6cac676b..084a2401e 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/AbstractPersonRepositoryIntegrationTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/AbstractPersonRepositoryIntegrationTests.java @@ -26,8 +26,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; @@ -93,6 +95,7 @@ import org.springframework.test.util.ReflectionTestUtils; * @author Mark Paluch * @author Fırat KÜÇÜK * @author Edward Prentice + * @author Junhyeong Choi */ @ExtendWith({ SpringExtension.class, DirtiesStateExtension.class }) @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -1830,4 +1833,117 @@ public abstract class AbstractPersonRepositoryIntegrationTests implements Dirtie assertThat(repository.findById(dave.getId()).map(Person::getShippingAddresses)) .contains(Collections.singleton(address)); } + + @Test // GH-4606 + @DirtiesState + void findsByFirstnameIsEmpty() { + + Person emptyFirstname = new Person("", "EmptyFirstname"); + repository.save(emptyFirstname); + + List result = repository.findByFirstnameIsEmpty(); + + assertThat(result).hasSize(1).contains(emptyFirstname); + } + + @Test // GH-4606 + void findsByFirstnameIsNotEmpty() { + + List result = repository.findByFirstnameIsNotEmpty(); + + assertThat(result).hasSize(all.size()).containsAll(all); + } + + @Test // GH-4606 + @DirtiesState + void blankStringIsNotConsideredEmpty() { + + Person blankFirstname = new Person(" ", "BlankFirstname"); + repository.save(blankFirstname); + + List emptyResult = repository.findByFirstnameIsEmpty(); + List notEmptyResult = repository.findByFirstnameIsNotEmpty(); + + assertThat(emptyResult).doesNotContain(blankFirstname); + assertThat(notEmptyResult).contains(blankFirstname); + } + + @Test // GH-4606 + @DirtiesState + void findsBySkillsIsEmpty() { + + Person emptySkills = new Person("Empty", "Skills"); + emptySkills.setSkills(Collections.emptyList()); + repository.save(emptySkills); + + List result = repository.findBySkillsIsEmpty(); + + assertThat(result).contains(emptySkills).doesNotContain(carter, boyd); + } + + @Test // GH-4606 + void findsBySkillsIsNotEmpty() { + + List result = repository.findBySkillsIsNotEmpty(); + + assertThat(result).contains(carter, boyd); + } + + @Test // GH-4606 + @DirtiesState + void findsByAddressIsEmpty() { + + Person emptyAddress = new Person("Empty", "Address"); + emptyAddress.setAddress(new Address()); + repository.save(emptyAddress); + + dave.setAddress(new Address("street", "zip", "city")); + repository.save(dave); + + List result = repository.findByAddressIsEmpty(); + + assertThat(result).contains(emptyAddress).doesNotContain(dave); + } + + @Test // GH-4606 + @DirtiesState + void findsByAddressIsNotEmpty() { + + dave.setAddress(new Address("street", "zip", "city")); + repository.save(dave); + + List result = repository.findByAddressIsNotEmpty(); + + assertThat(result).contains(dave); + } + + @Test // GH-4606 + @DirtiesState + void findsByMetadataIsEmpty() { + + dave.setMetadata(new HashMap<>()); + repository.save(dave); + + oliver.setMetadata(Map.of("key", "value")); + repository.save(oliver); + + List result = repository.findByMetadataIsEmpty(); + + assertThat(result).contains(dave).doesNotContain(oliver); + } + + @Test // GH-4606 + @DirtiesState + void findsByMetadataIsNotEmpty() { + + dave.setMetadata(new HashMap<>()); + repository.save(dave); + + oliver.setMetadata(Map.of("key", "value")); + repository.save(oliver); + + List result = repository.findByMetadataIsNotEmpty(); + + assertThat(result).contains(oliver).doesNotContain(dave); + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/Person.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/Person.java index f40ebcc9c..19c6d1a5f 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/Person.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/Person.java @@ -18,6 +18,7 @@ package org.springframework.data.mongodb.repository; import java.util.ArrayList; import java.util.Date; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; @@ -39,6 +40,7 @@ import org.springframework.data.mongodb.core.mapping.Unwrapped; * @author Thomas Darimont * @author Christoph Strobl * @author Mark Paluch + * @author Junhyeong Choi */ @Document public class Person extends Contact { @@ -81,6 +83,8 @@ public class Person extends Contact { int visits; + Map metadata; + public Person() { this(null, null); @@ -334,6 +338,14 @@ public class Person extends Contact { this.lazySpiritAnimal = lazySpiritAnimal; } + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + @Override public int hashCode() { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepository.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepository.java index f46c9153d..1d9414b61 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepository.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepository.java @@ -56,6 +56,7 @@ import org.springframework.data.util.Streamable; * @author Christoph Strobl * @author Fırat KÜÇÜK * @author Mark Paluch + * @author Junhyeong Choi */ public interface PersonRepository extends MongoRepository, QuerydslPredicateExecutor { @@ -510,6 +511,30 @@ public interface PersonRepository extends MongoRepository, Query List findBySpiritAnimal(User user); + // GH-4606 + List findByFirstnameIsEmpty(); + + // GH-4606 + List findByFirstnameIsNotEmpty(); + + // GH-4606 + List findBySkillsIsEmpty(); + + // GH-4606 + List findBySkillsIsNotEmpty(); + + // GH-4606 + List findByAddressIsEmpty(); + + // GH-4606 + List findByAddressIsNotEmpty(); + + // GH-4606 + List findByMetadataIsEmpty(); + + // GH-4606 + List findByMetadataIsNotEmpty(); + class Persons implements Streamable { private final Streamable streamable; diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/MongoQueryCreatorUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/MongoQueryCreatorUnitTests.java index f94937668..409a6a481 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/MongoQueryCreatorUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/MongoQueryCreatorUnitTests.java @@ -22,6 +22,7 @@ import static org.springframework.data.mongodb.test.util.Assertions.*; import java.lang.reflect.Method; import java.util.List; +import java.util.Map; import java.util.regex.Pattern; import org.bson.BsonRegularExpression; @@ -65,6 +66,7 @@ import org.springframework.data.repository.query.parser.PartTree; * @author Oliver Gierke * @author Thomas Darimont * @author Christoph Strobl + * @author Junhyeong Choi */ class MongoQueryCreatorUnitTests { @@ -670,6 +672,87 @@ class MongoQueryCreatorUnitTests { assertThat(creator.createQuery()).isEqualTo(query(where("location").nearSphere(point).maxDistance(1000.0D))); } + @Test // GH-4606 + void createsIsEmptyQueryForStringPropertyCorrectly() { + + PartTree tree = new PartTree("findByFirstNameIsEmpty", Person.class); + MongoQueryCreator creator = new MongoQueryCreator(tree, getAccessor(converter), context); + + Query query = creator.createQuery(); + assertThat(query).isEqualTo(query(where("firstName").is(""))); + } + + @Test // GH-4606 + void createsIsNotEmptyQueryForStringPropertyCorrectly() { + + PartTree tree = new PartTree("findByFirstNameIsNotEmpty", Person.class); + MongoQueryCreator creator = new MongoQueryCreator(tree, getAccessor(converter), context); + + Query query = creator.createQuery(); + assertThat(query).isEqualTo(query(where("firstName").ne(""))); + } + + @Test // GH-4606 + void createsIsEmptyQueryForCollectionPropertyCorrectly() { + + PartTree tree = new PartTree("findByEmailAddressesIsEmpty", User.class); + MongoQueryCreator creator = new MongoQueryCreator(tree, getAccessor(converter), context); + + Query query = creator.createQuery(); + assertThat(query).isEqualTo(query(where("emailAddresses").size(0))); + } + + @Test // GH-4606 + void createsIsNotEmptyQueryForCollectionPropertyCorrectly() { + + PartTree tree = new PartTree("findByEmailAddressesIsNotEmpty", User.class); + MongoQueryCreator creator = new MongoQueryCreator(tree, getAccessor(converter), context); + + Query query = creator.createQuery(); + // size > 0 is equivalent to exists and not empty + assertThat(query).isEqualTo(query(where("emailAddresses").not().size(0))); + } + + @Test // GH-4606 + void createsIsEmptyQueryForMapPropertyCorrectly() { + + PartTree tree = new PartTree("findByMetadataIsEmpty", User.class); + MongoQueryCreator creator = new MongoQueryCreator(tree, getAccessor(converter), context); + + Query query = creator.createQuery(); + assertThat(query).isEqualTo(query(where("metadata").is(new Document()))); + } + + @Test // GH-4606 + void createsIsNotEmptyQueryForMapPropertyCorrectly() { + + PartTree tree = new PartTree("findByMetadataIsNotEmpty", User.class); + MongoQueryCreator creator = new MongoQueryCreator(tree, getAccessor(converter), context); + + Query query = creator.createQuery(); + assertThat(query).isEqualTo(query(where("metadata").ne(new Document()))); + } + + @Test // GH-4606 + void createsIsEmptyQueryForDomainTypePropertyCorrectly() { + + PartTree tree = new PartTree("findByAddressIsEmpty", User.class); + MongoQueryCreator creator = new MongoQueryCreator(tree, getAccessor(converter), context); + + Query query = creator.createQuery(); + assertThat(query).isEqualTo(query(where("address").is(new Document()))); + } + + @Test // GH-4606 + void createsIsNotEmptyQueryForDomainTypePropertyCorrectly() { + + PartTree tree = new PartTree("findByAddressIsNotEmpty", User.class); + MongoQueryCreator creator = new MongoQueryCreator(tree, getAccessor(converter), context); + + Query query = creator.createQuery(); + assertThat(query).isEqualTo(query(where("address").ne(new Document()))); + } + interface PersonRepository extends Repository { List findByLocationNearAndFirstname(Point location, Distance maxDistance, String firstname); @@ -685,6 +768,8 @@ class MongoQueryCreatorUnitTests { List emailAddresses; + Map metadata; + Address address; Address2dSphere address2dSphere;