diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Index.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Index.java index 747202e80..3f7b1a4cd 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Index.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Index.java @@ -15,6 +15,7 @@ */ package org.springframework.data.mongodb.core.index; +import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; import java.util.Map.Entry; @@ -116,6 +117,20 @@ public class Index implements IndexDefinition { return expire(value, TimeUnit.SECONDS); } + /** + * Specifies the TTL. + * + * @param timeout must not be {@literal null}. + * @return this. + * @throws IllegalArgumentException if given {@literal timeout} is {@literal null}. + * @since 2.2 + */ + public Index expire(Duration timeout) { + + Assert.notNull(timeout, "Timeout must not be null!"); + return expire(timeout.getSeconds()); + } + /** * Specifies TTL with given {@link TimeUnit}. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Indexed.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Indexed.java index 144038661..196812db0 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Indexed.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Indexed.java @@ -131,4 +131,28 @@ public @interface Indexed { * "https://docs.mongodb.org/manual/tutorial/expire-data/">https://docs.mongodb.org/manual/tutorial/expire-data/ */ int expireAfterSeconds() default -1; + + /** + * Alternative for {@link #expireAfterSeconds()} to configure the timeout after which the collection should expire. + * Defaults to an empty String for no expiry. Accepts numeric values followed by their unit of measure (d(ays), + * h(ours), m(inutes), s(seconds)) or a Spring {@literal template expression}. + * + *
+	 *     
+	 *
+	 * @Indexed(expireAfter = "10s")
+	 * String expireAfterTenSeconds;
+	 *
+	 * @Indexed(expireAfter = "1d")
+	 * String expireAfterOneDay;
+	 *
+	 * @Indexed(expireAfter = "#{@mySpringBean.timeout}")
+	 * String expireAfterTimeoutObtainedFromSpringBean;
+	 *     
+	 * 
+ * + * @return {@literal 0s} by default. + * @since 2.2 + */ + String expireAfter() default "0s"; } 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 af56d06b0..f7481fed4 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 @@ -19,6 +19,7 @@ import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.RequiredArgsConstructor; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -28,6 +29,8 @@ import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -43,14 +46,22 @@ import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexRes import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexResolver.TextIndexIncludeOptions.IncludeStrategy; import org.springframework.data.mongodb.core.index.TextIndexDefinition.TextIndexDefinitionBuilder; import org.springframework.data.mongodb.core.index.TextIndexDefinition.TextIndexedFieldSpec; +import org.springframework.data.mongodb.core.mapping.BasicMongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.MongoMappingContext; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.data.util.TypeInformation; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.ParserContext; +import org.springframework.expression.common.LiteralExpression; +import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.NumberUtils; import org.springframework.util.StringUtils; /** @@ -68,8 +79,11 @@ import org.springframework.util.StringUtils; public class MongoPersistentEntityIndexResolver implements IndexResolver { private static final Logger LOGGER = LoggerFactory.getLogger(MongoPersistentEntityIndexResolver.class); + private static final Pattern TIMEOUT_PATTERN = Pattern.compile("(\\d+)(\\W+)?([dhms])"); + private static final SpelExpressionParser PARSER = new SpelExpressionParser(); private final MongoMappingContext mappingContext; + private EvaluationContextProvider evaluationContextProvider = EvaluationContextProvider.DEFAULT; /** * Create new {@link MongoPersistentEntityIndexResolver}. @@ -428,9 +442,54 @@ public class MongoPersistentEntityIndexResolver implements IndexResolver { indexDefinition.expire(index.expireAfterSeconds(), TimeUnit.SECONDS); } + if (!index.expireAfter().isEmpty() && !index.expireAfter().equals("0s")) { + + if (index.expireAfterSeconds() >= 0) { + throw new IllegalStateException(String.format( + "@Indexed already defines an expiration timeout of %s sec. via Indexed#expireAfterSeconds. Please make to use either expireAfterSeconds or expireAfter.", index.expireAfterSeconds())); + } + + EvaluationContext ctx = getEvaluationContext(); + + if (persitentProperty.getOwner() instanceof BasicMongoPersistentEntity) { + + EvaluationContext contextFromEntity = ((BasicMongoPersistentEntity) persitentProperty.getOwner()) + .getEvaluationContext(null); + if (contextFromEntity != null && !EvaluationContextProvider.DEFAULT.equals(contextFromEntity)) { + ctx = contextFromEntity; + } + } + + Duration timeout = computeIndexTimeout(index.expireAfter(), ctx); + if (!timeout.isZero() && !timeout.isNegative()) { + indexDefinition.expire(timeout); + } + } + return new IndexDefinitionHolder(dotPath, indexDefinition, collection); } + /** + * Get the default {@link EvaluationContext}. + * + * @return never {@literal null}. + * @since 2.2 + */ + protected EvaluationContext getEvaluationContext() { + return evaluationContextProvider.getEvaluationContext(null); + } + + /** + * Set the {@link EvaluationContextProvider} used for obtaining the {@link EvaluationContext} used to compute + * {@link org.springframework.expression.spel.standard.SpelExpression expressions}. + * + * @param evaluationContextProvider must not be {@literal null}. + * @since 2.2 + */ + public void setEvaluationContextProvider(EvaluationContextProvider evaluationContextProvider) { + this.evaluationContextProvider = evaluationContextProvider; + } + /** * Creates {@link IndexDefinition} wrapped in {@link IndexDefinitionHolder} out of {@link GeoSpatialIndexed} for * {@link MongoPersistentProperty}. @@ -511,6 +570,58 @@ public class MongoPersistentEntityIndexResolver implements IndexResolver { } } + /** + * Compute the index timeout value by evaluating a potential + * {@link org.springframework.expression.spel.standard.SpelExpression} and parsing the final value. + * + * @param timeoutValue must not be {@literal null}. + * @param evaluationContext must not be {@literal null}. + * @return never {@literal null} + * @since 2.2 + * @throws IllegalArgumentException for invalid duration values. + */ + private static Duration computeIndexTimeout(String timeoutValue, EvaluationContext evaluationContext) { + + String val = evaluatePotentialTemplateExpression(timeoutValue, evaluationContext); + + if (val == null) { + return Duration.ZERO; + } + + Matcher matcher = TIMEOUT_PATTERN.matcher(val); + if (matcher.find()) { + + Long timeout = NumberUtils.parseNumber(matcher.group(1), Long.class); + String unit = matcher.group(3); + + switch (unit) { + case "d": + return Duration.ofDays(timeout); + case "h": + return Duration.ofHours(timeout); + case "m": + return Duration.ofMinutes(timeout); + case "s": + return Duration.ofSeconds(timeout); + } + } + + throw new IllegalArgumentException( + String.format("Index timeout %s cannot be parsed. Please use the following pattern '\\d+\\W?[dhms]'.", val)); + } + + @Nullable + private static String evaluatePotentialTemplateExpression(String value, EvaluationContext evaluationContext) { + + Expression expression = PARSER.parseExpression(value, ParserContext.TEMPLATE_EXPRESSION); + if (expression instanceof LiteralExpression) { + return value; + } + + return expression.getValue(evaluationContext, String.class); + + } + /** * {@link CycleGuard} holds information about properties and the paths for accessing those. This information is used * to detect potential cycles within the references. 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 c65106b2d..757909d26 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 @@ -29,6 +29,7 @@ import org.springframework.data.mapping.PropertyHandler; import org.springframework.data.mapping.model.BasicPersistentEntity; import org.springframework.data.mongodb.MongoCollectionUtils; import org.springframework.data.util.TypeInformation; +import org.springframework.expression.EvaluationContext; import org.springframework.expression.Expression; import org.springframework.expression.ParserContext; import org.springframework.expression.common.LiteralExpression; @@ -138,6 +139,15 @@ public class BasicMongoPersistentEntity extends BasicPersistentEntity indexInfo = operations.execute("withSpelIndexTimeout", collection -> { + + return collection.listIndexes(org.bson.Document.class).into(new ArrayList<>()) // + .stream() // + .filter(it -> it.get("name").equals("someString")) // + .findFirst(); + }); + + Assertions.assertThat(indexInfo).isPresent(); + Assertions.assertThat(indexInfo.get()).containsEntry("expireAfterSeconds", 11L); + } + @Target({ ElementType.FIELD }) @Retention(RetentionPolicy.RUNTIME) @Indexed @@ -110,6 +157,17 @@ public class IndexingIntegrationTests { @Field("_lastname") @IndexedFieldAnnotation String lastname; } + @RequiredArgsConstructor + @Getter + static class TimeoutResolver { + final String timeout; + } + + @Document + class WithSpelIndexTimeout { + @Indexed(expireAfter = "#{@myTimeoutResolver?.timeout}") String someString; + } + /** * Returns whether an index with the given name exists for the given entity type. * 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 e4693df31..e9ce7b350 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 @@ -27,6 +27,7 @@ import java.lang.annotation.Target; import java.util.Collections; import java.util.List; +import org.assertj.core.api.Assertions; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Suite; @@ -201,6 +202,45 @@ public class MongoPersistentEntityIndexResolverUnitTests { isBsonObject().containing("sparse", true).containing("name", "different_name").notContaining("unique")); } + @Test // DATAMONGO-2112 + public void shouldResolveTimeoutFromString() { + + List indexDefinitions = prepareMappingContextAndResolveIndexForType( + WithExpireAfterAsPlainString.class); + + Assertions.assertThat(indexDefinitions.get(0).getIndexOptions()).containsEntry("expireAfterSeconds", 600L); + } + + @Test // DATAMONGO-2112 + public void shouldResolveTimeoutFromExpression() { + + List indexDefinitions = prepareMappingContextAndResolveIndexForType( + WithExpireAfterAsExpression.class); + + Assertions.assertThat(indexDefinitions.get(0).getIndexOptions()).containsEntry("expireAfterSeconds", 11L); + } + + @Test // DATAMONGO-2112 + public void shouldErrorOnInvalidTimeoutExpression() { + + MongoMappingContext mappingContext = prepareMappingContext(WithInvalidExpireAfter.class); + MongoPersistentEntityIndexResolver indexResolver = new MongoPersistentEntityIndexResolver(mappingContext); + + Assertions.assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> indexResolver + .resolveIndexForEntity(mappingContext.getRequiredPersistentEntity(WithInvalidExpireAfter.class))); + + } + + @Test // DATAMONGO-2112 + public void shouldErrorOnDuplicateTimeoutExpression() { + + MongoMappingContext mappingContext = prepareMappingContext(WithDuplicateExpiry.class); + MongoPersistentEntityIndexResolver indexResolver = new MongoPersistentEntityIndexResolver(mappingContext); + + Assertions.assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> indexResolver + .resolveIndexForEntity(mappingContext.getRequiredPersistentEntity(WithDuplicateExpiry.class))); + } + @Document("Zero") static class IndexOnLevelZero { @Indexed String indexedProperty; @@ -290,6 +330,26 @@ public class MongoPersistentEntityIndexResolverUnitTests { @AliasFor(annotation = org.springframework.data.mongodb.core.mapping.Field.class, attribute = "value") String name() default "_id"; } + + @Document + static class WithExpireAfterAsPlainString { + @Indexed(expireAfter = "10m") String withTimeout; + } + + @Document + static class WithExpireAfterAsExpression { + @Indexed(expireAfter = "#{10 + 1 + 's'}") String withTimeout; + } + + @Document + class WithInvalidExpireAfter { + @Indexed(expireAfter = "123ops") String withTimeout; + } + + @Document + class WithDuplicateExpiry { + @Indexed(expireAfter = "1s", expireAfterSeconds = 2) String withTimeout; + } } @Target({ ElementType.FIELD })