From 197998f7cbac55ac9a6fdbe21d3362f8ee9628c1 Mon Sep 17 00:00:00 2001 From: Ben Foster Date: Thu, 14 Jul 2022 14:11:49 -0400 Subject: [PATCH] Add support for collection expiration to @TimeSeries. Closes: #4099 Original Pull Request: #4114 --- .../data/mongodb/core/CollectionOptions.java | 29 +++- .../data/mongodb/core/EntityOperations.java | 137 +++++++++++++++++- .../mongodb/core/index/DurationStyle.java | 2 +- .../data/mongodb/core/mapping/TimeSeries.java | 39 +++++ .../mongodb/core/MongoTemplateUnitTests.java | 127 ++++++++++++++++ .../core/ReactiveMongoTemplateUnitTests.java | 126 ++++++++++++++++ .../core/index/IndexingIntegrationTests.java | 30 ++++ 7 files changed, 478 insertions(+), 12 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 45d84eb5f..67fcea933 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; @@ -38,6 +39,7 @@ import com.mongodb.client.model.ValidationLevel; * @author Christoph Strobl * @author Mark Paluch * @author Andreas Zink + * @author Ben Foster */ public class CollectionOptions { @@ -629,13 +631,15 @@ public class CollectionOptions { private final GranularityDefinition granularity; - private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity) { + private final long 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; this.metaField = metaField; this.granularity = granularity; + this.expireAfterSeconds = expireAfterSeconds; } /** @@ -647,7 +651,7 @@ public class CollectionOptions { * @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); } /** @@ -660,7 +664,7 @@ public class CollectionOptions { * @return new instance of {@link TimeSeriesOptions}. */ public TimeSeriesOptions metaField(String metaField) { - return new TimeSeriesOptions(timeField, metaField, granularity); + return new TimeSeriesOptions(timeField, metaField, granularity, expireAfterSeconds); } /** @@ -671,7 +675,17 @@ public class CollectionOptions { * @see Granularity */ public TimeSeriesOptions granularity(GranularityDefinition granularity) { - return new TimeSeriesOptions(timeField, metaField, granularity); + return new TimeSeriesOptions(timeField, metaField, granularity, expireAfterSeconds); + } + + /** + * Select the expire parameter to define automatic removal of documents older than a specified + * duration. + * + * @return new instance of {@link TimeSeriesOptions}. + */ + public TimeSeriesOptions expireAfter(Duration timeout) { + return new TimeSeriesOptions(timeField, metaField, granularity, timeout.getSeconds()); } /** @@ -697,6 +711,13 @@ public class CollectionOptions { return granularity; } + /** + * @return {@literal -1} if not specified + */ + public long getExpireAfterSeconds() { + return expireAfterSeconds; + } + @Override public String toString() { 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 310d0d4c0..594d6b9bb 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,11 +15,13 @@ */ package org.springframework.data.mongodb.core; +import java.time.Duration; import java.util.Collection; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; +import java.util.concurrent.TimeUnit; import org.bson.BsonNull; import org.bson.Document; @@ -40,10 +42,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.FieldName; -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; @@ -54,7 +54,13 @@ import org.springframework.data.projection.EntityProjection; import org.springframework.data.projection.EntityProjectionIntrospector; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.projection.TargetAware; +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; @@ -74,6 +80,7 @@ import com.mongodb.client.model.ValidationOptions; * @author Oliver Gierke * @author Mark Paluch * @author Christoph Strobl + * @author Ben Foster * @since 2.1 * @see MongoTemplate * @see ReactiveMongoTemplate @@ -89,6 +96,8 @@ class EntityOperations { private final MongoJsonSchemaMapper schemaMapper; + private EvaluationContextProvider evaluationContextProvider = EvaluationContextProvider.DEFAULT; + EntityOperations(MongoConverter converter) { this(converter, new QueryMapper(converter)); } @@ -276,7 +285,7 @@ class EntityOperations { MongoPersistentEntity entity = context.getPersistentEntity(entityClass); if (entity != null) { - return new TypedEntityOperations(entity); + return new TypedEntityOperations(entity, evaluationContextProvider); } } @@ -354,6 +363,10 @@ class EntityOperations { options.granularity(TimeSeriesGranularity.valueOf(it.getGranularity().name().toUpperCase())); } + if (it.getExpireAfterSeconds() >= 0) { + result.expireAfter(it.getExpireAfterSeconds(), TimeUnit.SECONDS); + } + result.timeSeriesOptions(options); }); @@ -1026,10 +1039,13 @@ class EntityOperations { */ 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 @@ -1077,6 +1093,26 @@ class EntityOperations { if (!Granularity.DEFAULT.equals(timeSeries.granularity())) { options = options.granularity(timeSeries.granularity()); } + + if (timeSeries.expireAfterSeconds() >= 0) { + 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); } @@ -1091,7 +1127,8 @@ class EntityOperations { if (StringUtils.hasText(source.getMetaField())) { target = target.metaField(mappedNameOrDefault(source.getMetaField())); } - return target.granularity(source.getGranularity()); + return target.granularity(source.getGranularity()) + .expireAfter(Duration.ofSeconds(source.getExpireAfterSeconds())); } private String mappedNameOrDefault(String name) { @@ -1105,4 +1142,90 @@ class EntityOperations { } } + + /** + * 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 56fcd15a4..7790d27ed 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 @@ import org.springframework.util.StringUtils; * @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 e90671146..2148c04b6 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 @@ import org.springframework.data.mongodb.core.timeseries.Granularity; * 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,42 @@ public @interface TimeSeries { @AliasFor(annotation = Document.class, attribute = "collation") String collation() default ""; + /** + * 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: + *
    + *
  • d: Days
  • + *
  • h: Hours
  • + *
  • m: Minutes
  • + *
  • s: Seconds
  • + *
  • Alternatively: A Spring {@literal template expression}. The expression can result in a + * {@link java.time.Duration} or a valid expiration {@link String} according to the already mentioned + * conventions.
  • + *
