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; @@ -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; @@ -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 { @@ -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 { @@ -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 { @@ -1013,12 +1021,12 @@ public class CollectionOptions {
* {@link java.util.Collection}. <br />
* {@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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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;
}
}

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

@ -371,6 +371,13 @@ class EntityOperations { @@ -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 { @@ -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

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; @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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);
}

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

@ -0,0 +1,40 @@ @@ -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 @@ @@ -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 @@ -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; @@ -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 { @@ -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() {

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; @@ -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 { @@ -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() {

Loading…
Cancel
Save