DATAMONGO-2112 - Allow usage of SpEL expression for index timeout.

We added expireAfter which accepts numeric values followed by the unit of measure (d(ays), h(ours), m(inutes), s(econds)) or a Spring template expression to the Indexed annotation.

   @Indexed(expireAfter = "10s")
    String expireAfterTenSeconds;

    @Indexed(expireAfter = "1d")
    String expireAfterOneDay;

    @Indexed(expireAfter = "#{@mySpringBean.timeout}")
    String expireAfterTimeoutObtainedFromSpringBean;

Original pull request: #647.
This commit is contained in:
Christoph Strobl
2019-02-20 10:23:53 +01:00
committed by Mark Paluch
parent 9c26859c04
commit 82da8027f5
6 changed files with 279 additions and 1 deletions

View File

@@ -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}.
*

View File

@@ -131,4 +131,28 @@ public @interface Indexed {
* "https://docs.mongodb.org/manual/tutorial/expire-data/">https://docs.mongodb.org/manual/tutorial/expire-data/</a>
*/
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}.
*
* <pre>
* <code>
*
* &#0064;Indexed(expireAfter = "10s")
* String expireAfterTenSeconds;
*
* &#0064;Indexed(expireAfter = "1d")
* String expireAfterOneDay;
*
* &#0064;Indexed(expireAfter = "#{&#0064;mySpringBean.timeout}")
* String expireAfterTimeoutObtainedFromSpringBean;
* </code>
* </pre>
*
* @return {@literal 0s} by default.
* @since 2.2
*/
String expireAfter() default "0s";
}

View File

@@ -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.

View File

@@ -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<T> extends BasicPersistentEntity<T, Mong
verifyFieldTypes();
}
/*
* (non-Javadoc)
* @see org.springframework.data.mapping.model.BasicPersistentEntity#getEvaluationContext(java.lang.Object)
*/
@Override
public EvaluationContext getEvaluationContext(Object rootObject) {
return super.getEvaluationContext(rootObject);
}
private void verifyFieldUniqueness() {
AssertFieldNameUniquenessHandler handler = new AssertFieldNameUniquenessHandler();

View File

@@ -18,20 +18,27 @@ package org.springframework.data.mongodb.core.index;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.MongoCollectionUtils;
import org.springframework.data.mongodb.MongoDbFactory;
import org.springframework.data.mongodb.config.AbstractMongoConfiguration;
import org.springframework.data.mongodb.core.MongoOperations;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
@@ -39,10 +46,13 @@ import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
import org.springframework.data.mongodb.test.util.Assertions;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import com.mongodb.MongoClient;
/**
* Integration tests for index handling.
*
@@ -52,13 +62,32 @@ import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
* @author Mark Paluch
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("classpath:infrastructure.xml")
@ContextConfiguration
public class IndexingIntegrationTests {
@Autowired MongoOperations operations;
@Autowired MongoDbFactory mongoDbFactory;
@Autowired ConfigurableApplicationContext context;
@Configuration
static class Config extends AbstractMongoConfiguration {
@Override
public MongoClient mongoClient() {
return new MongoClient();
}
@Override
protected String getDatabaseName() {
return "database";
}
@Bean
TimeoutResolver myTimeoutResolver() {
return new TimeoutResolver("11s");
}
}
@After
public void tearDown() {
operations.dropCollection(IndexedPerson.class);
@@ -97,6 +126,24 @@ public class IndexingIntegrationTests {
assertThat(hasIndex("_lastname", IndexedPerson.class), is(true));
}
@Test // DATAMONGO-2112
@DirtiesContext
public void evaluatesTimeoutSpelExpresssionWithBeanReference() {
operations.getConverter().getMappingContext().getPersistentEntity(WithSpelIndexTimeout.class);
Optional<org.bson.Document> 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.
*

View File

@@ -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<IndexDefinitionHolder> indexDefinitions = prepareMappingContextAndResolveIndexForType(
WithExpireAfterAsPlainString.class);
Assertions.assertThat(indexDefinitions.get(0).getIndexOptions()).containsEntry("expireAfterSeconds", 600L);
}
@Test // DATAMONGO-2112
public void shouldResolveTimeoutFromExpression() {
List<IndexDefinitionHolder> 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 })