Browse Source

Allow setting bucket maxSpan & rounding for time series collection.

See: #4985
pull/5016/head
Christoph Strobl 6 months ago
parent
commit
c9fdd2ef18
No known key found for this signature in database
GPG Key ID: E6054036D0C37A4B
  1. 59
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java
  2. 9
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityOperations.java
  3. 22
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/OutOperation.java
  4. 40
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/timeseries/Span.java
  5. 13
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java
  6. 17
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/OutOperationUnitTest.java

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

@ -30,7 +30,6 @@ import org.bson.BsonBinarySubType;
import org.bson.BsonNull; import org.bson.BsonNull;
import org.bson.Document; import org.bson.Document;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.query.Collation; import org.springframework.data.mongodb.core.query.Collation;
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty; import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty;
@ -41,6 +40,7 @@ import org.springframework.data.mongodb.core.schema.MongoJsonSchema;
import org.springframework.data.mongodb.core.schema.QueryCharacteristic; import org.springframework.data.mongodb.core.schema.QueryCharacteristic;
import org.springframework.data.mongodb.core.timeseries.Granularity; import org.springframework.data.mongodb.core.timeseries.Granularity;
import org.springframework.data.mongodb.core.timeseries.GranularityDefinition; import org.springframework.data.mongodb.core.timeseries.GranularityDefinition;
import org.springframework.data.mongodb.core.timeseries.Span;
import org.springframework.data.mongodb.core.validation.Validator; import org.springframework.data.mongodb.core.validation.Validator;
import org.springframework.data.util.Optionals; import org.springframework.data.util.Optionals;
import org.springframework.lang.CheckReturnValue; import org.springframework.lang.CheckReturnValue;
@ -982,16 +982,24 @@ public class CollectionOptions {
private @Nullable final String metaField; private @Nullable final String metaField;
private final GranularityDefinition granularity; private final GranularityDefinition granularity;
private final @Nullable Span span;
private final Duration expireAfter; private final Duration expireAfter;
private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity, private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity,
Duration expireAfter) { @Nullable Span span, Duration expireAfter) {
Assert.hasText(timeField, "Time field must not be empty or null"); Assert.hasText(timeField, "Time field must not be empty or null");
if (!Granularity.DEFAULT.equals(granularity) && span != null) {
throw new IllegalArgumentException(
"Cannot use granularity [%s] in conjunction with span".formatted(granularity.name()));
}
this.timeField = timeField; this.timeField = timeField;
this.metaField = metaField; this.metaField = metaField;
this.granularity = granularity; this.granularity = granularity;
this.span = span;
this.expireAfter = expireAfter; this.expireAfter = expireAfter;
} }
@ -1004,7 +1012,7 @@ public class CollectionOptions {
* @return new instance of {@link TimeSeriesOptions}. * @return new instance of {@link TimeSeriesOptions}.
*/ */
public static TimeSeriesOptions timeSeries(String timeField) { public static TimeSeriesOptions timeSeries(String timeField) {
return new TimeSeriesOptions(timeField, null, Granularity.DEFAULT, Duration.ofSeconds(-1)); return new TimeSeriesOptions(timeField, null, Granularity.DEFAULT, null, Duration.ofSeconds(-1));
} }
/** /**
@ -1013,12 +1021,12 @@ public class CollectionOptions {
* {@link java.util.Collection}. <br /> * {@link java.util.Collection}. <br />
* {@link Field#name() Annotated fieldnames} will be considered during the mapping process. * {@link Field#name() Annotated fieldnames} will be considered during the mapping process.
* *
* @param metaField must not be {@literal null}. * @param metaField use {@literal null} to unset.
* @return new instance of {@link TimeSeriesOptions}. * @return new instance of {@link TimeSeriesOptions}.
*/ */
@Contract("_ -> new") @Contract("_ -> new")
public TimeSeriesOptions metaField(String metaField) { public TimeSeriesOptions metaField(@Nullable String metaField) {
return new TimeSeriesOptions(timeField, metaField, granularity, expireAfter); return new TimeSeriesOptions(timeField, metaField, granularity, span, expireAfter);
} }
/** /**
@ -1030,7 +1038,21 @@ public class CollectionOptions {
*/ */
@Contract("_ -> new") @Contract("_ -> new")
public TimeSeriesOptions granularity(GranularityDefinition granularity) { public TimeSeriesOptions granularity(GranularityDefinition granularity) {
return new TimeSeriesOptions(timeField, metaField, granularity, expireAfter); return new TimeSeriesOptions(timeField, metaField, granularity, span, expireAfter);
}
/**
* Select the time between timestamps in the same bucket to define how data in the time series collection is
* organized. Cannot be used in conjunction with {@link #granularity(GranularityDefinition)}.
*
* @param span use {@literal null} to unset.
* @return new instance of {@link TimeSeriesOptions}.
* @see Span
* @since 5.0
*/
@Contract("_ -> new")
public TimeSeriesOptions span(@Nullable Span span) {
return new TimeSeriesOptions(timeField, metaField, granularity, span, expireAfter);
} }
/** /**
@ -1043,7 +1065,7 @@ public class CollectionOptions {
*/ */
@Contract("_ -> new") @Contract("_ -> new")
public TimeSeriesOptions expireAfter(Duration ttl) { public TimeSeriesOptions expireAfter(Duration ttl) {
return new TimeSeriesOptions(timeField, metaField, granularity, ttl); return new TimeSeriesOptions(timeField, metaField, granularity, span, ttl);
} }
/** /**
@ -1079,11 +1101,21 @@ public class CollectionOptions {
return expireAfter; return expireAfter;
} }
/**
* Get the span that defines a bucket.
*
* @return {@literal null} if not specified.
* @since 5.0
*/
public @Nullable Span getSpan() {
return span;
}
@Override @Override
public String toString() { public String toString() {
return "TimeSeriesOptions{" + "timeField='" + timeField + '\'' + ", metaField='" + metaField + '\'' return "TimeSeriesOptions{" + "timeField='" + timeField + '\'' + ", metaField='" + metaField + '\''
+ ", granularity=" + granularity + '}'; + ", granularity=" + granularity + ", span=" + span + ", expireAfter=" + expireAfter + '}';
} }
@Override @Override
@ -1103,6 +1135,13 @@ public class CollectionOptions {
if (!ObjectUtils.nullSafeEquals(metaField, that.metaField)) { if (!ObjectUtils.nullSafeEquals(metaField, that.metaField)) {
return false; return false;
} }
if (!ObjectUtils.nullSafeEquals(span, that.span)) {
return false;
}
if (!ObjectUtils.nullSafeEquals(expireAfter, that.expireAfter)) {
return false;
}
return ObjectUtils.nullSafeEquals(granularity, that.granularity); return ObjectUtils.nullSafeEquals(granularity, that.granularity);
} }
@ -1111,6 +1150,8 @@ public class CollectionOptions {
int result = ObjectUtils.nullSafeHashCode(timeField); int result = ObjectUtils.nullSafeHashCode(timeField);
result = 31 * result + ObjectUtils.nullSafeHashCode(metaField); result = 31 * result + ObjectUtils.nullSafeHashCode(metaField);
result = 31 * result + ObjectUtils.nullSafeHashCode(granularity); result = 31 * result + ObjectUtils.nullSafeHashCode(granularity);
result = 31 * result + ObjectUtils.nullSafeHashCode(span);
result = 31 * result + ObjectUtils.nullSafeHashCode(expireAfter);
return result; return result;
} }
} }

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

