diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/CompoundWildcardIndexDefinition.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/CompoundWildcardIndexDefinition.java new file mode 100644 index 000000000..8dc99cd84 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/CompoundWildcardIndexDefinition.java @@ -0,0 +1,60 @@ +/* + * Copyright 2014-2023 the original author or authors. + * + * 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 + * + * https://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 org.springframework.data.mongodb.core.index; + +import org.bson.Document; +import org.springframework.util.Assert; + +/** + * {@link CompoundWildcardIndexDefinition} is a specific {@link Index} that includes one {@link WildcardIndex} and + * one or more non-wildcard fields. + * + * @author Julia Lee + * @since 4.2 + */ +public class CompoundWildcardIndexDefinition extends WildcardIndex { + + private final Document indexKeys; + + /** + * Creates a new {@link CompoundWildcardIndexDefinition} for the given {@literal wildcardPath} and {@literal keys}. + * If {@literal wildcardPath} is empty, the wildcard index will apply to the root entity, using {@code $**}. + *
+ * + * @param wildcardPath can be a {@literal empty} {@link String}. + */ + public CompoundWildcardIndexDefinition(String wildcardPath, Document indexKeys) { + + super(wildcardPath); + this.indexKeys = indexKeys; + } + + @Override + public Document getIndexKeys() { + + Document document = new Document(); + document.putAll(indexKeys); + document.putAll(super.getIndexKeys()); + return document; + } + + @Override + public Document getIndexOptions() { + + Document options = super.getIndexOptions(); + return options; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/CompoundWildcardIndexed.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/CompoundWildcardIndexed.java new file mode 100644 index 000000000..78b06f2ac --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/CompoundWildcardIndexed.java @@ -0,0 +1,131 @@ +/* + * Copyright 2011-2023 the original author or authors. + * + * 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 + * + * https://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 org.springframework.data.mongodb.core.index; + +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Mark a class to use compound wildcard indexes.
+ * + *
+ * @Document
+ * @CompoundWildcardIndexed(wildcardFieldName = "address", fields = "{'firstname': 1}")
+ * class Person {
+ * 	String firstname;
+ * 	Address address;
+ * }
+ *
+ * db.product.createIndex({"address.$**": 1, "firstname": 1})
+ * 
+ * + * {@literal wildcardProjection} can be used to specify keys to in-/exclude in the index. + * + *
+ *
+ * @Document
+ * @CompoundWildcardIndexed(wildcardProjection = "{'address.zip': 0}", fields = "{'firstname': 1}")
+ * class Person {
+ * 	String firstname;
+ * 	Address address;
+ * }
+ *
+ * db.user.createIndex({"$**": 1, "firstname": 1}, {"wildcardProjection": {"address.zip": 0}})
+ * 
+ * + * @author Julia Lee + */ +@Target({ ElementType.TYPE }) +@Documented +@WildcardIndexed +@CompoundIndex +@Retention(RetentionPolicy.RUNTIME) +public @interface CompoundWildcardIndexed { + + /** + * The name of the sub-field to which a wildcard index is applied. If empty, the wildcard term will resolve to "$**". + * + * @return empty by default. + */ + String wildcardFieldName() default ""; + + /** + * Explicitly specify sub-fields to be in-/excluded as a {@link org.bson.Document#parse(String) parsable} String. + *
+ * NOTE: Can only be applied on when wildcard term is "$**" + * + * @return empty by default. + */ + @AliasFor(annotation = WildcardIndexed.class, attribute = "wildcardProjection") + String wildcardProjection() default ""; + + /** + * Definition of non-wildcard index(es) in JSON format, wherein the keys are the fields to be indexed and the values + * define the index direction (1 for ascending, -1 for descending).
+ * + *
+   * @Document
+   * @CompoundWildcardIndexed(wildcardProjection = "{ 'address.zip' : 0 }", fields = "{'firstname': 1}")
+   * class Person {
+   * 	String firstname;
+   * 	Address address;
+   * }
+	 * 
+ * + * @return empty String by default. + */ + @AliasFor(annotation = CompoundIndex.class, attribute = "def") + String fields(); + + /** + * Index name either as plain value or as {@link org.springframework.expression.spel.standard.SpelExpression template + * expression}.
+ * + * @return empty by default. + */ + @AliasFor(annotation = WildcardIndexed.class, attribute = "name") + String name() default ""; + + /** + * If set to {@literal true} then MongoDB will ignore the given index name and instead generate a new name. Defaults + * to {@literal false}. + * + * @return {@literal false} by default + */ + @AliasFor(annotation = WildcardIndexed.class, attribute = "useGeneratedName") + boolean useGeneratedName() default false; + + /** + * Only index the documents in a collection that meet a specified {@link IndexFilter filter expression}.
+ * + * @return empty by default. + */ + @AliasFor(annotation = WildcardIndexed.class, attribute = "partialFilter") + String partialFilter() default ""; + + /** + * Defines the collation to apply. + * + * @return an empty {@link String} by default. + */ + @AliasFor(annotation = WildcardIndexed.class, attribute = "collation") + String collation() default ""; +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolver.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolver.java index 86c896e7f..9e36d2a60 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolver.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolver.java @@ -79,6 +79,7 @@ import org.springframework.util.StringUtils; * @author Mark Paluch * @author Dave Perryman * @author Stefan Tirea + * @author Julia Lee * @since 1.5 */ public class MongoPersistentEntityIndexResolver implements IndexResolver { @@ -129,6 +130,7 @@ public class MongoPersistentEntityIndexResolver implements IndexResolver { indexInformation.addAll(potentiallyCreateCompoundIndexDefinitions("", collection, root)); indexInformation.addAll(potentiallyCreateWildcardIndexDefinitions("", collection, root)); indexInformation.addAll(potentiallyCreateTextIndexDefinition(root, collection)); + indexInformation.addAll(potentiallyCreateCompoundWildcardDefinition(root, collection)); root.doWithProperties((PropertyHandler) property -> this .potentiallyAddIndexForProperty(root, property, indexInformation, new CycleGuard())); @@ -154,6 +156,22 @@ public class MongoPersistentEntityIndexResolver implements IndexResolver { } } }); + + if (entity.isAnnotationPresent(CompoundWildcardIndexed.class)) { + CompoundWildcardIndexed indexed = entity.getRequiredAnnotation(CompoundWildcardIndexed.class); + + if (!ObjectUtils.isEmpty(indexed.wildcardFieldName()) && !ObjectUtils.isEmpty(indexed.wildcardProjection())) { + + throw new MappingException( + String.format("CompoundWildcardIndex.wildcardProjection is only allowed on \"$**\"; Offending property: %s", + indexed.wildcardFieldName())); + } + + if (ObjectUtils.isEmpty(indexed.wildcardFieldName()) && ObjectUtils.isEmpty(indexed.wildcardProjection())) { + + throw new MappingException(String.format("CompoundWildcardIndex.wildcardProjection is required on \"$**\"")); + } + } } private void potentiallyAddIndexForProperty(MongoPersistentEntity root, MongoPersistentProperty persistentProperty, @@ -280,7 +298,8 @@ public class MongoPersistentEntityIndexResolver implements IndexResolver { private List potentiallyCreateCompoundIndexDefinitions(String dotPath, String collection, MongoPersistentEntity entity) { - if (entity.findAnnotation(CompoundIndexes.class) == null && entity.findAnnotation(CompoundIndex.class) == null) { + if ((!entity.isAnnotationPresent(CompoundIndexes.class) && !entity.isAnnotationPresent(CompoundIndex.class)) + || entity.isAnnotationPresent(CompoundWildcardIndexed.class)) { return Collections.emptyList(); } @@ -290,7 +309,8 @@ public class MongoPersistentEntityIndexResolver implements IndexResolver { private List potentiallyCreateWildcardIndexDefinitions(String dotPath, String collection, MongoPersistentEntity entity) { - if (!entity.isAnnotationPresent(WildcardIndexed.class)) { + if (!entity.isAnnotationPresent(WildcardIndexed.class) + || entity.isAnnotationPresent(CompoundWildcardIndexed.class)) { return Collections.emptyList(); } @@ -345,6 +365,19 @@ public class MongoPersistentEntityIndexResolver implements IndexResolver { } + private Collection potentiallyCreateCompoundWildcardDefinition( + MongoPersistentEntity entity, String collection) { + + if (!entity.isAnnotationPresent(CompoundWildcardIndexed.class)) { + return Collections.emptyList(); + } + + CompoundWildcardIndexed compoundWildcardIndex = entity.getRequiredAnnotation(CompoundWildcardIndexed.class); + IndexDefinitionHolder compoundWildcardIndexDefinition = createCompoundWildcardIndexDefinition(collection, + compoundWildcardIndex, entity); + return Collections.singletonList(compoundWildcardIndexDefinition); + } + private void appendTextIndexInformation(DotPath dotPath, Path path, TextIndexDefinitionBuilder indexDefinitionBuilder, MongoPersistentEntity entity, TextIndexIncludeOptions includeOptions, CycleGuard guard) { @@ -483,6 +516,30 @@ public class MongoPersistentEntityIndexResolver implements IndexResolver { return new IndexDefinitionHolder(dotPath, indexDefinition, collection); } + protected IndexDefinitionHolder createCompoundWildcardIndexDefinition(String collection, CompoundWildcardIndexed index, + @Nullable MongoPersistentEntity entity) { + + String wildcardField = index.wildcardFieldName(); + org.bson.Document indexKeys = resolveCompoundIndexKeyFromStringDefinition("", index.fields(), entity); + + CompoundWildcardIndexDefinition indexDefinition = new CompoundWildcardIndexDefinition(wildcardField, indexKeys); + + if (StringUtils.hasText(index.wildcardProjection()) && ObjectUtils.isEmpty(wildcardField)) { + indexDefinition.wildcardProjection(evaluateWildcardProjection(index.wildcardProjection(), entity)); + } + + if (StringUtils.hasText(index.partialFilter())) { + indexDefinition.partial(evaluatePartialFilter(index.partialFilter(), entity)); + } + + if (!index.useGeneratedName()) { + indexDefinition.named(pathAwareIndexName(index.name(), "", entity, null)); + } + + indexDefinition.collation(resolveCollation(index, entity)); + return new IndexDefinitionHolder("", indexDefinition, collection); + } + private org.bson.Document resolveCompoundIndexKeyFromStringDefinition(String dotPath, String keyDefinitionString, PersistentEntity entity) { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolverUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolverUnitTests.java index 0cfb8bd09..37ed48282 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolverUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolverUnitTests.java @@ -40,6 +40,7 @@ import org.springframework.data.mapping.MappingException; import org.springframework.data.mongodb.core.DocumentTestUtils; import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexResolver.IndexDefinitionHolder; import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexResolverUnitTests.CompoundIndexResolutionTests; +import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexResolverUnitTests.CompoundWildcardIndexResolutionTests; import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexResolverUnitTests.GeoSpatialIndexResolutionTests; import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexResolverUnitTests.IndexResolutionTests; import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexResolverUnitTests.MixedIndexResolutionTests; @@ -62,10 +63,11 @@ import org.springframework.data.util.ClassTypeInformation; * @author Mark Paluch * @author Dave Perryman * @author Stefan Tirea + * @author Julia Lee */ @RunWith(Suite.class) @SuiteClasses({ IndexResolutionTests.class, GeoSpatialIndexResolutionTests.class, CompoundIndexResolutionTests.class, - TextIndexedResolutionTests.class, MixedIndexResolutionTests.class }) + CompoundWildcardIndexResolutionTests.class, TextIndexedResolutionTests.class, MixedIndexResolutionTests.class }) @SuppressWarnings("unused") public class MongoPersistentEntityIndexResolverUnitTests { @@ -601,7 +603,7 @@ public class MongoPersistentEntityIndexResolverUnitTests { public void compoundIndexDoesNotSpecifyNameWhenUsingGenerateName() { List indexDefinitions = prepareMappingContextAndResolveIndexForType( - ComountIndexWithAutogeneratedName.class); + CompoundIndexWithAutogeneratedName.class); IndexDefinition indexDefinition = indexDefinitions.get(0).getIndexDefinition(); assertThat(indexDefinition.getIndexOptions()) @@ -766,10 +768,10 @@ public class MongoPersistentEntityIndexResolverUnitTests { class IndexDefinedOnSuperClass extends CompoundIndexOnLevelZero {} - @Document("ComountIndexWithAutogeneratedName") + @Document("CompoundIndexWithAutogeneratedName") @CompoundIndexes({ @CompoundIndex(useGeneratedName = true, def = "{'foo': 1, 'bar': -1}", background = true, sparse = true, unique = true) }) - class ComountIndexWithAutogeneratedName {} + class CompoundIndexWithAutogeneratedName {} @Document("WithComposedAnnotation") @ComposedCompoundIndex @@ -829,6 +831,150 @@ public class MongoPersistentEntityIndexResolverUnitTests { class WithEvaluatedCollationFromCompoundIndex {} } + /** + * Test resolution of {@link CompoundWildcardIndexed}. + * + * @author Julia Lee + */ + public static class CompoundWildcardIndexResolutionTests { + + @Test // GH-4471 + public void compoundWildcardIndexOnSingleField() { + + List indexDefinitions = prepareMappingContextAndResolveIndexForType( + CompoundWildcardIndexOnFields.class); + + assertThat(indexDefinitions).hasSize(1); + assertIndexPathAndCollection(new String[] { "foo.$**", "bar", "baz" }, "CompoundWildcardIndexOnSingleField", + indexDefinitions.get(0)); + } + + @Test // GH-4471 + public void compoundWildcardIndexOnEntityWithProjection() { + + List indexDefinitions = prepareMappingContextAndResolveIndexForType( + CompoundWildcardIndexOnEntity.class); + + assertThat(indexDefinitions).hasSize(1); + assertThat(indexDefinitions.get(0)).satisfies(it -> { + assertThat(it.getIndexKeys()).isEqualTo(new org.bson.Document().append("$**", 1).append("bar", -1)); + assertThat(it.getIndexOptions()).containsEntry("wildcardProjection", + org.bson.Document.parse("{'foo.something' : 0}")); + }); + } + + @Test // GH-4471 + public void compoundWildcardIndexWithOptions() { + + List indexDefinitions = prepareMappingContextAndResolveIndexForType( + CompoundWildcardIndexWithOptions.class); + + assertThat(indexDefinitions).hasSize(1); + assertThat(indexDefinitions.get(0)).satisfies(it -> { + assertThat(it.getIndexKeys()).isEqualTo(new org.bson.Document().append("$**", 1).append("foo", 1)); + + org.bson.Document indexOptions = it.getIndexOptions(); + assertThat(indexOptions).containsEntry("name", "my_index_name"); + assertThat(indexOptions).containsEntry("wildcardProjection", org.bson.Document.parse("{'bar.something' : 1}")); + assertThat(indexOptions).containsEntry("collation", + new org.bson.Document().append("locale", "en_US").append("strength", 2)); + assertThat(indexOptions).containsEntry("partialFilterExpression", + org.bson.Document.parse("{'value': {'$exists': true}}")); + }); + } + + @Test // GH-4471 + public void compoundWildcardIndexWithCollationFromDocumentAnnotation() { + + List indexDefinitions = prepareMappingContextAndResolveIndexForType( + CompoundWildcardIndexWithCollationOnDocument.class); + + assertThat(indexDefinitions.get(0)).satisfies(it -> { + assertThat(it.getIndexKeys()).isEqualTo(new org.bson.Document().append("foo.$**", 1).append("bar", 1)); + assertThat(it.getIndexOptions()).containsEntry("collation", + new org.bson.Document().append("locale", "en_US").append("strength", 2)); + }); + } + + @Test // GH-4471 + public void compoundWildcardIndexWithEvaluatedCollationFromAnnotation() { + + List indexDefinitions = prepareMappingContextAndResolveIndexForType( + CompoundWildcardIndexWithEvaluatedCollation.class); + + assertThat(indexDefinitions.get(0)).satisfies(it -> { + assertThat(it.getIndexKeys()).isEqualTo(new org.bson.Document().append("foo.$**", 1).append("bar", 1)); + assertThat(it.getIndexOptions()).containsEntry("collation", new org.bson.Document().append("locale", "de_AT")); + }); + } + + @Test // GH-4471 + public void rejectsWildcardProjectionOnSingleField() { + + assertThatExceptionOfType(MappingException.class).isThrownBy(() -> + prepareMappingContextAndResolveIndexForType(IncorrectCompoundWildcardIndexOnFieldWithProjection.class)); + } + + @Test // GH-4471 + public void requiresWildcardProjectionOnEntireEntity() { + + assertThatExceptionOfType(MappingException.class).isThrownBy(() -> + prepareMappingContextAndResolveIndexForType(IncorrectCompoundWildcardIndexOnEntityWithoutProjection.class)); + } + + @Test // GH-4471 + public void resolvesMultipleIndexesWithCompoundWildcardIndex() { + + List indexDefinitions = prepareMappingContextAndResolveIndexForType( + MultipleIndexes.class); + + assertThat(indexDefinitions).hasSize(2); + + assertThat(indexDefinitions.get(0).getIndexDefinition()).isInstanceOf(CompoundWildcardIndexDefinition.class); + assertThat(indexDefinitions.get(1).getIndexDefinition()).isInstanceOf(Index.class); + + assertThat(indexDefinitions.get(0).getIndexKeys()).isEqualTo(new org.bson.Document().append("foo.$**", 1) + .append("bar", 1)); + assertThat(indexDefinitions.get(1).getIndexKeys()).isEqualTo(new org.bson.Document().append("one", 1)); + } + + @Document("CompoundWildcardIndexOnSingleField") + @CompoundWildcardIndexed(wildcardFieldName = "foo", fields = "{'bar': 1, 'baz': 1}") + class CompoundWildcardIndexOnFields {} + + @Document + @CompoundWildcardIndexed(wildcardFieldName = "foo", wildcardProjection = "{}", fields = "{'bar': 1}") + class IncorrectCompoundWildcardIndexOnFieldWithProjection {} + + @Document + @CompoundWildcardIndexed(fields = "{ 'bar': 1 }") + class IncorrectCompoundWildcardIndexOnEntityWithoutProjection {} + + @Document + @CompoundWildcardIndexed(wildcardProjection = "{'foo.something' : 0}", fields = "{'bar': -1}") + class CompoundWildcardIndexOnEntity {} + + @Document + @CompoundWildcardIndexed(fields = "{'foo': 1}", wildcardProjection = "{'bar.something': 1}", name = "my_index_name", + collation = "{'locale': 'en_US', 'strength': 2}", partialFilter = "{'value': {'$exists': true}}") + class CompoundWildcardIndexWithOptions {} + + @Document(collation = "{'locale': 'en_US', 'strength': 2}") + @CompoundWildcardIndexed(wildcardFieldName = "foo", fields = "{'bar': 1}") + class CompoundWildcardIndexWithCollationOnDocument {} + + @Document(collation = "{'locale': 'en_US', 'strength': 2}") + @CompoundWildcardIndexed(wildcardFieldName = "foo", fields = "{'bar': 1}", collation = "#{{'locale' : 'de' + '_' + 'AT'}}") + class CompoundWildcardIndexWithEvaluatedCollation {} + + @Document + @CompoundWildcardIndexed(wildcardFieldName = "foo", fields = "{'bar': 1}") + class MultipleIndexes { + @Indexed String one; + } + + } + public static class TextIndexedResolutionTests { @Test // DATAMONGO-937