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..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; @@ -37,6 +38,7 @@ * @author Christoph Strobl * @author Mark Paluch * @author Andreas Zink + * @author Ben Foster */ public class CollectionOptions { @@ -444,13 +446,15 @@ public static class TimeSeriesOptions { 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; } /** @@ -462,7 +466,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 +479,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 +490,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 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()); } /** @@ -511,5 +525,12 @@ public String getMetaField() { public GranularityDefinition getGranularity() { return granularity; } + + /** + * @return {@literal -1} if not specified + */ + 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 13a91aa625..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,10 +15,12 @@ */ package org.springframework.data.mongodb.core; +import java.time.Duration; import java.util.Collection; import java.util.Iterator; import java.util.Map; import java.util.Optional; +import java.util.concurrent.TimeUnit; import org.bson.Document; @@ -36,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; @@ -48,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; @@ -67,6 +73,7 @@ * @author Oliver Gierke * @author Mark Paluch * @author Christoph Strobl + * @author Ben Foster * @since 2.1 * @see MongoTemplate * @see ReactiveMongoTemplate @@ -82,6 +89,8 @@ class EntityOperations { private final MongoJsonSchemaMapper schemaMapper; + private EvaluationContextProvider evaluationContextProvider = EvaluationContextProvider.DEFAULT; + EntityOperations(MongoConverter converter) { this(converter, new QueryMapper(converter)); } @@ -258,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); } } @@ -331,6 +340,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); }); @@ -865,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 @@ -916,6 +932,26 @@ public CollectionOptions getCollectionOptions() { 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); } @@ -930,13 +966,99 @@ 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()) + .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 03b1147066..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 @@ -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,42 @@ @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 169832f8fe..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 @@ -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,82 @@ void createCollectionShouldSetUpTimeSeries() { .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() { @@ -2430,6 +2507,56 @@ static class TimeSeriesType { 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 0d9bca468c..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 @@ -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,82 @@ void createCollectionShouldSetUpTimeSeries() { .granularity(TimeSeriesGranularity.HOURS).toString()); } + @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) { Publisher realPublisher = Flux.just(document); @@ -1557,6 +1634,55 @@ static class TimeSeriesType { 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 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. *