Skip to content

Commit c9fdd2e

Browse files
Allow setting bucket maxSpan & rounding for time series collection.
See: #4985
1 parent 479d213 commit c9fdd2e

File tree

6 files changed

+145
-15
lines changed

6 files changed

+145
-15
lines changed

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

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
import org.bson.BsonNull;
3131
import org.bson.Document;
3232
import org.jspecify.annotations.Nullable;
33-
3433
import org.springframework.data.mongodb.core.mapping.Field;
3534
import org.springframework.data.mongodb.core.query.Collation;
3635
import org.springframework.data.mongodb.core.schema.IdentifiableJsonSchemaProperty;
@@ -41,6 +40,7 @@
4140
import org.springframework.data.mongodb.core.schema.QueryCharacteristic;
4241
import org.springframework.data.mongodb.core.timeseries.Granularity;
4342
import org.springframework.data.mongodb.core.timeseries.GranularityDefinition;
43+
import org.springframework.data.mongodb.core.timeseries.Span;
4444
import org.springframework.data.mongodb.core.validation.Validator;
4545
import org.springframework.data.util.Optionals;
4646
import org.springframework.lang.CheckReturnValue;
@@ -982,16 +982,24 @@ public static class TimeSeriesOptions {
982982
private @Nullable final String metaField;
983983

984984
private final GranularityDefinition granularity;
985+
private final @Nullable Span span;
985986

986987
private final Duration expireAfter;
987988

988989
private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity,
989-
Duration expireAfter) {
990+
@Nullable Span span, Duration expireAfter) {
991+
990992
Assert.hasText(timeField, "Time field must not be empty or null");
991993

994+
if (!Granularity.DEFAULT.equals(granularity) && span != null) {
995+
throw new IllegalArgumentException(
996+
"Cannot use granularity [%s] in conjunction with span".formatted(granularity.name()));
997+
}
998+
992999
this.timeField = timeField;
9931000
this.metaField = metaField;
9941001
this.granularity = granularity;
1002+
this.span = span;
9951003
this.expireAfter = expireAfter;
9961004
}
9971005

