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. +====