From c9fdd2ef188acaacfc5d4615823d07fdbe685b99 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 3 Jul 2025 11:38:00 +0200 Subject: [PATCH] Allow setting bucket maxSpan & rounding for time series collection. See: #4985 --- .../data/mongodb/core/CollectionOptions.java | 59 ++++++++++++++++--- .../data/mongodb/core/EntityOperations.java | 9 ++- .../core/aggregation/OutOperation.java | 22 +++++-- .../data/mongodb/core/timeseries/Span.java | 40 +++++++++++++ .../core/CollectionOptionsUnitTests.java | 13 ++++ .../aggregation/OutOperationUnitTest.java | 17 ++++++ 6 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/timeseries/Span.java diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/CollectionOptions.java index d7e47eac0..7379949ef 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 @@ -30,7 +30,6 @@ import org.bson.BsonBinarySubType; import org.bson.BsonNull; import org.bson.Document; import org.jspecify.annotations.Nullable; - import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.query.Collation; 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.timeseries.Granularity; 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.util.Optionals; import org.springframework.lang.CheckReturnValue; @@ -982,16 +982,24 @@ public class CollectionOptions { private @Nullable final String metaField; private final GranularityDefinition granularity; + private final @Nullable Span span; private final Duration expireAfter; 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"); + 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.metaField = metaField; this.granularity = granularity; + this.span = span; this.expireAfter = expireAfter; } @@ -1004,7 +1012,7 @@ public class CollectionOptions { * @return new instance of {@link TimeSeriesOptions}. */ 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}.
* {@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}. */ @Contract("_ -> new") - public TimeSeriesOptions metaField(String metaField) { - return new TimeSeriesOptions(timeField, metaField, granularity, expireAfter); + public TimeSeriesOptions metaField(@Nullable String metaField) { + return new TimeSeriesOptions(timeField, metaField, granularity, span, expireAfter); } /** @@ -1030,7 +1038,21 @@ public class CollectionOptions { */ @Contract("_ -> new") 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") 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; } + /** + * Get the span that defines a bucket. + * + * @return {@literal null} if not specified. + * @since 5.0 + */ + public @Nullable Span getSpan() { + return span; + } + @Override public String toString() { return "TimeSeriesOptions{" + "timeField='" + timeField + '\'' + ", metaField='" + metaField + '\'' - + ", granularity=" + granularity + '}'; + + ", granularity=" + granularity + ", span=" + span + ", expireAfter=" + expireAfter + '}'; } @Override @@ -1103,6 +1135,13 @@ public class CollectionOptions { if (!ObjectUtils.nullSafeEquals(metaField, that.metaField)) { return false; } + if (!ObjectUtils.nullSafeEquals(span, that.span)) { + return false; + } + if (!ObjectUtils.nullSafeEquals(expireAfter, that.expireAfter)) { + return false; + } + return ObjectUtils.nullSafeEquals(granularity, that.granularity); } @@ -1111,6 +1150,8 @@ public class CollectionOptions { int result = ObjectUtils.nullSafeHashCode(timeField); result = 31 * result + ObjectUtils.nullSafeHashCode(metaField); result = 31 * result + ObjectUtils.nullSafeHashCode(granularity); + result = 31 * result + ObjectUtils.nullSafeHashCode(span); + result = 31 * result + ObjectUtils.nullSafeHashCode(expireAfter); return result; } } 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 132765635..eab2dc59f 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 @@ -371,6 +371,13 @@ class EntityOperations { if (!Granularity.DEFAULT.equals(it.getGranularity())) { 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()) { result.expireAfter(it.getExpireAfter().toSeconds(), TimeUnit.SECONDS); @@ -1131,7 +1138,7 @@ class EntityOperations { if (StringUtils.hasText(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 diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/OutOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/OutOperation.java index c9c1a3cd8..9a699abe6 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/OutOperation.java +++ b/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.jspecify.annotations.Nullable; - import org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions; import org.springframework.data.mongodb.core.timeseries.Granularity; 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 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 */ - 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"); @@ -110,7 +111,7 @@ public class OutOperation implements AggregationOperation { * * @param timeField must not be {@literal null} or empty. * @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}. * @since 5.0 */ @@ -118,7 +119,10 @@ public class OutOperation implements AggregationOperation { public OutOperation timeSeries(String timeField, @Nullable String metaField, @Nullable Granularity granularity) { 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 @@ -135,6 +139,7 @@ public class OutOperation implements AggregationOperation { } if (timeSeriesOptions != null) { + Document timeSeriesDoc = new Document("timeField", timeSeriesOptions.getTimeField()); if (StringUtils.hasText(timeSeriesOptions.getMetaField())) { @@ -145,6 +150,13 @@ public class OutOperation implements AggregationOperation { 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); } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/timeseries/Span.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/timeseries/Span.java new file mode 100644 index 000000000..2822d46ee --- /dev/null +++ b/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; + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java index fd8c9fb97..023087a7f 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/CollectionOptionsUnitTests.java @@ -16,6 +16,8 @@ package org.springframework.data.mongodb.core; 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.TimeSeriesOptions; 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.queryable; +import java.time.Duration; import java.util.List; 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.MongoJsonSchema; 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; /** @@ -79,6 +84,14 @@ class CollectionOptionsUnitTests { .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 void validatorEquals() { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/OutOperationUnitTest.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/OutOperationUnitTest.java index 55f168aef..1c991a8ce 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/OutOperationUnitTest.java +++ b/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.test.util.Assertions.*; +import java.time.Duration; + import org.bson.Document; import org.junit.jupiter.api.Test; import org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions; import org.springframework.data.mongodb.core.timeseries.Granularity; +import org.springframework.data.mongodb.core.timeseries.Span; /** * Unit tests for {@link OutOperation}. @@ -123,6 +126,20 @@ class OutOperationUnitTest { 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 void outWithTimeFieldOnlyShouldRenderCorrectly() {