Skip to content

Commit 197998f

Browse files
bpfosterchristophstrobl
authored andcommitted
Add support for collection expiration to @timeseries.
Closes: #4099 Original Pull Request: #4114
1 parent 7fbd496 commit 197998f

File tree

7 files changed

+478
-12
lines changed

7 files changed

+478
-12
lines changed

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

+25-4
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package org.springframework.data.mongodb.core;
1717

18+
import java.time.Duration;
1819
import java.util.Optional;
1920

2021
import org.springframework.data.mongodb.core.mapping.Field;
@@ -38,6 +39,7 @@
3839
* @author Christoph Strobl
3940
* @author Mark Paluch
4041
* @author Andreas Zink
42+
* @author Ben Foster
4143
*/
4244
public class CollectionOptions {
4345

@@ -629,13 +631,15 @@ public static class TimeSeriesOptions {
629631

630632
private final GranularityDefinition granularity;
631633

632-
private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity) {
634+
private final long expireAfterSeconds;
633635

636+
private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity, long expireAfterSeconds) {
634637
Assert.hasText(timeField, "Time field must not be empty or null");
635638

636639
this.timeField = timeField;
637640
this.metaField = metaField;
638641
this.granularity = granularity;
642+
this.expireAfterSeconds = expireAfterSeconds;
639643
}
640644

641645
/**
@@ -647,7 +651,7 @@ private TimeSeriesOptions(String timeField, @Nullable String metaField, Granular
647651
* @return new instance of {@link TimeSeriesOptions}.
648652
*/
649653
public static TimeSeriesOptions timeSeries(String timeField) {
650-
return new TimeSeriesOptions(timeField, null, Granularity.DEFAULT);
654+
return new TimeSeriesOptions(timeField, null, Granularity.DEFAULT, -1);
651655
}
652656

653657
/**
@@ -660,7 +664,7 @@ public static TimeSeriesOptions timeSeries(String timeField) {
660664
* @return new instance of {@link TimeSeriesOptions}.
661665
*/
662666
public TimeSeriesOptions metaField(String metaField) {
663-
return new TimeSeriesOptions(timeField, metaField, granularity);
667+
return new TimeSeriesOptions(timeField, metaField, granularity, expireAfterSeconds);
664668
}
665669

666670
/**
@@ -671,7 +675,17 @@ public TimeSeriesOptions metaField(String metaField) {
671675
* @see Granularity
672676
*/
673677
public TimeSeriesOptions granularity(GranularityDefinition granularity) {
674-
return new TimeSeriesOptions(timeField, metaField, granularity);
678+
return new TimeSeriesOptions(timeField, metaField, granularity, expireAfterSeconds);
679+
}
680+
681+
/**
682+
* Select the expire parameter to define automatic removal of documents older than a specified
683+
* duration.
684+
*
685+
* @return new instance of {@link TimeSeriesOptions}.
686+
*/
687+
public TimeSeriesOptions expireAfter(Duration timeout) {
688+
return new TimeSeriesOptions(timeField, metaField, granularity, timeout.getSeconds());
675689
}
676690

677691
/**
@@ -697,6 +711,13 @@ public GranularityDefinition getGranularity() {
697711
return granularity;
698712
}
699713

714+
/**
715+
* @return {@literal -1} if not specified
716+
*/
717+
public long getExpireAfterSeconds() {
718+
return expireAfterSeconds;
719+
}
720+
700721
@Override
701722
public String toString() {
702723

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

+130-7
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
*/
1616
package org.springframework.data.mongodb.core;
1717

18+
import java.time.Duration;
1819
import java.util.Collection;
1920
import java.util.Iterator;
2021
import java.util.LinkedHashMap;
2122
import java.util.Map;
2223
import java.util.Optional;
24+
import java.util.concurrent.TimeUnit;
2325

2426
import org.bson.BsonNull;
2527
import org.bson.Document;
@@ -40,10 +42,8 @@
4042
import org.springframework.data.mongodb.core.convert.MongoWriter;
4143
import org.springframework.data.mongodb.core.convert.QueryMapper;
4244
import org.springframework.data.mongodb.core.mapping.FieldName;
43-
import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity;
44-
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
45-
import org.springframework.data.mongodb.core.mapping.MongoSimpleTypes;
46-
import org.springframework.data.mongodb.core.mapping.TimeSeries;
45+
import org.springframework.data.mongodb.core.index.DurationStyle;
46+
import org.springframework.data.mongodb.core.mapping.*;
4747
import org.springframework.data.mongodb.core.query.Collation;
4848
import org.springframework.data.mongodb.core.query.Criteria;
4949
import org.springframework.data.mongodb.core.query.Query;
@@ -54,7 +54,13 @@
5454
import org.springframework.data.projection.EntityProjectionIntrospector;
5555
import org.springframework.data.projection.ProjectionFactory;
5656
import org.springframework.data.projection.TargetAware;
57+
import org.springframework.data.spel.EvaluationContextProvider;
5758
import org.springframework.data.util.Optionals;
59+
import org.springframework.expression.EvaluationContext;
60+
import org.springframework.expression.Expression;
61+
import org.springframework.expression.ParserContext;
62+
import org.springframework.expression.common.LiteralExpression;
63+
import org.springframework.expression.spel.standard.SpelExpressionParser;
5864
import org.springframework.lang.Nullable;
5965
import org.springframework.util.Assert;
6066
import org.springframework.util.ClassUtils;
@@ -74,6 +80,7 @@
7480
* @author Oliver Gierke
7581
* @author Mark Paluch
7682
* @author Christoph Strobl
83+
* @author Ben Foster
7784
* @since 2.1
7885
* @see MongoTemplate
7986
* @see ReactiveMongoTemplate
@@ -89,6 +96,8 @@ class EntityOperations {
8996

9097
private final MongoJsonSchemaMapper schemaMapper;
9198

99+
private EvaluationContextProvider evaluationContextProvider = EvaluationContextProvider.DEFAULT;
100+
92101
EntityOperations(MongoConverter converter) {
93102
this(converter, new QueryMapper(converter));
94103
}
@@ -276,7 +285,7 @@ public <T> TypedOperations<T> forType(@Nullable Class<T> entityClass) {
276285
MongoPersistentEntity<?> entity = context.getPersistentEntity(entityClass);
277286

278287
if (entity != null) {
279-
return new TypedEntityOperations(entity);
288+
return new TypedEntityOperations(entity, evaluationContextProvider);
280289
}
281290

282291
}
@@ -354,6 +363,10 @@ public CreateCollectionOptions convertToCreateCollectionOptions(@Nullable Collec
354363
options.granularity(TimeSeriesGranularity.valueOf(it.getGranularity().name().toUpperCase()));
355364
}
356365

366+
if (it.getExpireAfterSeconds() >= 0) {
367+
result.expireAfter(it.getExpireAfterSeconds(), TimeUnit.SECONDS);
368+
}
369+
357370
result.timeSeriesOptions(options);
358371
});
359372

@@ -1026,10 +1039,13 @@ public TimeSeriesOptions mapTimeSeriesOptions(TimeSeriesOptions options) {
10261039
*/
10271040
static class TypedEntityOperations<T> implements TypedOperations<T> {
10281041

1042+
private static final SpelExpressionParser PARSER = new SpelExpressionParser();
10291043
private final MongoPersistentEntity<T> entity;
1044+
private final EvaluationContextProvider evaluationContextProvider;
10301045

1031-
protected TypedEntityOperations(MongoPersistentEntity<T> entity) {
1046+
protected TypedEntityOperations(MongoPersistentEntity<T> entity, EvaluationContextProvider evaluationContextProvider) {
10321047
this.entity = entity;
1048+
this.evaluationContextProvider = evaluationContextProvider;
10331049
}
10341050

10351051
@Override
@@ -1077,6 +1093,26 @@ public CollectionOptions getCollectionOptions() {
10771093
if (!Granularity.DEFAULT.equals(timeSeries.granularity())) {
10781094
options = options.granularity(timeSeries.granularity());
10791095
}
1096+
1097+
if (timeSeries.expireAfterSeconds() >= 0) {
1098+
options = options.expireAfter(Duration.ofSeconds(timeSeries.expireAfterSeconds()));
1099+
}
1100+
1101+
if (StringUtils.hasText(timeSeries.expireAfter())) {
1102+
1103+
if (timeSeries.expireAfterSeconds() >= 0) {
1104+
throw new IllegalStateException(String.format(
1105+
"@TimeSeries already defines an expiration timeout of %s seconds via TimeSeries#expireAfterSeconds; Please make to use either expireAfterSeconds or expireAfter",
1106+
timeSeries.expireAfterSeconds()));
1107+
}
1108+
1109+
Duration timeout = computeIndexTimeout(timeSeries.expireAfter(),
1110+
getEvaluationContextForProperty(entity));
1111+
if (!timeout.isZero() && !timeout.isNegative()) {
1112+
options = options.expireAfter(timeout);
1113+
}
1114+
}
1115+
10801116
collectionOptions = collectionOptions.timeSeries(options);
10811117
}
10821118

@@ -1091,7 +1127,8 @@ public TimeSeriesOptions mapTimeSeriesOptions(TimeSeriesOptions source) {
10911127
if (StringUtils.hasText(source.getMetaField())) {
10921128
target = target.metaField(mappedNameOrDefault(source.getMetaField()));
10931129
}
1094-
return target.granularity(source.getGranularity());
1130+
return target.granularity(source.getGranularity())
1131+
.expireAfter(Duration.ofSeconds(source.getExpireAfterSeconds()));
10951132
}
10961133

10971134
private String mappedNameOrDefault(String name) {
@@ -1105,4 +1142,90 @@ public String getIdKeyName() {
11051142
}
11061143
}
11071144

1145+
1146+
/**
1147+
* Compute the index timeout value by evaluating a potential
1148+
* {@link org.springframework.expression.spel.standard.SpelExpression} and parsing the final value.
1149+
*
1150+
* @param timeoutValue must not be {@literal null}.
1151+
* @param evaluationContext must not be {@literal null}.
1152+
* @return never {@literal null}
1153+
* @since 2.2
1154+
* @throws IllegalArgumentException for invalid duration values.
1155+
*/
1156+
private static Duration computeIndexTimeout(String timeoutValue, EvaluationContext evaluationContext) {
1157+
1158+
Object evaluatedTimeout = evaluate(timeoutValue, evaluationContext);
1159+
1160+
if (evaluatedTimeout == null) {
1161+
return Duration.ZERO;
1162+
}
1163+
1164+
if (evaluatedTimeout instanceof Duration) {
1165+
return (Duration) evaluatedTimeout;
1166+
}
1167+
1168+
String val = evaluatedTimeout.toString();
1169+
1170+
if (val == null) {
1171+
return Duration.ZERO;
1172+
}
1173+
1174+
return DurationStyle.detectAndParse(val);
1175+
}
1176+
1177+
@Nullable
1178+
private static Object evaluate(String value, EvaluationContext evaluationContext) {
1179+
1180+
Expression expression = PARSER.parseExpression(value, ParserContext.TEMPLATE_EXPRESSION);
1181+
if (expression instanceof LiteralExpression) {
1182+
return value;
1183+
}
1184+
1185+
return expression.getValue(evaluationContext, Object.class);
1186+
}
1187+
1188+
1189+
/**
1190+
* Get the {@link EvaluationContext} for a given {@link PersistentEntity entity} the default one.
1191+
*
1192+
* @param persistentEntity can be {@literal null}
1193+
* @return
1194+
*/
1195+
private EvaluationContext getEvaluationContextForProperty(@Nullable PersistentEntity<?, ?> persistentEntity) {
1196+
1197+
if (!(persistentEntity instanceof BasicMongoPersistentEntity)) {
1198+
return getEvaluationContext();
1199+
}
1200+
1201+
EvaluationContext contextFromEntity = ((BasicMongoPersistentEntity<?>) persistentEntity).getEvaluationContext(null);
1202+
1203+
if (!EvaluationContextProvider.DEFAULT.equals(contextFromEntity)) {
1204+
return contextFromEntity;
1205+
}
1206+
1207+
return getEvaluationContext();
1208+
}
1209+
1210+
/**
1211+
* Get the default {@link EvaluationContext}.
1212+
*
1213+
* @return never {@literal null}.
1214+
* @since 2.2
1215+
*/
1216+
protected EvaluationContext getEvaluationContext() {
1217+
return evaluationContextProvider.getEvaluationContext(null);
1218+
}
1219+
}
1220+
1221+
/**
1222+
* Set the {@link EvaluationContextProvider} used for obtaining the {@link EvaluationContext} used to compute
1223+
* {@link org.springframework.expression.spel.standard.SpelExpression expressions}.
1224+
*
1225+
* @param evaluationContextProvider must not be {@literal null}.
1226+
* @since 2.2
1227+
*/
1228+
public void setEvaluationContextProvider(EvaluationContextProvider evaluationContextProvider) {
1229+
this.evaluationContextProvider = evaluationContextProvider;
1230+
}
11081231
}

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
* @author Phillip Webb
3434
* @since 2.2
3535
*/
36-
enum DurationStyle {
36+
public enum DurationStyle {
3737

3838
/**
3939
* Simple formatting, for example '1s'.

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

+39
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
* Identifies a domain object to be persisted to a MongoDB Time Series collection.
2929
*
3030
* @author Christoph Strobl
31+
* @author Ben Foster
3132
* @since 3.3
3233
* @see <a href="https://docs.mongodb.com/manual/core/timeseries-collections">https://docs.mongodb.com/manual/core/timeseries-collections</a>
3334
*/
@@ -83,4 +84,42 @@
8384
@AliasFor(annotation = Document.class, attribute = "collation")
8485
String collation() default "";
8586

87+
/**
88+
* Configures the number of seconds after which the document should expire. Defaults to -1 for no expiry.
89+
*
90+
* @return {@literal -1} by default.
91+
* @see <a href=
92+
* "https://www.mongodb.com/docs/manual/core/timeseries/timeseries-automatic-removal/#set-up-automatic-removal-for-time-series-collections--ttl-</a>
93+
*/
94+
int expireAfterSeconds() default -1;
95+
96+
97+
/**
98+
* Alternative for {@link #expireAfterSeconds()} to configure the timeout after which the document should expire.
99+
* Defaults to an empty {@link String} for no expiry. Accepts numeric values followed by their unit of measure:
100+
* <ul>
101+
* <li><b>d</b>: Days</li>
102+
* <li><b>h</b>: Hours</li>
103+
* <li><b>m</b>: Minutes</li>
104+
* <li><b>s</b>: Seconds</li>
105+
* <li>Alternatively: A Spring {@literal template expression}. The expression can result in a
106+
* {@link java.time.Duration} or a valid expiration {@link String} according to the already mentioned
107+
* conventions.</li>
108+
* </ul>
109+
* Supports ISO-8601 style.
110+
*
111+
* <pre class="code">
112+
*
113+
* &#0064;Indexed(expireAfter = "10s") String expireAfterTenSeconds;
114+
*
115+
* &#0064;Indexed(expireAfter = "1d") String expireAfterOneDay;
116+
*
117+
* &#0064;Indexed(expireAfter = "P2D") String expireAfterTwoDays;
118+
*
119+
* &#0064;Indexed(expireAfter = "#{&#0064;mySpringBean.timeout}") String expireAfterTimeoutObtainedFromSpringBean;
120+
* </pre>
121+
*
122+
* @return empty by default.
123+
*/
124+
String expireAfter() default "";
86125
}

0 commit comments

Comments
 (0)