+ * 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 1a46c00ae..6d9e69ba5 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 @@ -141,6 +141,7 @@ import com.mongodb.client.result.UpdateResult; * @author Roman Puchkovskiy * @author Yadhukrishna S Pai * @author Jakub Zurawa + * @author Ben Foster */ @MockitoSettings(strictness = Strictness.LENIENT) public class MongoTemplateUnitTests extends MongoOperationsUnitTests { @@ -2386,6 +2387,82 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { .granularity(TimeSeriesGranularity.HOURS).toString()); } + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithExpiration() { + + template.createCollection(TimeSeriesTypeWithExpireAfterSeconds.class); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.SECONDS)) + .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() { @@ -2705,6 +2782,56 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { Object meta; } + @TimeSeries(timeField = "timestamp", expireAfterSeconds = 60) + 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; + } + + 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 475bb6dc7..01ad29816 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 @@ -132,6 +132,7 @@ import com.mongodb.reactivestreams.client.MongoDatabase; * @author Roman Puchkovskiy * @author Mathieu Ouellet * @author Yadhukrishna S Pai + * @author Ben Foster */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -1739,6 +1740,82 @@ public class ReactiveMongoTemplateUnitTests { verify(collection).withWriteConcern(eq(WriteConcern.UNACKNOWLEDGED)); } + @Test // GH-4099 + void createCollectionShouldSetUpTimeSeriesWithExpirationSeconds() { + + template.createCollection(TimeSeriesTypeWithExpireAfterSeconds.class).subscribe(); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.SECONDS)) + .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) { stubFindSubscribe(document, new AtomicLong()); } @@ -1887,6 +1964,55 @@ public class ReactiveMongoTemplateUnitTests { Object meta; } + @TimeSeries(timeField = "timestamp", expireAfterSeconds = 60) + 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; + } + static class ValueCapturingEntityCallback { private final List values = new ArrayList<>(1); 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 a14e8c2a1..088602fc8 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 @@ -21,6 +21,7 @@ import java.lang.annotation.ElementType; 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; @@ -44,6 +45,7 @@ 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.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; @@ -59,6 +61,7 @@ import com.mongodb.client.MongoClient; * @author Christoph Strobl * @author Jordi Llach * @author Mark Paluch + * @author Ben Foster */ @ExtendWith({ MongoClientExtension.class, SpringExtension.class }) @ContextConfiguration @@ -102,6 +105,7 @@ public class IndexingIntegrationTests { @AfterEach public void tearDown() { operations.dropCollection(IndexedPerson.class); + operations.dropCollection(TimeSeriesWithSpelIndexTimeout.class); } @Test // DATAMONGO-237 @@ -159,6 +163,27 @@ public class IndexingIntegrationTests { }); } + @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 @@ -189,6 +214,11 @@ public class IndexingIntegrationTests { @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. *