From 713cb2dbbcb360bab5295d606c5cfa8be980761e Mon Sep 17 00:00:00 2001 From: seonwoo_jung Date: Sat, 20 Jun 2026 06:27:36 +0900 Subject: [PATCH] Honor standalone @Collation on entities and propagate to repository base methods. Read the standalone @Collation annotation on the entity type from BasicMongoPersistentEntity. @Document.collation() keeps working through @AliasFor merging; standalone @Collation now contributes the entity collation even when @Document is absent or omits collation. Propagate the entity collation through SimpleMongoRepository and SimpleReactiveMongoRepository base CRUD methods (findById, existsById, findAll, count, deleteById, deleteAll, ...) so the collation is honored consistently, matching the behavior of Example-based methods. Closes: #4535 Signed-off-by: seonwoo_jung --- .../mapping/BasicMongoPersistentEntity.java | 16 ++- .../support/SimpleMongoRepository.java | 15 +++ .../SimpleReactiveMongoRepository.java | 18 +++ .../BasicMongoPersistentEntityUnitTests.java | 25 ++++ .../SimpleMongoRepositoryUnitTests.java | 109 ++++++++++++++++++ ...impleReactiveMongoRepositoryUnitTests.java | 55 +++++++++ 6 files changed, 236 insertions(+), 2 deletions(-) diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntity.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntity.java index 93f29a1a70..9491d78f8c 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntity.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntity.java @@ -27,6 +27,7 @@ import org.jspecify.annotations.Nullable; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.data.annotation.Id; import org.springframework.data.core.TypeInformation; import org.springframework.data.expression.ValueEvaluationContext; @@ -96,13 +97,24 @@ public BasicMongoPersistentEntity(TypeInformation typeInformation) { this.collection = StringUtils.hasText(document.collection()) ? document.collection() : fallback; this.language = StringUtils.hasText(document.language()) ? document.language() : ""; this.expression = detectExpression(document.collection()); - this.collation = document.collation(); - this.collationExpression = detectExpression(document.collation()); } else { this.collection = fallback; this.language = ""; this.expression = null; + } + + // Pick up the standalone @Collation annotation independently of @Document. @Document.collation + // remains supported because @Document is meta-annotated with @Collation and Document#collation + // is declared as @AliasFor(annotation = Collation.class, attribute = "value"), so the merged + // lookup returns the same value in either case. + org.springframework.data.mongodb.core.annotation.Collation collationAnnotation = AnnotatedElementUtils + .findMergedAnnotation(rawType, org.springframework.data.mongodb.core.annotation.Collation.class); + + if (collationAnnotation != null && StringUtils.hasText(collationAnnotation.value())) { + this.collation = collationAnnotation.value(); + this.collationExpression = detectExpression(collationAnnotation.value()); + } else { this.collation = null; this.collationExpression = null; } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SimpleMongoRepository.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SimpleMongoRepository.java index 735a4fda53..10b48efe00 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SimpleMongoRepository.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SimpleMongoRepository.java @@ -124,6 +124,7 @@ public Optional findById(ID id) { Assert.notNull(id, "The given id must not be null"); Query query = getIdQuery(id); + applyDefaultCollation(query); getReadPreference().ifPresent(query::withReadPreference); return Optional.ofNullable( @@ -136,6 +137,7 @@ public boolean existsById(ID id) { Assert.notNull(id, "The given id must not be null"); Query query = getIdQuery(id); + applyDefaultCollation(query); getReadPreference().ifPresent(query::withReadPreference); return mongoOperations.exists(query, entityInformation.getJavaType(), entityInformation.getCollectionName()); @@ -158,6 +160,7 @@ public List findAllById(Iterable ids) { public long count() { Query query = new Query(); + applyDefaultCollation(query); getReadPreference().ifPresent(query::withReadPreference); return mongoOperations.count(query, entityInformation.getCollectionName()); } @@ -168,6 +171,7 @@ public void deleteById(ID id) { Assert.notNull(id, "The given id must not be null"); Query query = getIdQuery(id); + applyDefaultCollation(query); getReadPreference().ifPresent(query::withReadPreference); mongoOperations.remove(query, entityInformation.getJavaType(), entityInformation.getCollectionName()); } @@ -193,6 +197,7 @@ public void deleteAllById(Iterable ids) { Assert.notNull(ids, "The given Iterable of ids must not be null"); Query query = getIdQuery(ids); + applyDefaultCollation(query); getReadPreference().ifPresent(query::withReadPreference); mongoOperations.remove(query, entityInformation.getJavaType(), entityInformation.getCollectionName()); } @@ -209,6 +214,7 @@ public void deleteAll(Iterable entities) { public void deleteAll() { Query query = new Query(); + applyDefaultCollation(query); getReadPreference().ifPresent(query::withReadPreference); mongoOperations.remove(query, entityInformation.getCollectionName()); @@ -385,6 +391,7 @@ private Criteria getIdCriteria(Object id) { private Query getIdQuery(Iterable ids) { Query query = new Query(new Criteria(entityInformation.getIdAttribute()).in(toCollection(ids))); + applyDefaultCollation(query); getReadPreference().ifPresent(query::withReadPreference); return query; } @@ -400,10 +407,18 @@ private List findAll(@Nullable Query query) { return Collections.emptyList(); } + applyDefaultCollation(query); getReadPreference().ifPresent(query::withReadPreference); return mongoOperations.find(query, entityInformation.getJavaType(), entityInformation.getCollectionName()); } + private void applyDefaultCollation(Query query) { + + if (entityInformation.hasCollation() && !query.getCollation().isPresent()) { + query.collation(entityInformation.getCollation()); + } + } + /** * {@link org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery} using {@link Example}. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SimpleReactiveMongoRepository.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SimpleReactiveMongoRepository.java index 5fc0c8c867..92d8213a46 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SimpleReactiveMongoRepository.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/SimpleReactiveMongoRepository.java @@ -130,6 +130,7 @@ public Mono findById(ID id) { Assert.notNull(id, "The given id must not be null"); Query query = getIdQuery(id); + applyDefaultCollation(query); getReadPreference().ifPresent(query::withReadPreference); return mongoOperations.findOne(query, entityInformation.getJavaType(), entityInformation.getCollectionName()); } @@ -142,6 +143,7 @@ public Mono findById(Publisher publisher) { return Mono.from(publisher).flatMap(id -> { Query query = getIdQuery(id); + applyDefaultCollation(query); readPreference.ifPresent(query::withReadPreference); return mongoOperations.findOne(query, entityInformation.getJavaType(), entityInformation.getCollectionName()); }); @@ -153,6 +155,7 @@ public Mono existsById(ID id) { Assert.notNull(id, "The given id must not be null"); Query query = getIdQuery(id); + applyDefaultCollation(query); getReadPreference().ifPresent(query::withReadPreference); return mongoOperations.exists(query, entityInformation.getJavaType(), entityInformation.getCollectionName()); } @@ -165,6 +168,7 @@ public Mono existsById(Publisher publisher) { return Mono.from(publisher).flatMap(id -> { Query query = getIdQuery(id); + applyDefaultCollation(query); readPreference.ifPresent(query::withReadPreference); return mongoOperations.exists(query, entityInformation.getJavaType(), entityInformation.getCollectionName()); }); @@ -191,6 +195,7 @@ public Flux findAllById(Publisher ids) { Optional readPreference = getReadPreference(); return Flux.from(ids).buffer().flatMapSequential(listOfIds -> { Query query = getIdQuery(listOfIds); + applyDefaultCollation(query); readPreference.ifPresent(query::withReadPreference); return mongoOperations.find(query, entityInformation.getJavaType(), entityInformation.getCollectionName()); }); @@ -200,6 +205,7 @@ public Flux findAllById(Publisher ids) { public Mono count() { Query query = new Query(); + applyDefaultCollation(query); getReadPreference().ifPresent(query::withReadPreference); return mongoOperations.count(query, entityInformation.getCollectionName()); } @@ -217,6 +223,7 @@ private Mono deleteById(ID id, Optional readPreference) { Assert.notNull(id, "The given id must not be null"); Query query = getIdQuery(id); + applyDefaultCollation(query); readPreference.ifPresent(query::withReadPreference); return mongoOperations.remove(query, entityInformation.getJavaType(), entityInformation.getCollectionName()).then(); } @@ -230,6 +237,7 @@ public Mono deleteById(Publisher publisher) { return Mono.from(publisher).flatMap(id -> { Query query = getIdQuery(id); + applyDefaultCollation(query); readPreference.ifPresent(query::withReadPreference); return mongoOperations.remove(query, entityInformation.getJavaType(), entityInformation.getCollectionName()); }).then(); @@ -272,6 +280,7 @@ public Mono deleteAllById(Iterable ids) { private Mono deleteAllById(Iterable ids, Optional readPreference) { Query query = getIdQuery(ids); + applyDefaultCollation(query); readPreference.ifPresent(query::withReadPreference); return mongoOperations.remove(query, entityInformation.getJavaType(), entityInformation.getCollectionName()).then(); @@ -302,6 +311,7 @@ public Mono deleteAll(Publisher entityStream) { @Override public Mono deleteAll() { Query query = new Query(); + applyDefaultCollation(query); getReadPreference().ifPresent(query::withReadPreference); return mongoOperations.remove(query, entityInformation.getCollectionName()).then(Mono.empty()); } @@ -444,10 +454,18 @@ void setRepositoryMethodMetadata(CrudMethodMetadata crudMethodMetadata) { private Flux findAll(Query query) { + applyDefaultCollation(query); getReadPreference().ifPresent(query::withReadPreference); return mongoOperations.find(query, entityInformation.getJavaType(), entityInformation.getCollectionName()); } + private void applyDefaultCollation(Query query) { + + if (entityInformation.hasCollation() && !query.getCollation().isPresent()) { + query.collation(entityInformation.getCollation()); + } + } + private Optional getReadPreference() { if (crudMethodMetadata == null) { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntityUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntityUnitTests.java index 23231dde29..24c7ef26fa 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntityUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntityUnitTests.java @@ -296,6 +296,24 @@ void usesCorrectExpressionsForCollectionAndCollation() { assertThat(entity.getCollation()).isEqualTo(Collation.of("en_US")); } + @Test // GH-4535 + void readsStandaloneCollationAnnotation() { + + BasicMongoPersistentEntity entity = new BasicMongoPersistentEntity<>( + TypeInformation.of(WithStandaloneCollation.class)); + + assertThat(entity.getCollation()).isEqualTo(Collation.of("en_US")); + } + + @Test // GH-4535 + void readsStandaloneCollationAnnotationWithoutDocumentAnnotation() { + + BasicMongoPersistentEntity entity = new BasicMongoPersistentEntity<>( + TypeInformation.of(WithStandaloneCollationOnly.class)); + + assertThat(entity.getCollation()).isEqualTo(Collation.of("en_US")); + } + @Test // DATAMONGO-2341 void detectsShardedEntityCorrectly() { @@ -398,6 +416,13 @@ class WithSimpleCollation {} @Document(collation = "{ 'locale' : 'en_US' }") class WithDocumentCollation {} + @Document + @org.springframework.data.mongodb.core.annotation.Collation("en_US") + class WithStandaloneCollation {} + + @org.springframework.data.mongodb.core.annotation.Collation("en_US") + class WithStandaloneCollationOnly {} + @Sharded private class WithDefaultShardKey {} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/SimpleMongoRepositoryUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/SimpleMongoRepositoryUnitTests.java index 2437aa7a0a..b46793777e 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/SimpleMongoRepositoryUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/SimpleMongoRepositoryUnitTests.java @@ -145,6 +145,105 @@ public void shouldAddDefaultCollationToFindOneForExampleIfPresent() { assertThat(query.getValue().getCollation()).contains(collation); } + @ParameterizedTest // GH-4535 + @MethodSource("findCallsThatAcceptDefaultCollation") + void shouldAddDefaultCollationToFindMethods( + Consumer> findCall) { + + Collation collation = Collation.of("en_US"); + when(entityInformation.hasCollation()).thenReturn(true); + when(entityInformation.getCollation()).thenReturn(collation); + + findCall.accept(repository); + + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongoOperations).find(query.capture(), any(), any()); + + assertThat(query.getValue().getCollation()).contains(collation); + } + + @Test // GH-4535 + void shouldAddDefaultCollationToFindById() { + + Collation collation = Collation.of("en_US"); + when(entityInformation.hasCollation()).thenReturn(true); + when(entityInformation.getCollation()).thenReturn(collation); + when(entityInformation.getIdAttribute()).thenReturn("id"); + + repository.findById("42"); + + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongoOperations).findOne(query.capture(), any(), any()); + + assertThat(query.getValue().getCollation()).contains(collation); + } + + @Test // GH-4535 + void shouldAddDefaultCollationToExistsById() { + + Collation collation = Collation.of("en_US"); + when(entityInformation.hasCollation()).thenReturn(true); + when(entityInformation.getCollation()).thenReturn(collation); + when(entityInformation.getIdAttribute()).thenReturn("id"); + + repository.existsById("42"); + + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongoOperations).exists(query.capture(), any(), any()); + + assertThat(query.getValue().getCollation()).contains(collation); + } + + @Test // GH-4535 + void shouldAddDefaultCollationToCount() { + + Collation collation = Collation.of("en_US"); + when(entityInformation.hasCollation()).thenReturn(true); + when(entityInformation.getCollation()).thenReturn(collation); + when(entityInformation.getCollectionName()).thenReturn("documents"); + + repository.count(); + + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongoOperations).count(query.capture(), any(String.class)); + + assertThat(query.getValue().getCollation()).contains(collation); + } + + @Test // GH-4535 + void shouldAddDefaultCollationToDeleteById() { + + Collation collation = Collation.of("en_US"); + when(entityInformation.hasCollation()).thenReturn(true); + when(entityInformation.getCollation()).thenReturn(collation); + when(entityInformation.getIdAttribute()).thenReturn("id"); + when(entityInformation.getJavaType()).thenReturn(Object.class); + when(entityInformation.getCollectionName()).thenReturn("documents"); + + repository.deleteById("42"); + + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongoOperations).remove(query.capture(), any(Class.class), any(String.class)); + + assertThat(query.getValue().getCollation()).contains(collation); + } + + @Test // GH-4535 + void shouldAddDefaultCollationToDeleteAll() { + + Collation collation = Collation.of("en_US"); + when(entityInformation.hasCollation()).thenReturn(true); + when(entityInformation.getCollation()).thenReturn(collation); + when(entityInformation.getCollectionName()).thenReturn("documents"); + + repository.deleteAll(); + + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongoOperations).remove(query.capture(), any(String.class)); + + assertThat(query.getValue().getCollation()).contains(collation); + } + @ParameterizedTest // GH-2971 @MethodSource("findAllCalls") void shouldAddReadPreferenceToFindAllMethods(Consumer> findCall) @@ -219,6 +318,16 @@ private static Stream findAllCalls() { Arguments.of(findAllWithExampleAndPage)); } + private static Stream findCallsThatAcceptDefaultCollation() { + + Consumer> findAll = SimpleMongoRepository::findAll; + Consumer> findAllWithSort = repo -> repo.findAll(Sort.by("age")); + Consumer> findAllWithPage = repo -> repo + .findAll(PageRequest.of(1, 20, Sort.by("age"))); + + return Stream.of(Arguments.of(findAll), Arguments.of(findAllWithSort), Arguments.of(findAllWithPage)); + } + static class TestDummy { } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/SimpleReactiveMongoRepositoryUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/SimpleReactiveMongoRepositoryUnitTests.java index a3c541a176..aaf7621a01 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/SimpleReactiveMongoRepositoryUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/support/SimpleReactiveMongoRepositoryUnitTests.java @@ -150,6 +150,61 @@ void shouldAddDefaultCollationToFindOneForExampleIfPresent() { assertThat(query.getValue().getCollation()).contains(collation); } + @Test // GH-4535 + void shouldAddDefaultCollationToFindById() { + + when(mongoOperations.findOne(any(), any(), any())).thenReturn(mono); + + Collation collation = Collation.of("en_US"); + when(entityInformation.hasCollation()).thenReturn(true); + when(entityInformation.getCollation()).thenReturn(collation); + when(entityInformation.getIdAttribute()).thenReturn("id"); + + repository.findById("42").subscribe(); + + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongoOperations).findOne(query.capture(), any(), any()); + + assertThat(query.getValue().getCollation()).contains(collation); + } + + @Test // GH-4535 + void shouldAddDefaultCollationToCount() { + + when(mongoOperations.count(any(), anyString())).thenReturn(mono); + + Collation collation = Collation.of("en_US"); + when(entityInformation.hasCollation()).thenReturn(true); + when(entityInformation.getCollation()).thenReturn(collation); + when(entityInformation.getCollectionName()).thenReturn("testdummy"); + + repository.count().subscribe(); + + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongoOperations).count(query.capture(), anyString()); + + assertThat(query.getValue().getCollation()).contains(collation); + } + + @Test // GH-4535 + void shouldAddDefaultCollationToDeleteAll() { + + when(mongoOperations.remove(any(Query.class), anyString())).thenReturn(mono); + when(mono.then(any(Mono.class))).thenReturn(Mono.empty()); + + Collation collation = Collation.of("en_US"); + when(entityInformation.hasCollation()).thenReturn(true); + when(entityInformation.getCollation()).thenReturn(collation); + when(entityInformation.getCollectionName()).thenReturn("testdummy"); + + repository.deleteAll().subscribe(); + + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongoOperations).remove(query.capture(), anyString()); + + assertThat(query.getValue().getCollation()).contains(collation); + } + @ParameterizedTest // GH-2971 @MethodSource("findAllCalls") void shouldAddReadPreferenceToFindAllMethods(