Skip to content

Commit 82da802

Browse files
christophstroblmp911de
authored andcommitted
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.
1 parent 9c26859 commit 82da802

File tree

6 files changed

+279
-1
lines changed

6 files changed

+279
-1
lines changed

spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Index.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package org.springframework.data.mongodb.core.index;
1717

18+
import java.time.Duration;
1819
import java.util.LinkedHashMap;
1920
import java.util.Map;
2021
import java.util.Map.Entry;
@@ -116,6 +117,20 @@ public Index expire(long value) {
116117
return expire(value, TimeUnit.SECONDS);
117118
}
118119

120+
/**
121+
* Specifies the TTL.
122+
*
123+
* @param timeout must not be {@literal null}.
124+
* @return this.
125+
* @throws IllegalArgumentException if given {@literal timeout} is {@literal null}.
126+
* @since 2.2
127+
*/
128+
public Index expire(Duration timeout) {
129+
130+
Assert.notNull(timeout, "Timeout must not be null!");
131+
return expire(timeout.getSeconds());
132+
}
133+
119134
/**
120135
* Specifies TTL with given {@link TimeUnit}.
121136
*

spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Indexed.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,28 @@
131131
* "https://docs.mongodb.org/manual/tutorial/expire-data/">https://docs.mongodb.org/manual/tutorial/expire-data/</a>
132132
*/
133133
int expireAfterSeconds() default -1;
134+
135+
/**
136+
* Alternative for {@link #expireAfterSeconds()} to configure the timeout after which the collection should expire.
137+
* Defaults to an empty String for no expiry. Accepts numeric values followed by their unit of measure (d(ays),
138+
* h(ours), m(inutes), s(seconds)) or a Spring {@literal template expression}.
139+
*
140+
* <pre>
141+
* <code>
142+
*
143+
* &#0064;Indexed(expireAfter = "10s")
144+
* String expireAfterTenSeconds;
145+
*
146+
* &#0064;Indexed(expireAfter = "1d")
147+
* String expireAfterOneDay;
148+
*
149+
* &#0064;Indexed(expireAfter = "#{&#0064;mySpringBean.timeout}")
150+
* String expireAfterTimeoutObtainedFromSpringBean;
151+
* </code>
152+
* </pre>
153+
*
154+
* @return {@literal 0s} by default.
155+
* @since 2.2
156+
*/
157+
String expireAfter() default "0s";
134158
}

spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolver.java

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import lombok.EqualsAndHashCode;
2020
import lombok.RequiredArgsConstructor;
2121

22+
import java.time.Duration;
2223
import java.util.ArrayList;
2324
import java.util.Arrays;
2425
import java.util.Collection;
@@ -28,6 +29,8 @@
2829
import java.util.List;
2930
import java.util.Set;
3031
import java.util.concurrent.TimeUnit;
32+
import java.util.regex.Matcher;
33+
import java.util.regex.Pattern;
3134
import java.util.stream.Collectors;
3235

3336
import org.slf4j.Logger;
@@ -43,14 +46,22 @@
4346
import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexResolver.TextIndexIncludeOptions.IncludeStrategy;
4447
import org.springframework.data.mongodb.core.index.TextIndexDefinition.TextIndexDefinitionBuilder;
4548
import org.springframework.data.mongodb.core.index.TextIndexDefinition.TextIndexedFieldSpec;
49+
import org.springframework.data.mongodb.core.mapping.BasicMongoPersistentEntity;
4650
import org.springframework.data.mongodb.core.mapping.Document;
4751
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
4852
import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity;
4953
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
54+
import org.springframework.data.spel.EvaluationContextProvider;
5055
import org.springframework.data.util.TypeInformation;
56+
import org.springframework.expression.EvaluationContext;
57+
import org.springframework.expression.Expression;
58+
import org.springframework.expression.ParserContext;
59+
import org.springframework.expression.common.LiteralExpression;
60+
import org.springframework.expression.spel.standard.SpelExpressionParser;
5161
import org.springframework.lang.Nullable;
5262
import org.springframework.util.Assert;
5363
import org.springframework.util.ClassUtils;
64+
import org.springframework.util.NumberUtils;
5465
import org.springframework.util.StringUtils;
5566

