From ecc3f8f2fa0d47712477788ed58e8f0469914200 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Wed, 27 Jul 2022 07:04:00 +0200 Subject: [PATCH] Polishing. Switch to Spring Framework duration formatting. Favour expireAfter with string parameter over the seconds based variant. Deprecate the existing expireAfterSeconds attribute of the Indexed annotation. Consider property value syntax when parsing timeout expressions. Remove DurationStyle (package visible). Update documentation. Original Pull Request: #4114 --- .../data/mongodb/core/CollectionOptions.java | 51 +++-- .../data/mongodb/core/EntityOperations.java | 149 ++++-------- .../mongodb/core/index/DurationStyle.java | 216 ------------------ .../data/mongodb/core/index/Indexed.java | 6 +- .../MongoPersistentEntityIndexResolver.java | 20 +- .../data/mongodb/core/mapping/TimeSeries.java | 26 +-- .../data/mongodb/util/DurationUtil.java | 96 ++++++++ .../mongodb/core/MongoTemplateUnitTests.java | 75 +++--- .../core/ReactiveMongoTemplateUnitTests.java | 35 +-- .../template-collection-management.adoc | 6 + 10 files changed, 236 insertions(+), 444 deletions(-) delete mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/DurationStyle.java create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/DurationUtil.java 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 67fcea933..6322f93bc 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 @@ -17,6 +17,7 @@ package org.springframework.data.mongodb.core; import java.time.Duration; import java.util.Optional; +import java.util.function.Function; import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.query.Collation; @@ -90,7 +91,7 @@ public class CollectionOptions { /** * Quick way to set up {@link CollectionOptions} for a Time Series collection. For more advanced settings use - * {@link #timeSeries(TimeSeriesOptions)}. + * {@link #timeSeries(String, Function)}. * * @param timeField The name of the property which contains the date in each time series document. Must not be * {@literal null}. @@ -99,7 +100,19 @@ public class CollectionOptions { * @since 3.3 */ public static CollectionOptions timeSeries(String timeField) { - return empty().timeSeries(TimeSeriesOptions.timeSeries(timeField)); + return timeSeries(timeField, it -> it); + } + + /** + * Set up {@link CollectionOptions} for a Time Series collection. + * + * @param timeField the name of the field that contains the date in each time series document. + * @param options a function to apply additional settings to {@link TimeSeriesOptions}. + * @return new instance of {@link CollectionOptions}. + * @since 4.4 + */ + public static CollectionOptions timeSeries(String timeField, Function options) { + return empty().timeSeries(options.apply(TimeSeriesOptions.timeSeries(timeField))); } /** @@ -619,9 +632,9 @@ public class CollectionOptions { * Options applicable to Time Series collections. * * @author Christoph Strobl - * @since 3.3 * @see https://docs.mongodb.com/manual/core/timeseries-collections + * @since 3.3 */ public static class TimeSeriesOptions { @@ -631,15 +644,16 @@ public class CollectionOptions { private final GranularityDefinition granularity; - private final long expireAfterSeconds; + private final Duration expireAfter; - private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity, long expireAfterSeconds) { + private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity, + Duration expireAfter) { Assert.hasText(timeField, "Time field must not be empty or null"); this.timeField = timeField; this.metaField = metaField; this.granularity = granularity; - this.expireAfterSeconds = expireAfterSeconds; + this.expireAfter = expireAfter; } /** @@ -651,7 +665,7 @@ public class CollectionOptions { * @return new instance of {@link TimeSeriesOptions}. */ public static TimeSeriesOptions timeSeries(String timeField) { - return new TimeSeriesOptions(timeField, null, Granularity.DEFAULT, -1); + return new TimeSeriesOptions(timeField, null, Granularity.DEFAULT, Duration.ofSeconds(-1)); } /** @@ -664,7 +678,7 @@ public class CollectionOptions { * @return new instance of {@link TimeSeriesOptions}. */ public TimeSeriesOptions metaField(String metaField) { - return new TimeSeriesOptions(timeField, metaField, granularity, expireAfterSeconds); + return new TimeSeriesOptions(timeField, metaField, granularity, expireAfter); } /** @@ -675,17 +689,19 @@ public class CollectionOptions { * @see Granularity */ public TimeSeriesOptions granularity(GranularityDefinition granularity) { - return new TimeSeriesOptions(timeField, metaField, granularity, expireAfterSeconds); + return new TimeSeriesOptions(timeField, metaField, granularity, expireAfter); } /** - * Select the expire parameter to define automatic removal of documents older than a specified - * duration. + * Set the {@link Duration} for automatic removal of documents older than a specified value. * + * @param ttl must not be {@literal null}. * @return new instance of {@link TimeSeriesOptions}. + * @see com.mongodb.client.model.CreateCollectionOptions#expireAfter(long, java.util.concurrent.TimeUnit) + * @since 4.4 */ - public TimeSeriesOptions expireAfter(Duration timeout) { - return new TimeSeriesOptions(timeField, metaField, granularity, timeout.getSeconds()); + public TimeSeriesOptions expireAfter(Duration ttl) { + return new TimeSeriesOptions(timeField, metaField, granularity, ttl); } /** @@ -712,10 +728,13 @@ public class CollectionOptions { } /** - * @return {@literal -1} if not specified + * Get the {@link Duration} for automatic removal of documents. + * + * @return a {@link Duration#isNegative() negative} value if not specified. + * @since 4.4 */ - public long getExpireAfterSeconds() { - return expireAfterSeconds; + public Duration getExpireAfter() { + return expireAfter; } @Override 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 594d6b9bb..c89bade32 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 @@ -26,8 +26,11 @@ import java.util.concurrent.TimeUnit; import org.bson.BsonNull; import org.bson.Document; import org.springframework.core.convert.ConversionService; +import org.springframework.core.env.Environment; +import org.springframework.core.env.EnvironmentCapable; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.convert.CustomConversions; +import org.springframework.data.expression.ValueEvaluationContext; import org.springframework.data.mapping.IdentifierAccessor; import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.PersistentEntity; @@ -41,26 +44,25 @@ import org.springframework.data.mongodb.core.convert.MongoConverter; 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.BasicMongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.FieldName; -import org.springframework.data.mongodb.core.index.DurationStyle; -import org.springframework.data.mongodb.core.mapping.*; +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.query.Collation; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.timeseries.Granularity; import org.springframework.data.mongodb.core.validation.Validator; import org.springframework.data.mongodb.util.BsonUtils; +import org.springframework.data.mongodb.util.DurationUtil; 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.expression.spel.support.SimpleEvaluationContext; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -96,7 +98,7 @@ class EntityOperations { private final MongoJsonSchemaMapper schemaMapper; - private EvaluationContextProvider evaluationContextProvider = EvaluationContextProvider.DEFAULT; + private @Nullable Environment environment; EntityOperations(MongoConverter converter) { this(converter, new QueryMapper(converter)); @@ -117,6 +119,9 @@ class EntityOperations { .and(((target, underlyingType) -> !conversions.isSimpleType(target))), context); this.schemaMapper = new MongoJsonSchemaMapper(converter); + if (converter instanceof EnvironmentCapable environmentCapable) { + this.environment = environmentCapable.getEnvironment(); + } } /** @@ -285,7 +290,7 @@ class EntityOperations { MongoPersistentEntity entity = context.getPersistentEntity(entityClass); if (entity != null) { - return new TypedEntityOperations(entity, evaluationContextProvider); + return new TypedEntityOperations(entity, environment); } } @@ -363,8 +368,8 @@ class EntityOperations { options.granularity(TimeSeriesGranularity.valueOf(it.getGranularity().name().toUpperCase())); } - if (it.getExpireAfterSeconds() >= 0) { - result.expireAfter(it.getExpireAfterSeconds(), TimeUnit.SECONDS); + if (!it.getExpireAfter().isNegative()) { + result.expireAfter(it.getExpireAfter().toSeconds(), TimeUnit.SECONDS); } result.timeSeriesOptions(options); @@ -1039,13 +1044,14 @@ 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, EvaluationContextProvider evaluationContextProvider) { + @Nullable private final Environment environment; + + protected TypedEntityOperations(MongoPersistentEntity entity, @Nullable Environment environment) { + this.entity = entity; - this.evaluationContextProvider = evaluationContextProvider; + this.environment = environment; } @Override @@ -1094,21 +1100,10 @@ class EntityOperations { 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()) { + Duration timeout = computeIndexTimeout(timeSeries.expireAfter(), getEvaluationContextForEntity(entity)); + if (!timeout.isNegative()) { options = options.expireAfter(timeout); } } @@ -1127,105 +1122,45 @@ class EntityOperations { if (StringUtils.hasText(source.getMetaField())) { target = target.metaField(mappedNameOrDefault(source.getMetaField())); } - 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; + return target.granularity(source.getGranularity()).expireAfter(source.getExpireAfter()); } @Override public String getIdKeyName() { return entity.getIdProperty().getName(); } - } - - - /** - * 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); + private String mappedNameOrDefault(String name) { + MongoPersistentProperty persistentProperty = entity.getPersistentProperty(name); + return persistentProperty != null ? persistentProperty.getFieldName() : name; } - /** - * Get the {@link EvaluationContext} for a given {@link PersistentEntity entity} the default one. + * Get the {@link ValueEvaluationContext} for a given {@link PersistentEntity entity} the default one. * * @param persistentEntity can be {@literal null} - * @return + * @return the context to use. */ - private EvaluationContext getEvaluationContextForProperty(@Nullable PersistentEntity persistentEntity) { + private ValueEvaluationContext getEvaluationContextForEntity(@Nullable PersistentEntity persistentEntity) { - if (!(persistentEntity instanceof BasicMongoPersistentEntity)) { - return getEvaluationContext(); + if (persistentEntity instanceof BasicMongoPersistentEntity mongoEntity) { + return mongoEntity.getValueEvaluationContext(null); } - EvaluationContext contextFromEntity = ((BasicMongoPersistentEntity) persistentEntity).getEvaluationContext(null); - - if (!EvaluationContextProvider.DEFAULT.equals(contextFromEntity)) { - return contextFromEntity; - } - - return getEvaluationContext(); + return ValueEvaluationContext.of(this.environment, SimpleEvaluationContext.forReadOnlyDataBinding().build()); } /** - * Get the default {@link EvaluationContext}. + * Compute the index timeout value by evaluating a potential + * {@link org.springframework.expression.spel.standard.SpelExpression} and parsing the final value. * - * @return never {@literal null}. - * @since 2.2 + * @param timeoutValue must not be {@literal null}. + * @param evaluationContext must not be {@literal null}. + * @return never {@literal null} + * @throws IllegalArgumentException for invalid duration values. */ - protected EvaluationContext getEvaluationContext() { - return evaluationContextProvider.getEvaluationContext(null); + private static Duration computeIndexTimeout(String timeoutValue, ValueEvaluationContext evaluationContext) { + return DurationUtil.evaluate(timeoutValue, evaluationContext); } } - - /** - * 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 deleted file mode 100644 index 7790d27ed..000000000 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/DurationStyle.java +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright 2012-2024 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.mongodb.core.index; - -import java.time.Duration; -import java.time.temporal.ChronoUnit; -import java.util.function.Function; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; -import org.springframework.util.StringUtils; - -/** - * Duration format styles. - *
- * Fork of {@code org.springframework.boot.convert.DurationStyle}. - * - * @author Phillip Webb - * @since 2.2 - */ -public enum DurationStyle { - - /** - * Simple formatting, for example '1s'. - */ - SIMPLE("^([\\+\\-]?\\d+)([a-zA-Z]{0,2})$") { - - @Override - public Duration parse(String value, @Nullable ChronoUnit unit) { - try { - Matcher matcher = matcher(value); - Assert.state(matcher.matches(), "Does not match simple duration pattern"); - String suffix = matcher.group(2); - return (StringUtils.hasLength(suffix) ? Unit.fromSuffix(suffix) : Unit.fromChronoUnit(unit)) - .parse(matcher.group(1)); - } catch (Exception ex) { - throw new IllegalArgumentException("'" + value + "' is not a valid simple duration", ex); - } - } - }, - - /** - * ISO-8601 formatting. - */ - ISO8601("^[\\+\\-]?P.*$") { - - @Override - public Duration parse(String value, @Nullable ChronoUnit unit) { - try { - return Duration.parse(value); - } catch (Exception ex) { - throw new IllegalArgumentException("'" + value + "' is not a valid ISO-8601 duration", ex); - } - } - }; - - private final Pattern pattern; - - DurationStyle(String pattern) { - this.pattern = Pattern.compile(pattern); - } - - protected final boolean matches(String value) { - return this.pattern.matcher(value).matches(); - } - - protected final Matcher matcher(String value) { - return this.pattern.matcher(value); - } - - /** - * Parse the given value to a duration. - * - * @param value the value to parse - * @return a duration - */ - public Duration parse(String value) { - return parse(value, null); - } - - /** - * Parse the given value to a duration. - * - * @param value the value to parse - * @param unit the duration unit to use if the value doesn't specify one ({@code null} will default to ms) - * @return a duration - */ - public abstract Duration parse(String value, @Nullable ChronoUnit unit); - - /** - * Detect the style then parse the value to return a duration. - * - * @param value the value to parse - * @return the parsed duration - * @throws IllegalStateException if the value is not a known style or cannot be parsed - */ - public static Duration detectAndParse(String value) { - return detectAndParse(value, null); - } - - /** - * Detect the style then parse the value to return a duration. - * - * @param value the value to parse - * @param unit the duration unit to use if the value doesn't specify one ({@code null} will default to ms) - * @return the parsed duration - * @throws IllegalStateException if the value is not a known style or cannot be parsed - */ - public static Duration detectAndParse(String value, @Nullable ChronoUnit unit) { - return detect(value).parse(value, unit); - } - - /** - * Detect the style from the given source value. - * - * @param value the source value - * @return the duration style - * @throws IllegalStateException if the value is not a known style - */ - public static DurationStyle detect(String value) { - Assert.notNull(value, "Value must not be null"); - for (DurationStyle candidate : values()) { - if (candidate.matches(value)) { - return candidate; - } - } - throw new IllegalArgumentException("'" + value + "' is not a valid duration"); - } - - /** - * Units that we support. - */ - enum Unit { - - /** - * Milliseconds. - */ - MILLIS(ChronoUnit.MILLIS, "ms", Duration::toMillis), - - /** - * Seconds. - */ - SECONDS(ChronoUnit.SECONDS, "s", Duration::getSeconds), - - /** - * Minutes. - */ - MINUTES(ChronoUnit.MINUTES, "m", Duration::toMinutes), - - /** - * Hours. - */ - HOURS(ChronoUnit.HOURS, "h", Duration::toHours), - - /** - * Days. - */ - DAYS(ChronoUnit.DAYS, "d", Duration::toDays); - - private final ChronoUnit chronoUnit; - - private final String suffix; - - private final Function longValue; - - Unit(ChronoUnit chronoUnit, String suffix, Function toUnit) { - this.chronoUnit = chronoUnit; - this.suffix = suffix; - this.longValue = toUnit; - } - - public Duration parse(String value) { - return Duration.of(Long.parseLong(value), this.chronoUnit); - } - - public long longValue(Duration value) { - return this.longValue.apply(value); - } - - public static Unit fromChronoUnit(ChronoUnit chronoUnit) { - if (chronoUnit == null) { - return Unit.MILLIS; - } - for (Unit candidate : values()) { - if (candidate.chronoUnit == chronoUnit) { - return candidate; - } - } - throw new IllegalArgumentException("Unknown unit " + chronoUnit); - } - - public static Unit fromSuffix(String suffix) { - for (Unit candidate : values()) { - if (candidate.suffix.equalsIgnoreCase(suffix)) { - return candidate; - } - } - throw new IllegalArgumentException("Unknown unit '" + suffix + "'"); - } - } -} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Indexed.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Indexed.java index 3f263bac5..88e006d63 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Indexed.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Indexed.java @@ -136,7 +136,9 @@ public @interface Indexed { * @return {@literal -1} by default. * @see https://docs.mongodb.org/manual/tutorial/expire-data/ + * @deprecated since 4.4 - Please use {@link #expireAfter()} instead. */ + @Deprecated(since="4.4", forRemoval = true) int expireAfterSeconds() default -1; /** @@ -154,13 +156,9 @@ public @interface Indexed { * 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;
 	 * 
* diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolver.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolver.java index 9776c219e..86c896e7f 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolver.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolver.java @@ -54,6 +54,7 @@ import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.data.mongodb.core.query.Collation; import org.springframework.data.mongodb.util.BsonUtils; import org.springframework.data.mongodb.util.DotPath; +import org.springframework.data.mongodb.util.DurationUtil; import org.springframework.data.mongodb.util.MongoClientVersion; import org.springframework.data.mongodb.util.spel.ExpressionUtils; import org.springframework.data.spel.EvaluationContextProvider; @@ -800,24 +801,7 @@ public class MongoPersistentEntityIndexResolver implements IndexResolver { * @throws IllegalArgumentException for invalid duration values. */ private static Duration computeIndexTimeout(String timeoutValue, Supplier evaluationContext) { - - Object evaluatedTimeout = ExpressionUtils.evaluate(timeoutValue, evaluationContext); - - if (evaluatedTimeout == null) { - return Duration.ZERO; - } - - if (evaluatedTimeout instanceof Duration duration) { - return duration; - } - - String val = evaluatedTimeout.toString(); - - if (val == null) { - return Duration.ZERO; - } - - return DurationStyle.detectAndParse(val); + return DurationUtil.evaluate(timeoutValue, evaluationContext); } /** 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 2148c04b6..b3006c48b 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,17 +85,7 @@ public @interface TimeSeries { 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. + * 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
  • @@ -109,17 +99,15 @@ public @interface TimeSeries { * 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;
    +	 * @TimeSeries(expireAfter = "10s") String expireAfterTenSeconds;
    +	 * @TimeSeries(expireAfter = "1d") String expireAfterOneDay;
    +	 * @TimeSeries(expireAfter = "P2D") String expireAfterTwoDays;
    +	 * @TimeSeries(expireAfter = "#{@mySpringBean.timeout}") String expireAfterTimeoutObtainedFromSpringBean;
    +	 * @TimeSeries(expireAfter = "${my.property.timeout}") String expireAfterTimeoutObtainedFromProperty;
     	 * 
    * * @return empty by default. + * @since 4.4 */ String expireAfter() default ""; } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/DurationUtil.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/DurationUtil.java new file mode 100644 index 000000000..48f57aa9f --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/DurationUtil.java @@ -0,0 +1,96 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.util; + +import java.time.Duration; +import java.util.function.Supplier; + +import org.springframework.core.env.Environment; +import org.springframework.data.expression.ValueEvaluationContext; +import org.springframework.data.expression.ValueExpression; +import org.springframework.data.expression.ValueExpressionParser; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.format.datetime.standard.DurationFormatterUtils; +import org.springframework.lang.Nullable; + +/** + * Helper to evaluate Duration from expressions. + * + * @author Christoph Strobl + * @since 4.4 + */ +public class DurationUtil { + + private static final ValueExpressionParser PARSER = ValueExpressionParser.create(SpelExpressionParser::new); + + /** + * Evaluates and potentially parses the given string representation into a {@link Duration} value. + * + * @param value the {@link String} representation of the duration to evaluate. + * @param evaluationContext context supplier for property and expression language evaluation. + * @return the evaluated duration. + */ + public static Duration evaluate(String value, ValueEvaluationContext evaluationContext) { + + ValueExpression expression = PARSER.parse(value); + Object evaluatedTimeout = expression.evaluate(evaluationContext); + + if (evaluatedTimeout == null) { + return Duration.ZERO; + } + + if (evaluatedTimeout instanceof Duration duration) { + return duration; + } + + return parse(evaluatedTimeout.toString()); + } + + /** + * Evaluates and potentially parses the given string representation into a {@link Duration} value. + * + * @param value the {@link String} representation of the duration to evaluate. + * @param evaluationContext context supplier for expression language evaluation. + * @return the evaluated duration. + */ + public static Duration evaluate(String value, Supplier evaluationContext) { + + return evaluate(value, new ValueEvaluationContext() { + @Nullable + @Override + public Environment getEnvironment() { + return null; + } + + @Nullable + @Override + public EvaluationContext getEvaluationContext() { + return evaluationContext.get(); + } + }); + } + + /** + * + * @param duration duration string to parse. + * @return parsed {@link Duration}. + * @see DurationFormatterUtils + */ + public static Duration parse(String duration) { + return DurationFormatterUtils.detectAndParse(duration); + } +} 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 6d9e69ba5..d7f3c3566 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 @@ -100,6 +100,7 @@ import org.springframework.data.mongodb.core.timeseries.Granularity; import org.springframework.data.mongodb.util.BsonUtils; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.lang.Nullable; +import org.springframework.mock.env.MockEnvironment; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.CollectionUtils; @@ -166,6 +167,7 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { private MongoExceptionTranslator exceptionTranslator = new MongoExceptionTranslator(); private MappingMongoConverter converter; private MongoMappingContext mappingContext; + private MockEnvironment environment = new MockEnvironment(); @BeforeEach void beforeEach() { @@ -214,10 +216,12 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { this.mappingContext = new MongoMappingContext(); mappingContext.setAutoIndexCreation(true); + mappingContext.setEnvironment(environment); mappingContext.setSimpleTypeHolder(new MongoCustomConversions(Collections.emptyList()).getSimpleTypeHolder()); mappingContext.afterPropertiesSet(); this.converter = spy(new MappingMongoConverter(new DefaultDbRefResolver(factory), mappingContext)); + when(this.converter.getEnvironment()).thenReturn(environment); converter.afterPropertiesSet(); this.template = new MongoTemplate(factory, converter); } @@ -2388,33 +2392,35 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { } @Test // GH-4099 - void createCollectionShouldSetUpTimeSeriesWithExpiration() { + void createCollectionShouldSetUpTimeSeriesWithExpirationFromString() { - template.createCollection(TimeSeriesTypeWithExpireAfterSeconds.class); + template.createCollection(TimeSeriesTypeWithExpireAfterAsPlainString.class); ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); verify(db).createCollection(any(), options.capture()); - assertThat(options.getValue().getExpireAfter(TimeUnit.SECONDS)) - .isEqualTo(60); + assertThat(options.getValue().getExpireAfter(TimeUnit.MINUTES)) + .isEqualTo(10); } @Test // GH-4099 - void createCollectionShouldSetUpTimeSeriesWithExpirationFromString() { + void createCollectionShouldSetUpTimeSeriesWithExpirationFromProperty() { + + environment.setProperty("my.timeout", "12m"); - template.createCollection(ReactiveMongoTemplateUnitTests.TimeSeriesTypeWithExpireAfterAsPlainString.class); + template.createCollection(TimeSeriesTypeWithExpireAfterFromProperty.class); ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); verify(db).createCollection(any(), options.capture()); assertThat(options.getValue().getExpireAfter(TimeUnit.MINUTES)) - .isEqualTo(10); + .isEqualTo(12); } @Test // GH-4099 void createCollectionShouldSetUpTimeSeriesWithExpirationFromIso8601String() { - template.createCollection(ReactiveMongoTemplateUnitTests.TimeSeriesTypeWithExpireAfterAsIso8601Style.class); + template.createCollection(TimeSeriesTypeWithExpireAfterAsIso8601Style.class); ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); verify(db).createCollection(any(), options.capture()); @@ -2426,7 +2432,7 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { @Test // GH-4099 void createCollectionShouldSetUpTimeSeriesWithExpirationFromExpression() { - template.createCollection(ReactiveMongoTemplateUnitTests.TimeSeriesTypeWithExpireAfterAsExpression.class); + template.createCollection(TimeSeriesTypeWithExpireAfterAsExpression.class); ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); verify(db).createCollection(any(), options.capture()); @@ -2438,7 +2444,7 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { @Test // GH-4099 void createCollectionShouldSetUpTimeSeriesWithExpirationFromExpressionReturningDuration() { - template.createCollection(ReactiveMongoTemplateUnitTests.TimeSeriesTypeWithExpireAfterAsExpressionResultingInDuration.class); + template.createCollection(TimeSeriesTypeWithExpireAfterAsExpressionResultingInDuration.class); ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); verify(db).createCollection(any(), options.capture()); @@ -2451,15 +2457,7 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { 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) + template.createCollection(TimeSeriesTypeWithInvalidExpireAfter.class) ); } @@ -2613,7 +2611,32 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { verify(collection).withWriteConcern(eq(WriteConcern.UNACKNOWLEDGED)); } - class AutogenerateableId { + @Test // GH-4099 + void passOnTimeSeriesExpireOption() { + + template.createCollection("time-series-collection", + CollectionOptions.timeSeries("time_stamp", options -> options.expireAfter(Duration.ofSeconds(10)))); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.SECONDS)).isEqualTo(10); + } + + @Test // GH-4099 + void doNotSetTimeSeriesExpireOptionForNegativeValue() { + + template.createCollection("time-series-collection", + CollectionOptions.timeSeries("time_stamp", options -> options.expireAfter(Duration.ofSeconds(-10)))); + + ArgumentCaptor options = ArgumentCaptor.forClass(CreateCollectionOptions.class); + verify(db).createCollection(any(), options.capture()); + + assertThat(options.getValue().getExpireAfter(TimeUnit.SECONDS)).isEqualTo(0L); + } + + + class AutogenerateableId { @Id BigInteger id; } @@ -2782,8 +2805,8 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { Object meta; } - @TimeSeries(timeField = "timestamp", expireAfterSeconds = 60) - static class TimeSeriesTypeWithExpireAfterSeconds { + @TimeSeries(timeField = "timestamp", expireAfter = "${my.timeout}") + static class TimeSeriesTypeWithExpireAfterFromProperty { String id; Instant timestamp; @@ -2824,14 +2847,6 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { 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 01ad29816..957c1e835 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 @@ -134,6 +134,7 @@ import com.mongodb.reactivestreams.client.MongoDatabase; * @author Yadhukrishna S Pai * @author Ben Foster */ +@SuppressWarnings({ "unchecked", "rawtypes" }) @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) public class ReactiveMongoTemplateUnitTests { @@ -1740,18 +1741,6 @@ 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() { @@ -1808,14 +1797,6 @@ public class ReactiveMongoTemplateUnitTests { ); } - @Test // GH-4099 - void createCollectionShouldSetUpTimeSeriesWithDuplicateTimeoutExpiration() { - - assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> - template.createCollection(TimeSeriesTypeWithDuplicateExpireAfter.class).subscribe() - ); - } - private void stubFindSubscribe(Document document) { stubFindSubscribe(document, new AtomicLong()); } @@ -1964,13 +1945,6 @@ 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 { @@ -2006,13 +1980,6 @@ public class ReactiveMongoTemplateUnitTests { 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/src/main/antora/modules/ROOT/pages/mongodb/template-collection-management.adoc b/src/main/antora/modules/ROOT/pages/mongodb/template-collection-management.adoc index 4d8ff007e..cdd20b335 100644 --- a/src/main/antora/modules/ROOT/pages/mongodb/template-collection-management.adoc +++ b/src/main/antora/modules/ROOT/pages/mongodb/template-collection-management.adoc @@ -201,3 +201,9 @@ template.createCollection(Measurement.class); The snippets above can easily be transferred to the reactive API offering the very same methods. Make sure to properly _subscribe_ to the returned publishers. + +[TIP] +==== +You can use the `@TimeSeries#expireAfter` option to have MongoDB automatically remove expired buckets. +The attribute allows different timeout formats like `10s`, `3h`,... as well as expression (`#{@mySpringBean.timeout}`) and property placeholder (`${my.property.timeout}`) syntax. +====