From 3006ae28d843003cf0b5cf959415195c9a21ac26 Mon Sep 17 00:00:00 2001 From: Ben Foster Date: Thu, 14 Jul 2022 14:11:49 -0400 Subject: [PATCH 1/2] Add support for collection expiration to @TimeSeries. Closes #4099 --- .../data/mongodb/core/CollectionOptions.java | 28 ++++++++++++++++--- .../data/mongodb/core/EntityOperations.java | 12 +++++++- .../data/mongodb/core/mapping/TimeSeries.java | 9 ++++++ .../mongodb/core/MongoTemplateUnitTests.java | 21 ++++++++++++++ .../core/ReactiveMongoTemplateUnitTests.java | 20 +++++++++++++ 5 files changed, 85 insertions(+), 5 deletions(-) diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java index 7adbf62607..c926d94fb8 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java @@ -37,6 +37,7 @@ * @author Christoph Strobl * @author Mark Paluch * @author Andreas Zink + * @author Ben Foster */ public class CollectionOptions { @@ -444,13 +445,15 @@ public static class TimeSeriesOptions { private final GranularityDefinition granularity; - private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity) { + private final int expireAfterSeconds; + private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity, int expireAfterSeconds) { Assert.hasText(timeField, "Time field must not be empty or null"); this.timeField = timeField; this.metaField = metaField; this.granularity = granularity; + this.expireAfterSeconds = expireAfterSeconds; } /** @@ -462,7 +465,7 @@ private TimeSeriesOptions(String timeField, @Nullable String metaField, Granular * @return new instance of {@link TimeSeriesOptions}. */ public static TimeSeriesOptions timeSeries(String timeField) { - return new TimeSeriesOptions(timeField, null, Granularity.DEFAULT); + return new TimeSeriesOptions(timeField, null, Granularity.DEFAULT, -1); } /** @@ -475,7 +478,7 @@ public static TimeSeriesOptions timeSeries(String timeField) { * @return new instance of {@link TimeSeriesOptions}. */ public TimeSeriesOptions metaField(String metaField) { - return new TimeSeriesOptions(timeField, metaField, granularity); + return new TimeSeriesOptions(timeField, metaField, granularity, expireAfterSeconds); } /** @@ -486,7 +489,17 @@ public TimeSeriesOptions metaField(String metaField) { * @see Granularity */ public TimeSeriesOptions granularity(GranularityDefinition granularity) { - return new TimeSeriesOptions(timeField, metaField, granularity); + return new TimeSeriesOptions(timeField, metaField, granularity, expireAfterSeconds); + } + + /** + * Select the expireAfterSeconds parameter to define automatic removal of documents older than a specified + * number of seconds. + * + * @return new instance of {@link TimeSeriesOptions}. + */ + public TimeSeriesOptions expireAfterSeconds(int expireAfterSeconds) { + return new TimeSeriesOptions(timeField, metaField, granularity, expireAfterSeconds); } /** @@ -511,5 +524,12 @@ public String getMetaField() { public GranularityDefinition getGranularity() { return granularity; } + + /** + * @return {@literal -1} if not specified + */ + public int getExpireAfterSeconds() { + return expireAfterSeconds; + } } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java index 13a91aa625..131969c9c7 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java @@ -19,6 +19,7 @@ import java.util.Iterator; import java.util.Map; import java.util.Optional; +import java.util.concurrent.TimeUnit; import org.bson.Document; @@ -67,6 +68,7 @@ * @author Oliver Gierke * @author Mark Paluch * @author Christoph Strobl + * @author Ben Foster * @since 2.1 * @see MongoTemplate * @see ReactiveMongoTemplate @@ -331,6 +333,10 @@ public CreateCollectionOptions convertToCreateCollectionOptions(@Nullable Collec options.granularity(TimeSeriesGranularity.valueOf(it.getGranularity().name().toUpperCase())); } + if (it.getExpireAfterSeconds() >= 0) { + result.expireAfter(it.getExpireAfterSeconds(), TimeUnit.SECONDS); + } + result.timeSeriesOptions(options); }); @@ -916,6 +922,9 @@ public CollectionOptions getCollectionOptions() { if (!Granularity.DEFAULT.equals(timeSeries.granularity())) { options = options.granularity(timeSeries.granularity()); } + if (timeSeries.expireAfterSeconds() >= 0) { + options = options.expireAfterSeconds(timeSeries.expireAfterSeconds()); + } collectionOptions = collectionOptions.timeSeries(options); } @@ -930,7 +939,8 @@ public TimeSeriesOptions mapTimeSeriesOptions(TimeSeriesOptions source) { if (StringUtils.hasText(source.getMetaField())) { target = target.metaField(mappedNameOrDefault(source.getMetaField())); } - return target.granularity(source.getGranularity()); + return target.granularity(source.getGranularity()) + .expireAfterSeconds(source.getExpireAfterSeconds()); } private String mappedNameOrDefault(String name) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/TimeSeries.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/TimeSeries.java index 03b1147066..a5cfcfdd97 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/TimeSeries.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/TimeSeries.java @@ -28,6 +28,7 @@ * Identifies a domain object to be persisted to a MongoDB Time Series collection. * * @author Christoph Strobl + * @author Ben Foster * @since 3.3 * @see https://docs.mongodb.com/manual/core/timeseries-collections */ @@ -83,4 +84,12 @@ @AliasFor(annotation = Document.class, attribute = "collation") String collation() default ""; + /** + * Configures the number of seconds after which the collection should expire. Defaults to -1 for no expiry. + * + * @return {@literal -1} by default. + * @see + */ + int expireAfterSeconds() default -1; } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java index 169832f8fe..6171ac6dff 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java @@ -142,6 +142,7 @@ * @author Michael J. Simons * @author Roman Puchkovskiy * @author Yadhukrishna S Pai + * @author Ben Foster */ @MockitoSettings(strictness = Strictness.LENIENT) public class MongoTemplateUnitTests extends MongoOperationsUnitTests { @@ -2272,6 +2273,18 @@ void createCollectionShouldSetUpTimeSeries() { .granularity(TimeSeriesGranularity.HOURS).toString()); } + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithExpiration() { + + template.createCollection(TimeSeriesTypeWithExpire.class); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.SECONDS)) + .isEqualTo(60); + } + @Test // GH-3522 void usedCountDocumentsForEmptyQueryByDefault() { @@ -2430,6 +2443,14 @@ static class TimeSeriesType { Object meta; } + @TimeSeries(timeField = "timestamp", expireAfterSeconds = 60) + static class TimeSeriesTypeWithExpire { + + String id; + Instant timestamp; + } + + static class TypeImplementingIterator implements Iterator { @Override diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java index 0d9bca468c..e0606141e6 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java @@ -126,6 +126,7 @@ * @author Roman Puchkovskiy * @author Mathieu Ouellet * @author Yadhukrishna S Pai + * @author Ben Foster */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -1485,6 +1486,18 @@ void createCollectionShouldSetUpTimeSeries() { .granularity(TimeSeriesGranularity.HOURS).toString()); } + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithExpiration() { + + template.createCollection(TimeSeriesTypeWithExpire.class).subscribe(); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.SECONDS)) + .isEqualTo(60); + } + private void stubFindSubscribe(Document document) { Publisher realPublisher = Flux.just(document); @@ -1557,6 +1570,13 @@ static class TimeSeriesType { Object meta; } + @TimeSeries(timeField = "timestamp", expireAfterSeconds = 60) + static class TimeSeriesTypeWithExpire { + + String id; + Instant timestamp; + } + static class ValueCapturingEntityCallback { private final List values = new ArrayList<>(1); From d78537c02cb230126b656e32f8bff2a7c795f154 Mon Sep 17 00:00:00 2001 From: Ben Foster Date: Tue, 26 Jul 2022 12:50:16 +0000 Subject: [PATCH 2/2] Add support for expression-based time series expiration --- .../data/mongodb/core/CollectionOptions.java | 15 +- .../data/mongodb/core/EntityOperations.java | 128 ++++++++++++++++-- .../mongodb/core/index/DurationStyle.java | 2 +- .../data/mongodb/core/mapping/TimeSeries.java | 32 ++++- .../mongodb/core/MongoTemplateUnitTests.java | 110 ++++++++++++++- .../core/ReactiveMongoTemplateUnitTests.java | 112 ++++++++++++++- .../core/index/IndexingIntegrationTests.java | 30 ++++ 7 files changed, 407 insertions(+), 22 deletions(-) diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java index c926d94fb8..c2ff7ed530 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java @@ -15,6 +15,7 @@ */ package org.springframework.data.mongodb.core; +import java.time.Duration; import java.util.Optional; import org.springframework.data.mongodb.core.mapping.Field; @@ -445,9 +446,9 @@ public static class TimeSeriesOptions { private final GranularityDefinition granularity; - private final int expireAfterSeconds; + private final long expireAfterSeconds; - private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity, int expireAfterSeconds) { + private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity, long expireAfterSeconds) { Assert.hasText(timeField, "Time field must not be empty or null"); this.timeField = timeField; @@ -493,13 +494,13 @@ public TimeSeriesOptions granularity(GranularityDefinition granularity) { } /** - * Select the expireAfterSeconds parameter to define automatic removal of documents older than a specified - * number of seconds. + * Select the expire parameter to define automatic removal of documents older than a specified + * duration. * * @return new instance of {@link TimeSeriesOptions}. */ - public TimeSeriesOptions expireAfterSeconds(int expireAfterSeconds) { - return new TimeSeriesOptions(timeField, metaField, granularity, expireAfterSeconds); + public TimeSeriesOptions expireAfter(Duration timeout) { + return new TimeSeriesOptions(timeField, metaField, granularity, timeout.getSeconds()); } /** @@ -528,7 +529,7 @@ public GranularityDefinition getGranularity() { /** * @return {@literal -1} if not specified */ - public int getExpireAfterSeconds() { + public long getExpireAfterSeconds() { return expireAfterSeconds; } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java index 131969c9c7..756a4565f5 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java @@ -15,6 +15,7 @@ */ package org.springframework.data.mongodb.core; +import java.time.Duration; import java.util.Collection; import java.util.Iterator; import java.util.Map; @@ -37,10 +38,8 @@ import org.springframework.data.mongodb.core.convert.MongoJsonSchemaMapper; import org.springframework.data.mongodb.core.convert.MongoWriter; import org.springframework.data.mongodb.core.convert.QueryMapper; -import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; -import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; -import org.springframework.data.mongodb.core.mapping.MongoSimpleTypes; -import org.springframework.data.mongodb.core.mapping.TimeSeries; +import org.springframework.data.mongodb.core.index.DurationStyle; +import org.springframework.data.mongodb.core.mapping.*; import org.springframework.data.mongodb.core.query.Collation; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; @@ -49,7 +48,13 @@ import org.springframework.data.projection.EntityProjection; import org.springframework.data.projection.EntityProjectionIntrospector; import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.data.util.Optionals; +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; @@ -84,6 +89,8 @@ class EntityOperations { private final MongoJsonSchemaMapper schemaMapper; + private EvaluationContextProvider evaluationContextProvider = EvaluationContextProvider.DEFAULT; + EntityOperations(MongoConverter converter) { this(converter, new QueryMapper(converter)); } @@ -260,7 +267,7 @@ public TypedOperations forType(@Nullable Class entityClass) { MongoPersistentEntity entity = context.getPersistentEntity(entityClass); if (entity != null) { - return new TypedEntityOperations(entity); + return new TypedEntityOperations(entity, evaluationContextProvider); } } @@ -871,10 +878,13 @@ public TimeSeriesOptions mapTimeSeriesOptions(TimeSeriesOptions options) { */ static class TypedEntityOperations implements TypedOperations { + private static final SpelExpressionParser PARSER = new SpelExpressionParser(); private final MongoPersistentEntity entity; + private final EvaluationContextProvider evaluationContextProvider; - protected TypedEntityOperations(MongoPersistentEntity entity) { + protected TypedEntityOperations(MongoPersistentEntity entity, EvaluationContextProvider evaluationContextProvider) { this.entity = entity; + this.evaluationContextProvider = evaluationContextProvider; } @Override @@ -922,9 +932,26 @@ public CollectionOptions getCollectionOptions() { if (!Granularity.DEFAULT.equals(timeSeries.granularity())) { options = options.granularity(timeSeries.granularity()); } + if (timeSeries.expireAfterSeconds() >= 0) { - options = options.expireAfterSeconds(timeSeries.expireAfterSeconds()); + options = options.expireAfter(Duration.ofSeconds(timeSeries.expireAfterSeconds())); + } + + if (StringUtils.hasText(timeSeries.expireAfter())) { + + if (timeSeries.expireAfterSeconds() >= 0) { + throw new IllegalStateException(String.format( + "@TimeSeries already defines an expiration timeout of %s seconds via TimeSeries#expireAfterSeconds; Please make to use either expireAfterSeconds or expireAfter", + timeSeries.expireAfterSeconds())); + } + + Duration timeout = computeIndexTimeout(timeSeries.expireAfter(), + getEvaluationContextForProperty(entity)); + if (!timeout.isZero() && !timeout.isNegative()) { + options = options.expireAfter(timeout); + } } + collectionOptions = collectionOptions.timeSeries(options); } @@ -940,13 +967,98 @@ public TimeSeriesOptions mapTimeSeriesOptions(TimeSeriesOptions source) { target = target.metaField(mappedNameOrDefault(source.getMetaField())); } return target.granularity(source.getGranularity()) - .expireAfterSeconds(source.getExpireAfterSeconds()); + .expireAfter(Duration.ofSeconds(source.getExpireAfterSeconds())); } private String mappedNameOrDefault(String name) { MongoPersistentProperty persistentProperty = entity.getPersistentProperty(name); return persistentProperty != null ? persistentProperty.getFieldName() : name; } + + + /** + * 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) { + + Object evaluatedTimeout = evaluate(timeoutValue, evaluationContext); + + if (evaluatedTimeout == null) { + return Duration.ZERO; + } + + if (evaluatedTimeout instanceof Duration) { + return (Duration) evaluatedTimeout; + } + + String val = evaluatedTimeout.toString(); + + if (val == null) { + return Duration.ZERO; + } + + return DurationStyle.detectAndParse(val); + } + + @Nullable + private static Object evaluate(String value, EvaluationContext evaluationContext) { + + Expression expression = PARSER.parseExpression(value, ParserContext.TEMPLATE_EXPRESSION); + if (expression instanceof LiteralExpression) { + return value; + } + + return expression.getValue(evaluationContext, Object.class); + } + + + /** + * Get the {@link EvaluationContext} for a given {@link PersistentEntity entity} the default one. + * + * @param persistentEntity can be {@literal null} + * @return + */ + private EvaluationContext getEvaluationContextForProperty(@Nullable PersistentEntity persistentEntity) { + + if (!(persistentEntity instanceof BasicMongoPersistentEntity)) { + return getEvaluationContext(); + } + + EvaluationContext contextFromEntity = ((BasicMongoPersistentEntity) persistentEntity).getEvaluationContext(null); + + if (!EvaluationContextProvider.DEFAULT.equals(contextFromEntity)) { + return contextFromEntity; + } + + return getEvaluationContext(); + } + + /** + * 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; + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/DurationStyle.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/DurationStyle.java index 03af97ff83..8ae461f90c 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/DurationStyle.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/DurationStyle.java @@ -33,7 +33,7 @@ * @author Phillip Webb * @since 2.2 */ -enum DurationStyle { +public enum DurationStyle { /** * Simple formatting, for example '1s'. diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/TimeSeries.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/TimeSeries.java index a5cfcfdd97..7acb4730b9 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/TimeSeries.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/TimeSeries.java @@ -85,11 +85,41 @@ String collation() default ""; /** - * Configures the number of seconds after which the collection should expire. Defaults to -1 for no expiry. + * Configures the number of seconds after which the document should expire. Defaults to -1 for no expiry. * * @return {@literal -1} by default. * @see */ int expireAfterSeconds() default -1; + + + /** + * Alternative for {@link #expireAfterSeconds()} to configure the timeout after which the document should expire. + * Defaults to an empty {@link String} for no expiry. Accepts numeric values followed by their unit of measure: + * + * Supports ISO-8601 style. + * + *
+	 *
+	 * @Indexed(expireAfter = "10s") String expireAfterTenSeconds;
+	 *
+	 * @Indexed(expireAfter = "1d") String expireAfterOneDay;
+	 *
+	 * @Indexed(expireAfter = "P2D") String expireAfterTwoDays;
+	 *
+	 * @Indexed(expireAfter = "#{@mySpringBean.timeout}") String expireAfterTimeoutObtainedFromSpringBean;
+	 * 
+ * + * @return empty by default. + */ + String expireAfter() default ""; } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java index 6171ac6dff..f62cd0256b 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java @@ -2276,7 +2276,7 @@ void createCollectionShouldSetUpTimeSeries() { @Test // GH-4099 void createCollectionShouldSetUpTimeSeriesWithExpiration() { - template.createCollection(TimeSeriesTypeWithExpire.class); + template.createCollection(TimeSeriesTypeWithExpireAfterSeconds.class); ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); verify(db).createCollection(any(), options.capture()); @@ -2285,6 +2285,70 @@ void createCollectionShouldSetUpTimeSeriesWithExpiration() { .isEqualTo(60); } + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithExpirationFromString() { + + template.createCollection(ReactiveMongoTemplateUnitTests.TimeSeriesTypeWithExpireAfterAsPlainString.class); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.MINUTES)) + .isEqualTo(10); + } + + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithExpirationFromIso8601String() { + + template.createCollection(ReactiveMongoTemplateUnitTests.TimeSeriesTypeWithExpireAfterAsIso8601Style.class); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.DAYS)) + .isEqualTo(1); + } + + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithExpirationFromExpression() { + + template.createCollection(ReactiveMongoTemplateUnitTests.TimeSeriesTypeWithExpireAfterAsExpression.class); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.SECONDS)) + .isEqualTo(11); + } + + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithExpirationFromExpressionReturningDuration() { + + template.createCollection(ReactiveMongoTemplateUnitTests.TimeSeriesTypeWithExpireAfterAsExpressionResultingInDuration.class); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.SECONDS)) + .isEqualTo(100); + } + + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithInvalidTimeoutExpiration() { + + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> + template.createCollection(ReactiveMongoTemplateUnitTests.TimeSeriesTypeWithInvalidExpireAfter.class) + ); + } + + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithDuplicateTimeoutExpiration() { + + assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> + template.createCollection(ReactiveMongoTemplateUnitTests.TimeSeriesTypeWithDuplicateExpireAfter.class) + ); + } + @Test // GH-3522 void usedCountDocumentsForEmptyQueryByDefault() { @@ -2444,7 +2508,49 @@ static class TimeSeriesType { } @TimeSeries(timeField = "timestamp", expireAfterSeconds = 60) - static class TimeSeriesTypeWithExpire { + static class TimeSeriesTypeWithExpireAfterSeconds { + + String id; + Instant timestamp; + } + + @TimeSeries(timeField = "timestamp", expireAfter = "10m") + static class TimeSeriesTypeWithExpireAfterAsPlainString { + + String id; + Instant timestamp; + } + + @TimeSeries(timeField = "timestamp", expireAfter = "P1D") + static class TimeSeriesTypeWithExpireAfterAsIso8601Style { + + String id; + Instant timestamp; + } + + @TimeSeries(timeField = "timestamp", expireAfter = "#{10 + 1 + 's'}") + static class TimeSeriesTypeWithExpireAfterAsExpression { + + String id; + Instant timestamp; + } + + @TimeSeries(timeField = "timestamp", expireAfter = "#{T(java.time.Duration).ofSeconds(100)}") + static class TimeSeriesTypeWithExpireAfterAsExpressionResultingInDuration { + + String id; + Instant timestamp; + } + + @TimeSeries(timeField = "timestamp", expireAfter = "123ops") + static class TimeSeriesTypeWithInvalidExpireAfter { + + String id; + Instant timestamp; + } + + @TimeSeries(timeField = "timestamp", expireAfter = "1s", expireAfterSeconds = 2) + static class TimeSeriesTypeWithDuplicateExpireAfter { String id; Instant timestamp; diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java index e0606141e6..f6de402c19 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java @@ -1487,9 +1487,9 @@ void createCollectionShouldSetUpTimeSeries() { } @Test // GH-4099 - void createCollectionShouldSetUpTimeSeriesWithExpiration() { + void createCollectionShouldSetUpTimeSeriesWithExpirationSeconds() { - template.createCollection(TimeSeriesTypeWithExpire.class).subscribe(); + template.createCollection(TimeSeriesTypeWithExpireAfterSeconds.class).subscribe(); ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); verify(db).createCollection(any(), options.capture()); @@ -1498,6 +1498,70 @@ void createCollectionShouldSetUpTimeSeriesWithExpiration() { .isEqualTo(60); } + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithExpirationFromString() { + + template.createCollection(TimeSeriesTypeWithExpireAfterAsPlainString.class).subscribe(); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.MINUTES)) + .isEqualTo(10); + } + + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithExpirationFromIso8601String() { + + template.createCollection(TimeSeriesTypeWithExpireAfterAsIso8601Style.class).subscribe(); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.DAYS)) + .isEqualTo(1); + } + + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithExpirationFromExpression() { + + template.createCollection(TimeSeriesTypeWithExpireAfterAsExpression.class).subscribe(); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.SECONDS)) + .isEqualTo(11); + } + + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithExpirationFromExpressionReturningDuration() { + + template.createCollection(TimeSeriesTypeWithExpireAfterAsExpressionResultingInDuration.class).subscribe(); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.SECONDS)) + .isEqualTo(100); + } + + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithInvalidTimeoutExpiration() { + + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> + template.createCollection(TimeSeriesTypeWithInvalidExpireAfter.class).subscribe() + ); + } + + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithDuplicateTimeoutExpiration() { + + assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> + template.createCollection(TimeSeriesTypeWithDuplicateExpireAfter.class).subscribe() + ); + } + private void stubFindSubscribe(Document document) { Publisher realPublisher = Flux.just(document); @@ -1571,7 +1635,49 @@ static class TimeSeriesType { } @TimeSeries(timeField = "timestamp", expireAfterSeconds = 60) - static class TimeSeriesTypeWithExpire { + static class TimeSeriesTypeWithExpireAfterSeconds { + + String id; + Instant timestamp; + } + + @TimeSeries(timeField = "timestamp", expireAfter = "10m") + static class TimeSeriesTypeWithExpireAfterAsPlainString { + + String id; + Instant timestamp; + } + + @TimeSeries(timeField = "timestamp", expireAfter = "P1D") + static class TimeSeriesTypeWithExpireAfterAsIso8601Style { + + String id; + Instant timestamp; + } + + @TimeSeries(timeField = "timestamp", expireAfter = "#{10 + 1 + 's'}") + static class TimeSeriesTypeWithExpireAfterAsExpression { + + String id; + Instant timestamp; + } + + @TimeSeries(timeField = "timestamp", expireAfter = "#{T(java.time.Duration).ofSeconds(100)}") + static class TimeSeriesTypeWithExpireAfterAsExpressionResultingInDuration { + + String id; + Instant timestamp; + } + + @TimeSeries(timeField = "timestamp", expireAfter = "123ops") + static class TimeSeriesTypeWithInvalidExpireAfter { + + String id; + Instant timestamp; + } + + @TimeSeries(timeField = "timestamp", expireAfter = "1s", expireAfterSeconds = 2) + static class TimeSeriesTypeWithDuplicateExpireAfter { String id; Instant timestamp; diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/IndexingIntegrationTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/IndexingIntegrationTests.java index f87eac0c73..4d15a89fde 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/IndexingIntegrationTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/index/IndexingIntegrationTests.java @@ -24,6 +24,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -47,6 +48,7 @@ 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.core.mapping.TimeSeries; import org.springframework.data.mongodb.test.util.Client; import org.springframework.data.mongodb.test.util.MongoClientExtension; import org.springframework.test.annotation.DirtiesContext; @@ -62,6 +64,7 @@ * @author Christoph Strobl * @author Jordi Llach * @author Mark Paluch + * @author Ben Foster */ @ExtendWith({ MongoClientExtension.class, SpringExtension.class }) @ContextConfiguration @@ -105,6 +108,7 @@ protected boolean autoIndexCreation() { @AfterEach public void tearDown() { operations.dropCollection(IndexedPerson.class); + operations.dropCollection(TimeSeriesWithSpelIndexTimeout.class); } @Test // DATAMONGO-237 @@ -162,6 +166,27 @@ public void evaluatesTimeoutSpelExpresssionWithBeanReference() { }); } + @Test // GH-4099 + @DirtiesContext + public void evaluatesTimeSeriesTimeoutSpelExpresssionWithBeanReference() { + + operations.createCollection(TimeSeriesWithSpelIndexTimeout.class); + + final Optional collectionInfo = operations.execute(db -> { + return db.listCollections().into(new ArrayList<>()) + .stream() + .filter(c -> "timeSeriesWithSpelIndexTimeout".equals(c.get("name"))) + .findFirst(); + }); + + assertThat(collectionInfo).isPresent(); + assertThat(collectionInfo.get()).hasEntrySatisfying("options", options -> { + final org.bson.Document optionsDoc = (org.bson.Document) options; + // MongoDB 5 returns int not long + assertThat(optionsDoc.get("expireAfterSeconds")).isIn(11, 11L); + }); + } + @Target({ ElementType.FIELD }) @Retention(RetentionPolicy.RUNTIME) @Indexed @@ -186,6 +211,11 @@ class WithSpelIndexTimeout { @Indexed(expireAfter = "#{@myTimeoutResolver?.timeout}") String someString; } + @TimeSeries(expireAfter = "#{@myTimeoutResolver?.timeout}", timeField = "timestamp") + class TimeSeriesWithSpelIndexTimeout { + Instant timestamp; + } + /** * Returns whether an index with the given name exists for the given entity type. *