5667
/**
@@ -68,8 +79,11 @@
6879
public class MongoPersistentEntityIndexResolver implements IndexResolver {
6980

7081
private static final Logger LOGGER = LoggerFactory.getLogger(MongoPersistentEntityIndexResolver.class);
82+
private static final Pattern TIMEOUT_PATTERN = Pattern.compile("(\\d+)(\\W+)?([dhms])");
83+
private static final SpelExpressionParser PARSER = new SpelExpressionParser();
7184

7285
private final MongoMappingContext mappingContext;
86+
private EvaluationContextProvider evaluationContextProvider = EvaluationContextProvider.DEFAULT;
7387

7488
/**
7589
* Create new {@link MongoPersistentEntityIndexResolver}.
@@ -428,9 +442,54 @@ protected IndexDefinitionHolder createIndexDefinition(String dotPath, String col
428442
indexDefinition.expire(index.expireAfterSeconds(), TimeUnit.SECONDS);
429443
}
430444

445+
if (!index.expireAfter().isEmpty() && !index.expireAfter().equals("0s")) {
446+
447+
if (index.expireAfterSeconds() >= 0) {
448+
throw new IllegalStateException(String.format(
449+
"@Indexed already defines an expiration timeout of %s sec. via Indexed#expireAfterSeconds. Please make to use either expireAfterSeconds or expireAfter.", index.expireAfterSeconds()));
450+
}
451+
452+
EvaluationContext ctx = getEvaluationContext();
453+
454+
if (persitentProperty.getOwner() instanceof BasicMongoPersistentEntity) {
455+
456+
EvaluationContext contextFromEntity = ((BasicMongoPersistentEntity<?>) persitentProperty.getOwner())
457+
.getEvaluationContext(null);
458+
if (contextFromEntity != null && !EvaluationContextProvider.DEFAULT.equals(contextFromEntity)) {
459+
ctx = contextFromEntity;
460+
}
461+
}
462+
463+
Duration timeout = computeIndexTimeout(index.expireAfter(), ctx);
464+
if (!timeout.isZero() && !timeout.isNegative()) {
465+
indexDefinition.expire(timeout);
466+
}
467+
}
468+
431469
return new IndexDefinitionHolder(dotPath, indexDefinition, collection);
432470
}
433471

472+
/**
473+
* Get the default {@link EvaluationContext}.
474+
*
475+
* @return never {@literal null}.
476+
* @since 2.2
477+
*/
478+
protected EvaluationContext getEvaluationContext() {
479+
return evaluationContextProvider.getEvaluationContext(null);
480+
}
481+
482+
/**
483+
* Set the {@link EvaluationContextProvider} used for obtaining the {@link EvaluationContext} used to compute
484+
* {@link org.springframework.expression.spel.standard.SpelExpression expressions}.
485+
*
486+
* @param evaluationContextProvider must not be {@literal null}.
487+
* @since 2.2
488+
*/
489+
public void setEvaluationContextProvider(EvaluationContextProvider evaluationContextProvider) {
490+
this.evaluationContextProvider = evaluationContextProvider;
491+
}
492+
434493
/**
435494
* Creates {@link IndexDefinition} wrapped in {@link IndexDefinitionHolder} out of {@link GeoSpatialIndexed} for
436495
* {@link MongoPersistentProperty}.
@@ -511,6 +570,58 @@ private void resolveAndAddIndexesForAssociation(Association<MongoPersistentPrope
511570
}
512571
}
513572

573+
/**
574+
* Compute the index timeout value by evaluating a potential
575+
* {@link org.springframework.expression.spel.standard.SpelExpression} and parsing the final value.
576+
*
577+
* @param timeoutValue must not be {@literal null}.
578+
* @param evaluationContext must not be {@literal null}.
579+
* @return never {@literal null}
580+
* @since 2.2
581+
* @throws IllegalArgumentException for invalid duration values.
582+
*/
583+
private static Duration computeIndexTimeout(String timeoutValue, EvaluationContext evaluationContext) {
584+
585+
String val = evaluatePotentialTemplateExpression(timeoutValue, evaluationContext);
586+
587+
if (val == null) {
588+
return Duration.ZERO;
589+
}
590+
591+
Matcher matcher = TIMEOUT_PATTERN.matcher(val);
592+
if (matcher.find()) {
593+
594+
Long timeout = NumberUtils.parseNumber(matcher.group(1), Long.class);
595+
String unit = matcher.group(3);
596+
597+
switch (unit) {
598+
case "d":
599+
return Duration.ofDays(timeout);
600+
case "h":
601+
return Duration.ofHours(timeout);
602+
case "m":
603+
return Duration.ofMinutes(timeout);
604+
case "s":
605+
return Duration.ofSeconds(timeout);
606+
}
607+
}
608+
609+
throw new IllegalArgumentException(
610+
String.format("Index timeout %s cannot be parsed. Please use the following pattern '\\d+\\W?[dhms]'.", val));
611+
}
612+
613+
@Nullable
614+
private static String evaluatePotentialTemplateExpression(String value, EvaluationContext evaluationContext) {
615+
616+
Expression expression = PARSER.parseExpression(value, ParserContext.TEMPLATE_EXPRESSION);
617+
if (expression instanceof LiteralExpression) {
618+
return value;
619+
}
620+
621+
return expression.getValue(evaluationContext, String.class);
622+
623+
}
624+
514625
/**
515626
* {@link CycleGuard} holds information about properties and the paths for accessing those. This information is used
516627
* to detect potential cycles within the references.

spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/BasicMongoPersistentEntity.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.springframework.data.mapping.model.BasicPersistentEntity;
3030
import org.springframework.data.mongodb.MongoCollectionUtils;
3131
import org.springframework.data.util.TypeInformation;
32+
import org.springframework.expression.EvaluationContext;
3233
import org.springframework.expression.Expression;
3334
import org.springframework.expression.ParserContext;
3435
import org.springframework.expression.common.LiteralExpression;
@@ -138,6 +139,15 @@ public void verify() {
138139
verifyFieldTypes();
139140
}
140141

142+
/*
143+
* (non-Javadoc)
144+
* @see org.springframework.data.mapping.model.BasicPersistentEntity#getEvaluationContext(java.lang.Object)
145+
*/
146+
@Override
147+
public EvaluationContext getEvaluationContext(Object rootObject) {
148+
return super.getEvaluationContext(rootObject);
149+
}
150+
141151
private void verifyFieldUniqueness() {
142152

143153
AssertFieldNameUniquenessHandler handler = new AssertFieldNameUniquenessHandler();

spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/IndexingIntegrationTests.java

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,31 +18,41 @@
1818
import static org.hamcrest.CoreMatchers.*;
1919
import static org.junit.Assert.*;
2020

21+
import lombok.Getter;
22+
import lombok.RequiredArgsConstructor;
23+
2124
import java.lang.annotation.ElementType;
2225
import java.lang.annotation.Retention;
2326
import java.lang.annotation.RetentionPolicy;
2427
import java.lang.annotation.Target;
2528
import java.util.ArrayList;
2629
import java.util.List;
30+
import java.util.Optional;
2731

2832
import org.junit.After;
2933
import org.junit.Test;
3034
import org.junit.runner.RunWith;
3135
import org.springframework.beans.factory.annotation.Autowired;
3236
import org.springframework.context.ConfigurableApplicationContext;
37+
import org.springframework.context.annotation.Bean;
38+
import org.springframework.context.annotation.Configuration;
3339
import org.springframework.data.mongodb.MongoCollectionUtils;
3440
import org.springframework.data.mongodb.MongoDbFactory;
41+
import org.springframework.data.mongodb.config.AbstractMongoConfiguration;
3542
import org.springframework.data.mongodb.core.MongoOperations;
3643
import org.springframework.data.mongodb.core.MongoTemplate;
3744
import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
3845
import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver;
3946
import org.springframework.data.mongodb.core.mapping.Document;
4047
import org.springframework.data.mongodb.core.mapping.Field;
4148
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
49+
import org.springframework.data.mongodb.test.util.Assertions;
4250
import org.springframework.test.annotation.DirtiesContext;
4351
import org.springframework.test.context.ContextConfiguration;
4452
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
4553

54+
import com.mongodb.MongoClient;
55+
4656
/**
4757
* Integration tests for index handling.
4858
*
@@ -52,13 +62,32 @@
5262
* @author Mark Paluch
5363
*/
5464
@RunWith(SpringJUnit4ClassRunner.class)
55-
@ContextConfiguration("classpath:infrastructure.xml")
65+
@ContextConfiguration
5666
public class IndexingIntegrationTests {
5767

5868
@Autowired MongoOperations operations;
5969
@Autowired MongoDbFactory mongoDbFactory;
6070
@Autowired ConfigurableApplicationContext context;
6171

72+
@Configuration
73+
static class Config extends AbstractMongoConfiguration {
74+
75+
@Override
76+
public MongoClient mongoClient() {
77+
return new MongoClient();
78+
}
79+
80+
@Override
81+
protected String getDatabaseName() {
82+
return "database";
83+
}
84+
85+
@Bean
86+
TimeoutResolver myTimeoutResolver() {
87+
return new TimeoutResolver("11s");
88+
}
89+
}
90+
6291
@After
6392
public void tearDown() {
6493
operations.dropCollection(IndexedPerson.class);
@@ -97,6 +126,24 @@ public void createsIndexFromMetaAnnotation() {
97126
assertThat(hasIndex("_lastname", IndexedPerson.class), is(true));
98127
}
99128

129+
@Test // DATAMONGO-2112
130+
@DirtiesContext
131+
public void evaluatesTimeoutSpelExpresssionWithBeanReference() {
132+
133+
operations.getConverter().getMappingContext().getPersistentEntity(WithSpelIndexTimeout.class);
134+
135+
Optional<org.bson.Document> indexInfo = operations.execute("withSpelIndexTimeout", collection -> {
136+
137+
return collection.listIndexes(org.bson.Document.class).into(new ArrayList<>()) //
138+
.stream() //
139+
.filter(it -> it.get("name").equals("someString")) //
140+
.findFirst();
141+
});
142+
143+
Assertions.assertThat(indexInfo).isPresent();
144+
Assertions.assertThat(indexInfo.get()).containsEntry("expireAfterSeconds", 11L);
145+
}
146+
100147
@Target({ ElementType.FIELD })
101148
@Retention(RetentionPolicy.RUNTIME)
102149
@Indexed
@@ -110,6 +157,17 @@ class IndexedPerson {
110157
@Field("_lastname") @IndexedFieldAnnotation String lastname;
111158
}
112159

160+
@RequiredArgsConstructor
161+
@Getter
162+
static class TimeoutResolver {
163+
final String timeout;
164+
}
165+
166+
@Document
167+
class WithSpelIndexTimeout {
168+
@Indexed(expireAfter = "#{@myTimeoutResolver?.timeout}") String someString;
169+
}
170+
113171
/**
114172
* Returns whether an index with the given name exists for the given entity type.
115173
*

0 commit comments

Comments
 (0)