Browse Source

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
pull/4007/merge
Christoph Strobl 3 years ago committed by Christoph Strobl
parent
commit
ecc3f8f2fa
No known key found for this signature in database
GPG Key ID: E6054036D0C37A4B
  1. 51
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java
  2. 149
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java
  3. 216
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/DurationStyle.java
  4. 6
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Indexed.java
  5. 20
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexResolver.java
  6. 26
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/TimeSeries.java
  7. 96
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/DurationUtil.java
  8. 75
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java
  9. 35
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java
  10. 6
      src/main/antora/modules/ROOT/pages/mongodb/template-collection-management.adoc

51
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java

@ -17,6 +17,7 @@ package org.springframework.data.mongodb.core; @@ -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 { @@ -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 { @@ -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<TimeSeriesOptions, TimeSeriesOptions> options) {
return empty().timeSeries(options.apply(TimeSeriesOptions.timeSeries(timeField)));
}
/**
@ -619,9 +632,9 @@ public class CollectionOptions { @@ -619,9 +632,9 @@ public class CollectionOptions {
* Options applicable to Time Series collections.
*
* @author Christoph Strobl
* @since 3.3
* @see <a href=
* "https://docs.mongodb.com/manual/core/timeseries-collections">https://docs.mongodb.com/manual/core/timeseries-collections</a>
* @since 3.3
*/
public static class TimeSeriesOptions {
@ -631,15 +644,16 @@ public class CollectionOptions { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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

149
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java

@ -26,8 +26,11 @@ import java.util.concurrent.TimeUnit; @@ -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; @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -1039,13 +1044,14 @@ class EntityOperations {
*/
static class TypedEntityOperations<T> implements TypedOperations<T> {
private static final SpelExpressionParser PARSER = new SpelExpressionParser();
private final MongoPersistentEntity<T> entity;
private final EvaluationContextProvider evaluationContextProvider;
protected TypedEntityOperations(MongoPersistentEntity<T> entity, EvaluationContextProvider evaluationContextProvider) {
@Nullable private final Environment environment;
protected TypedEntityOperations(MongoPersistentEntity<T> entity, @Nullable Environment environment) {
this.entity = entity;
this.evaluationContextProvider = evaluationContextProvider;
this.environment = environment;
}
@Override
@ -1094,21 +1100,10 @@ class EntityOperations { @@ -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 { @@ -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;
}
}

216
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/DurationStyle.java

@ -1,216 +0,0 @@ @@ -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.
* <br />
* 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<Duration, Long> longValue;
Unit(ChronoUnit chronoUnit, String suffix, Function<Duration, Long> 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 + "'");
}
}
}

6
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/Indexed.java

@ -136,7 +136,9 @@ public @interface Indexed { @@ -136,7 +136,9 @@ public @interface Indexed {
* @return {@literal -1} by default.
* @see <a href=
* "https://docs.mongodb.org/manual/tutorial/expire-data/">https://docs.mongodb.org/manual/tutorial/expire-data/</a>
* @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 { @@ -154,13 +156,9 @@ public @interface Indexed {
* Supports ISO-8601 style.
*
* <pre class="code">
*
* &#0064;Indexed(expireAfter = "10s") String expireAfterTenSeconds;
*
* &#0064;Indexed(expireAfter = "1d") String expireAfterOneDay;
*
* &#0064;Indexed(expireAfter = "P2D") String expireAfterTwoDays;
*
* &#0064;Indexed(expireAfter = "#{&#0064;mySpringBean.timeout}") String expireAfterTimeoutObtainedFromSpringBean;
* </pre>
*

20
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; @@ -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 { @@ -800,24 +801,7 @@ public class MongoPersistentEntityIndexResolver implements IndexResolver {
* @throws IllegalArgumentException for invalid duration values.
*/
private static Duration computeIndexTimeout(String timeoutValue, Supplier<EvaluationContext> 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);
}
/**

26
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/TimeSeries.java

@ -85,17 +85,7 @@ public @interface TimeSeries { @@ -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 <a href=
* "https://www.mongodb.com/docs/manual/core/timeseries/timeseries-automatic-removal/#set-up-automatic-removal-for-time-series-collections--ttl-</a>
*/
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:
* <ul>
* <li><b>d</b>: Days</li>
@ -109,17 +99,15 @@ public @interface TimeSeries { @@ -109,17 +99,15 @@ public @interface TimeSeries {
* Supports ISO-8601 style.
*
* <pre class="code">
*
* &#0064;Indexed(expireAfter = "10s") String expireAfterTenSeconds;
*
* &#0064;Indexed(expireAfter = "1d") String expireAfterOneDay;
*
* &#0064;Indexed(expireAfter = "P2D") String expireAfterTwoDays;
*
* &#0064;Indexed(expireAfter = "#{&#0064;mySpringBean.timeout}") String expireAfterTimeoutObtainedFromSpringBean;
* &#0064;TimeSeries(expireAfter = "10s") String expireAfterTenSeconds;
* &#0064;TimeSeries(expireAfter = "1d") String expireAfterOneDay;
* &#0064;TimeSeries(expireAfter = "P2D") String expireAfterTwoDays;
* &#0064;TimeSeries(expireAfter = "#{&#0064;mySpringBean.timeout}") String expireAfterTimeoutObtainedFromSpringBean;
* &#0064;TimeSeries(expireAfter = "&#36;{my.property.timeout}") String expireAfterTimeoutObtainedFromProperty;
* </pre>
*
* @return empty by default.
* @since 4.4
*/
String expireAfter() default "";
}

96
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/DurationUtil.java

@ -0,0 +1,96 @@ @@ -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> 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);
}
}

75
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; @@ -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 { @@ -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 { @@ -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 { @@ -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<CreateCollectionOptions> 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<CreateCollectionOptions> 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<CreateCollectionOptions> options = ArgumentCaptor.forClass(CreateCollectionOptions.class);
verify(db).createCollection(any(), options.capture());
@ -2426,7 +2432,7 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { @@ -2426,7 +2432,7 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests {
@Test // GH-4099
void createCollectionShouldSetUpTimeSeriesWithExpirationFromExpression() {
template.createCollection(ReactiveMongoTemplateUnitTests.TimeSeriesTypeWithExpireAfterAsExpression.class);
template.createCollection(TimeSeriesTypeWithExpireAfterAsExpression.class);
ArgumentCaptor<CreateCollectionOptions> options = ArgumentCaptor.forClass(CreateCollectionOptions.class);
verify(db).createCollection(any(), options.capture());
@ -2438,7 +2444,7 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { @@ -2438,7 +2444,7 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests {
@Test // GH-4099
void createCollectionShouldSetUpTimeSeriesWithExpirationFromExpressionReturningDuration() {
template.createCollection(ReactiveMongoTemplateUnitTests.TimeSeriesTypeWithExpireAfterAsExpressionResultingInDuration.class);
template.createCollection(TimeSeriesTypeWithExpireAfterAsExpressionResultingInDuration.class);
ArgumentCaptor<CreateCollectionOptions> options = ArgumentCaptor.forClass(CreateCollectionOptions.class);
verify(db).createCollection(any(), options.capture());
@ -2451,15 +2457,7 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { @@ -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 { @@ -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<CreateCollectionOptions> 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<CreateCollectionOptions> 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 { @@ -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 { @@ -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

35
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java

@ -134,6 +134,7 @@ import com.mongodb.reactivestreams.client.MongoDatabase; @@ -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 { @@ -1740,18 +1741,6 @@ public class ReactiveMongoTemplateUnitTests {
verify(collection).withWriteConcern(eq(WriteConcern.UNACKNOWLEDGED));
}
@Test // GH-4099
void createCollectionShouldSetUpTimeSeriesWithExpirationSeconds() {
template.createCollection(TimeSeriesTypeWithExpireAfterSeconds.class).subscribe();
ArgumentCaptor<CreateCollectionOptions> 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 { @@ -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 { @@ -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 { @@ -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<T> {
private final List<T> values = new ArrayList<>(1);

6
src/main/antora/modules/ROOT/pages/mongodb/template-collection-management.adoc

@ -201,3 +201,9 @@ template.createCollection(Measurement.class); @@ -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.
====

Loading…
Cancel
Save