@@ -1004,7 +1012,7 @@ private TimeSeriesOptions(String timeField, @Nullable String metaField, Granular
10041012
* @return new instance of {@link TimeSeriesOptions}.
10051013
*/
10061014
public static TimeSeriesOptions timeSeries(String timeField) {
1007-
return new TimeSeriesOptions(timeField, null, Granularity.DEFAULT, Duration.ofSeconds(-1));
1015+
return new TimeSeriesOptions(timeField, null, Granularity.DEFAULT, null, Duration.ofSeconds(-1));
10081016
}
10091017

10101018
/**
@@ -1013,12 +1021,12 @@ public static TimeSeriesOptions timeSeries(String timeField) {
10131021
* {@link java.util.Collection}. <br />
10141022
* {@link Field#name() Annotated fieldnames} will be considered during the mapping process.
10151023
*
1016-
* @param metaField must not be {@literal null}.
1024+
* @param metaField use {@literal null} to unset.
10171025
* @return new instance of {@link TimeSeriesOptions}.
10181026
*/
10191027
@Contract("_ -> new")
1020-
public TimeSeriesOptions metaField(String metaField) {
1021-
return new TimeSeriesOptions(timeField, metaField, granularity, expireAfter);
1028+
public TimeSeriesOptions metaField(@Nullable String metaField) {
1029+
return new TimeSeriesOptions(timeField, metaField, granularity, span, expireAfter);
10221030
}
10231031

10241032
/**
@@ -1030,7 +1038,21 @@ public TimeSeriesOptions metaField(String metaField) {
10301038
*/
10311039
@Contract("_ -> new")
10321040
public TimeSeriesOptions granularity(GranularityDefinition granularity) {
1033-
return new TimeSeriesOptions(timeField, metaField, granularity, expireAfter);
1041+
return new TimeSeriesOptions(timeField, metaField, granularity, span, expireAfter);
1042+
}
1043+
1044+
/**
1045+
* Select the time between timestamps in the same bucket to define how data in the time series collection is
1046+
* organized. Cannot be used in conjunction with {@link #granularity(GranularityDefinition)}.
1047+
*
1048+
* @param span use {@literal null} to unset.
1049+
* @return new instance of {@link TimeSeriesOptions}.
1050+
* @see Span
1051+
* @since 5.0
1052+
*/
1053+
@Contract("_ -> new")
1054+
public TimeSeriesOptions span(@Nullable Span span) {
1055+
return new TimeSeriesOptions(timeField, metaField, granularity, span, expireAfter);
10341056
}
10351057

10361058
/**
@@ -1043,7 +1065,7 @@ public TimeSeriesOptions granularity(GranularityDefinition granularity) {
10431065
*/
10441066
@Contract("_ -> new")
10451067
public TimeSeriesOptions expireAfter(Duration ttl) {
1046-
return new TimeSeriesOptions(timeField, metaField, granularity, ttl);
1068+
return new TimeSeriesOptions(timeField, metaField, granularity, span, ttl);
10471069
}
10481070

10491071
/**
@@ -1079,11 +1101,21 @@ public Duration getExpireAfter() {
10791101
return expireAfter;
10801102
}
10811103

1104+
/**
1105+
* Get the span that defines a bucket.
1106+
*
1107+
* @return {@literal null} if not specified.
1108+
* @since 5.0
1109+
*/
1110+
public @Nullable Span getSpan() {
1111+
return span;
1112+
}
1113+
10821114
@Override
10831115
public String toString() {
10841116

10851117
return "TimeSeriesOptions{" + "timeField='" + timeField + '\'' + ", metaField='" + metaField + '\''
1086-
+ ", granularity=" + granularity + '}';
1118+
+ ", granularity=" + granularity + ", span=" + span + ", expireAfter=" + expireAfter + '}';
10871119
}
10881120

10891121
@Override
@@ -1103,6 +1135,13 @@ public boolean equals(@Nullable Object o) {
11031135
if (!ObjectUtils.nullSafeEquals(metaField, that.metaField)) {
11041136
return false;
11051137
}
1138+
if (!ObjectUtils.nullSafeEquals(span, that.span)) {
1139+
return false;
1140+
}
1141+
if (!ObjectUtils.nullSafeEquals(expireAfter, that.expireAfter)) {
1142+
return false;
1143+
}
1144+
11061145
return ObjectUtils.nullSafeEquals(granularity, that.granularity);
11071146
}
11081147

@@ -1111,6 +1150,8 @@ public int hashCode() {
11111150
int result = ObjectUtils.nullSafeHashCode(timeField);
11121151
result = 31 * result + ObjectUtils.nullSafeHashCode(metaField);
11131152
result = 31 * result + ObjectUtils.nullSafeHashCode(granularity);
1153+
result = 31 * result + ObjectUtils.nullSafeHashCode(span);
1154+
result = 31 * result + ObjectUtils.nullSafeHashCode(expireAfter);
11141155
return result;
11151156
}
11161157
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,13 @@ public CreateCollectionOptions convertToCreateCollectionOptions(@Nullable Collec
371371
if (!Granularity.DEFAULT.equals(it.getGranularity())) {
372372
options.granularity(TimeSeriesGranularity.valueOf(it.getGranularity().name().toUpperCase()));
373373
}
374+
if (it.getSpan() != null) {
375+
376+
long bucketMaxSpanInSeconds = it.getSpan().time().toSeconds();
377+
// right now there's only one value since the two options must have the same value.
378+
options.bucketMaxSpan(bucketMaxSpanInSeconds, TimeUnit.SECONDS);
379+
options.bucketRounding(bucketMaxSpanInSeconds, TimeUnit.SECONDS);
380+
}
374381

375382
if (!it.getExpireAfter().isNegative()) {
376383
result.expireAfter(it.getExpireAfter().toSeconds(), TimeUnit.SECONDS);
@@ -1131,7 +1138,7 @@ public TimeSeriesOptions mapTimeSeriesOptions(TimeSeriesOptions source) {
11311138
if (StringUtils.hasText(source.getMetaField())) {
11321139
target = target.metaField(mappedNameOrDefault(source.getMetaField()));
11331140
}
1134-
return target.granularity(source.getGranularity()).expireAfter(source.getExpireAfter());
1141+
return target.granularity(source.getGranularity()).expireAfter(source.getExpireAfter()).span(source.getSpan());
11351142
}
11361143

11371144
@Override

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
import org.bson.Document;
1919
import org.jspecify.annotations.Nullable;
20-
2120
import org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions;
2221
import org.springframework.data.mongodb.core.timeseries.Granularity;
2322
import org.springframework.lang.Contract;
@@ -52,10 +51,12 @@ public OutOperation(String outCollectionName) {
5251
/**
5352
* @param databaseName Optional database name the target collection is located in. Can be {@literal null}.
5453
* @param collectionName Collection name to export the results. Must not be {@literal null}. Can be {@literal null}.
55-
* @param timeSeriesOptions Optional time series options for creating a time series collection. Can be {@literal null}.
54+
* @param timeSeriesOptions Optional time series options for creating a time series collection. Can be
55+
* {@literal null}.
5656
* @since 5.0
5757
*/
58-
private OutOperation(@Nullable String databaseName, String collectionName, @Nullable TimeSeriesOptions timeSeriesOptions) {
58+
private OutOperation(@Nullable String databaseName, String collectionName,
59+
@Nullable TimeSeriesOptions timeSeriesOptions) {
5960

6061
Assert.notNull(collectionName, "Collection name must not be null");
6162

@@ -110,15 +111,18 @@ public OutOperation timeSeries(String timeField) {
110111
*
111112
* @param timeField must not be {@literal null} or empty.
112113
* @param metaField can be {@literal null}.
113-
* @param granularity can be {@literal null}.
114+
* @param granularity defaults to {@link Granularity#DEFAULT} if {@literal null}.
114115
* @return new instance of {@link OutOperation}.
115116
* @since 5.0
116117
*/
117118
@Contract("_, _, _ -> new")
118119
public OutOperation timeSeries(String timeField, @Nullable String metaField, @Nullable Granularity granularity) {
119120

120121
Assert.hasText(timeField, "TimeField must not be null or empty");
121-
return timeSeries(TimeSeriesOptions.timeSeries(timeField).metaField(metaField).granularity(granularity));
122+
123+
TimeSeriesOptions options = TimeSeriesOptions.timeSeries(timeField).metaField(metaField)
124+
.granularity(granularity != null ? granularity : Granularity.DEFAULT);
125+
return timeSeries(options);
122126
}
123127

124128
@Override
@@ -135,6 +139,7 @@ public Document toDocument(AggregationOperationContext context) {
135139
}
136140

137141
if (timeSeriesOptions != null) {
142+
138143
Document timeSeriesDoc = new Document("timeField", timeSeriesOptions.getTimeField());
139144

140145
if (StringUtils.hasText(timeSeriesOptions.getMetaField())) {
@@ -145,6 +150,13 @@ public Document toDocument(AggregationOperationContext context) {
145150
timeSeriesDoc.put("granularity", timeSeriesOptions.getGranularity().name().toLowerCase());
146151
}
147152

153+
if (timeSeriesOptions.getSpan() != null && timeSeriesOptions.getSpan().time() != null) {
154+
155+
long spanSeconds = timeSeriesOptions.getSpan().time().getSeconds();
156+
timeSeriesDoc.put("bucketMaxSpanSeconds", spanSeconds);
157+
timeSeriesDoc.put("bucketRoundingSeconds", spanSeconds);
158+
}
159+
148160
outDocument.put("timeseries", timeSeriesDoc);
149161
}
150162

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright 2025-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.mongodb.core.timeseries;
17+
18+
import java.time.Duration;
19+
20+
/**
21+
* @author Christoph Strobl
22+
* @since 5.0
23+
*/
24+
public interface Span {
25+
26+
/**
27+
* Defines the time between timestamps in the same bucket in a range between {@literal 1-31.536.000} seconds.
28+
*/
29+
Duration time();
30+
31+
/**
32+
* Simple factory to create a {@link Span} for the given {@link Duration}.
33+
*
34+
* @param duration time between timestamps
35+
* @return new instance of {@link Span}.
36+
*/
37+
static Span of(Duration duration) {
38+
return () -> duration;
39+
}
40+
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
package org.springframework.data.mongodb.core;
1717

1818
import static org.assertj.core.api.Assertions.assertThat;
19+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
20+
import static org.assertj.core.api.Assertions.assertThatNoException;
1921
import static org.springframework.data.mongodb.core.CollectionOptions.EncryptedFieldsOptions;
2022
import static org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions;
2123
import static org.springframework.data.mongodb.core.CollectionOptions.emitChangedRevisions;
@@ -24,6 +26,7 @@
2426
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.int32;
2527
import static org.springframework.data.mongodb.core.schema.JsonSchemaProperty.queryable;
2628

29+
import java.time.Duration;
2730
import java.util.List;
2831

2932
import org.bson.BsonNull;
@@ -33,6 +36,8 @@
3336
import org.springframework.data.mongodb.core.schema.JsonSchemaProperty;
3437
import org.springframework.data.mongodb.core.schema.MongoJsonSchema;
3538
import org.springframework.data.mongodb.core.schema.QueryCharacteristics;
39+
import org.springframework.data.mongodb.core.timeseries.Granularity;
40+
import org.springframework.data.mongodb.core.timeseries.Span;
3641
import org.springframework.data.mongodb.core.validation.Validator;
3742

3843
/**
@@ -79,6 +84,14 @@ void timeSeriesEquals() {
7984
.isNotEqualTo(empty().timeSeries(TimeSeriesOptions.timeSeries("other")));
8085
}
8186

87+
@Test // GH-4985
88+
void timeSeriesValidatesGranularityAndSpanSettings() {
89+
90+
assertThatNoException().isThrownBy(() -> empty().timeSeries(TimeSeriesOptions.timeSeries("tf").span(Span.of(Duration.ofSeconds(1))).granularity(Granularity.DEFAULT)));
91+
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> TimeSeriesOptions.timeSeries("tf").granularity(Granularity.HOURS).span(Span.of(Duration.ofSeconds(1))));
92+
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> TimeSeriesOptions.timeSeries("tf").span(Span.of(Duration.ofSeconds(1))).granularity(Granularity.HOURS));
93+
}
94+
8295
@Test // GH-4210
8396
void validatorEquals() {
8497

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@
1818
import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
1919
import static org.springframework.data.mongodb.test.util.Assertions.*;
2020

21+
import java.time.Duration;
22+
2123
import org.bson.Document;
2224
import org.junit.jupiter.api.Test;
2325
import org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions;
2426
import org.springframework.data.mongodb.core.timeseries.Granularity;
27+
import org.springframework.data.mongodb.core.timeseries.Span;
2528

2629
/**
2730
* Unit tests for {@link OutOperation}.
@@ -123,6 +126,20 @@ void outWithTimeSeriesOptionsShouldRenderCorrectly() {
123126
assertThat(result).containsEntry("$out.timeseries.granularity", "seconds");
124127
}
125128

129+
@Test // GH-4985
130+
void outWithTimeSeriesOptionsUsingSpanShouldRenderCorrectly() {
131+
132+
TimeSeriesOptions options = TimeSeriesOptions.timeSeries("timestamp").metaField("metadata").span(Span.of(Duration.ofMinutes(2)));
133+
Document result = Aggregation.out("timeseries-col", options).toDocument(Aggregation.DEFAULT_CONTEXT);
134+
135+
assertThat(result).containsEntry("$out.coll", "timeseries-col");
136+
assertThat(result).containsEntry("$out.timeseries.timeField", "timestamp");
137+
assertThat(result).containsEntry("$out.timeseries.metaField", "metadata");
138+
assertThat(result).containsEntry("$out.timeseries.bucketMaxSpanSeconds", 120L);
139+
assertThat(result).containsEntry("$out.timeseries.bucketRoundingSeconds", 120L);
140+
assertThat(result).doesNotContainKey("$out.timeseries.granularity");
141+
}
142+
126143
@Test // GH-4985
127144
void outWithTimeFieldOnlyShouldRenderCorrectly() {
128145

0 commit comments

Comments
 (0)