@ -371,6 +371,13 @@ class EntityOperations {
if (!Granularity.DEFAULT.equals(it.getGranularity())) { if (!Granularity.DEFAULT.equals(it.getGranularity())) {
options.granularity(TimeSeriesGranularity.valueOf(it.getGranularity().name().toUpperCase())); options.granularity(TimeSeriesGranularity.valueOf(it.getGranularity().name().toUpperCase()));
} }
if (it.getSpan() != null) {
long bucketMaxSpanInSeconds = it.getSpan().time().toSeconds();
// right now there's only one value since the two options must have the same value.
options.bucketMaxSpan(bucketMaxSpanInSeconds, TimeUnit.SECONDS);
options.bucketRounding(bucketMaxSpanInSeconds, TimeUnit.SECONDS);
}
if (!it.getExpireAfter().isNegative()) { if (!it.getExpireAfter().isNegative()) {
result.expireAfter(it.getExpireAfter().toSeconds(), TimeUnit.SECONDS); result.expireAfter(it.getExpireAfter().toSeconds(), TimeUnit.SECONDS);
@ -1131,7 +1138,7 @@ class EntityOperations {
if (StringUtils.hasText(source.getMetaField())) { if (StringUtils.hasText(source.getMetaField())) {
target = target.metaField(mappedNameOrDefault(source.getMetaField())); target = target.metaField(mappedNameOrDefault(source.getMetaField()));
} }
return target.granularity(source.getGranularity()).expireAfter(source.getExpireAfter()); return target.granularity(source.getGranularity()).expireAfter(source.getExpireAfter()).span(source.getSpan());
} }
@Override @Override

22
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/OutOperation.java

@ -17,7 +17,6 @@ package org.springframework.data.mongodb.core.aggregation;
import org.bson.Document; import org.bson.Document;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions; import org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions;
import org.springframework.data.mongodb.core.timeseries.Granularity; import org.springframework.data.mongodb.core.timeseries.Granularity;
import org.springframework.lang.Contract; import org.springframework.lang.Contract;
@ -52,10 +51,12 @@ public class OutOperation implements AggregationOperation {
/** /**
* @param databaseName Optional database name the target collection is located in. Can be {@literal null}. * @param databaseName Optional database name the target collection is located in. Can be {@literal null}.
* @param collectionName Collection name to export the results. Must not be {@literal null}. Can be {@literal null}. * @param collectionName Collection name to export the results. Must not be {@literal null}. Can be {@literal null}.
* @param timeSeriesOptions Optional time series options for creating a time series collection. Can be {@literal null}. * @param timeSeriesOptions Optional time series options for creating a time series collection. Can be
* {@literal null}.
* @since 5.0 * @since 5.0
*/ */
private OutOperation(@Nullable String databaseName, String collectionName, @Nullable TimeSeriesOptions timeSeriesOptions) { private OutOperation(@Nullable String databaseName, String collectionName,
@Nullable TimeSeriesOptions timeSeriesOptions) {
Assert.notNull(collectionName, "Collection name must not be null"); Assert.notNull(collectionName, "Collection name must not be null");
@ -110,7 +111,7 @@ public class OutOperation implements AggregationOperation {
* *
* @param timeField must not be {@literal null} or empty. * @param timeField must not be {@literal null} or empty.
* @param metaField can be {@literal null}. * @param metaField can be {@literal null}.
* @param granularity can be {@literal null}. * @param granularity defaults to {@link Granularity#DEFAULT} if {@literal null}.
* @return new instance of {@link OutOperation}. * @return new instance of {@link OutOperation}.
* @since 5.0 * @since 5.0
*/ */
@ -118,7 +119,10 @@ public class OutOperation implements AggregationOperation {
public OutOperation timeSeries(String timeField, @Nullable String metaField, @Nullable Granularity granularity) { public OutOperation timeSeries(String timeField, @Nullable String metaField, @Nullable Granularity granularity) {
Assert.hasText(timeField, "TimeField must not be null or empty"); Assert.hasText(timeField, "TimeField must not be null or empty");
return timeSeries(TimeSeriesOptions.timeSeries(timeField).metaField(metaField).granularity(granularity));
TimeSeriesOptions options = TimeSeriesOptions.timeSeries(timeField).metaField(metaField)
.granularity(granularity != null ? granularity : Granularity.DEFAULT);
return timeSeries(options);
} }
@Override @Override
@ -135,6 +139,7 @@ public class OutOperation implements AggregationOperation {
} }
if (timeSeriesOptions != null) { if (timeSeriesOptions != null) {
Document timeSeriesDoc = new Document("timeField", timeSeriesOptions.getTimeField()); Document timeSeriesDoc = new Document("timeField", timeSeriesOptions.getTimeField());
if (StringUtils.hasText(timeSeriesOptions.getMetaField())) { if (StringUtils.hasText(timeSeriesOptions.getMetaField())) {
@ -145,6 +150,13 @@ public class OutOperation implements AggregationOperation {
timeSeriesDoc.put("granularity", timeSeriesOptions.getGranularity().name().toLowerCase()); timeSeriesDoc.put("granularity", timeSeriesOptions.getGranularity().name().toLowerCase());
} }
if (timeSeriesOptions.getSpan() != null && timeSeriesOptions.getSpan().time() != null) {
long spanSeconds = timeSeriesOptions.getSpan().time().getSeconds();
timeSeriesDoc.put("bucketMaxSpanSeconds", spanSeconds);
timeSeriesDoc.put("bucketRoundingSeconds", spanSeconds);
}
outDocument.put("timeseries", timeSeriesDoc); outDocument.put("timeseries", timeSeriesDoc);
} }

40
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/timeseries/Span.java

@ -0,0 +1,40 @@
/*
* Copyright 2025-present 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.timeseries;
import java.time.Duration;
/**
* @author Christoph Strobl
* @since 5.0
*/
public interface Span {
/**
* Defines the time between timestamps in the same bucket in a range between {@literal 1-31.536.000} seconds.
*/
Duration time();
/**
* Simple factory to create a {@link Span} for the given {@link Duration}.
*
* @param duration time between timestamps
* @return new instance of {@link Span}.
*/
static Span of(Duration duration) {
return () -> duration;
}
}

13
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java

@ -16,6 +16,8 @@
package org.springframework.data.mongodb.core; package org.springframework.data.mongodb.core;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.springframework.data.mongodb.core.CollectionOptions.EncryptedFieldsOptions; import static org.springframework.data.mongodb.core.CollectionOptions.EncryptedFieldsOptions;
import static org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions; import static org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions;
import static org.springframework.data.mongodb.core.CollectionOptions.emitChangedRevisions; import static org.springframework.data.mongodb.core.CollectionOptions.emitChangedRevisions;
@ -24,6 +26,7 @@ import static org.springframework.data.mongodb.core.CollectionOptions.encryptedC
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.int32; import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.int32;
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.queryable; import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.queryable;
import java.time.Duration;
import java.util.List; import java.util.List;
import org.bson.BsonNull; import org.bson.BsonNull;
@ -33,6 +36,8 @@ import org.springframework.data.mongodb.core.query.Collation;
import org.springframework.data.mongodb.core.schema.JsonSchemaProperty; import org.springframework.data.mongodb.core.schema.JsonSchemaProperty;
import org.springframework.data.mongodb.core.schema.MongoJsonSchema; import org.springframework.data.mongodb.core.schema.MongoJsonSchema;
import org.springframework.data.mongodb.core.schema.QueryCharacteristics; import org.springframework.data.mongodb.core.schema.QueryCharacteristics;
import org.springframework.data.mongodb.core.timeseries.Granularity;
import org.springframework.data.mongodb.core.timeseries.Span;
import org.springframework.data.mongodb.core.validation.Validator; import org.springframework.data.mongodb.core.validation.Validator;
/** /**
@ -79,6 +84,14 @@ class CollectionOptionsUnitTests {
.isNotEqualTo(empty().timeSeries(TimeSeriesOptions.timeSeries("other"))); .isNotEqualTo(empty().timeSeries(TimeSeriesOptions.timeSeries("other")));
} }
@Test // GH-4985
void timeSeriesValidatesGranularityAndSpanSettings() {
assertThatNoException().isThrownBy(() -> empty().timeSeries(TimeSeriesOptions.timeSeries("tf").span(Span.of(Duration.ofSeconds(1))).granularity(Granularity.DEFAULT)));
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> TimeSeriesOptions.timeSeries("tf").granularity(Granularity.HOURS).span(Span.of(Duration.ofSeconds(1))));
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> TimeSeriesOptions.timeSeries("tf").span(Span.of(Duration.ofSeconds(1))).granularity(Granularity.HOURS));
}
@Test // GH-4210 @Test // GH-4210
void validatorEquals() { void validatorEquals() {

17
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/OutOperationUnitTest.java

@ -18,10 +18,13 @@ package org.springframework.data.mongodb.core.aggregation;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
import static org.springframework.data.mongodb.test.util.Assertions.*; import static org.springframework.data.mongodb.test.util.Assertions.*;
import java.time.Duration;
import org.bson.Document; import org.bson.Document;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions; import org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions;
import org.springframework.data.mongodb.core.timeseries.Granularity; import org.springframework.data.mongodb.core.timeseries.Granularity;
import org.springframework.data.mongodb.core.timeseries.Span;
/** /**
* Unit tests for {@link OutOperation}. * Unit tests for {@link OutOperation}.
@ -123,6 +126,20 @@ class OutOperationUnitTest {
assertThat(result).containsEntry("$out.timeseries.granularity", "seconds"); assertThat(result).containsEntry("$out.timeseries.granularity", "seconds");
} }
@Test // GH-4985
void outWithTimeSeriesOptionsUsingSpanShouldRenderCorrectly() {
TimeSeriesOptions options = TimeSeriesOptions.timeSeries("timestamp").metaField("metadata").span(Span.of(Duration.ofMinutes(2)));
Document result = Aggregation.out("timeseries-col", options).toDocument(Aggregation.DEFAULT_CONTEXT);
assertThat(result).containsEntry("$out.coll", "timeseries-col");
assertThat(result).containsEntry("$out.timeseries.timeField", "timestamp");
assertThat(result).containsEntry("$out.timeseries.metaField", "metadata");
assertThat(result).containsEntry("$out.timeseries.bucketMaxSpanSeconds", 120L);
assertThat(result).containsEntry("$out.timeseries.bucketRoundingSeconds", 120L);
assertThat(result).doesNotContainKey("$out.timeseries.granularity");
}
@Test // GH-4985 @Test // GH-4985
void outWithTimeFieldOnlyShouldRenderCorrectly() { void outWithTimeFieldOnlyShouldRenderCorrectly() {

Loading…
Cancel
Save