From 3c8d2e4bf49087182aef7343533a6ff5c9adb098 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 3 Jul 2025 11:39:07 +0200 Subject: [PATCH 1/4] Prepare issue branch. --- pom.xml | 2 +- spring-data-mongodb-distribution/pom.xml | 2 +- spring-data-mongodb/pom.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 3daab4d790..c2cbbf7b60 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-mongodb-parent - 5.0.0-SNAPSHOT + 5.0.x-GH-4985-SNAPSHOT pom Spring Data MongoDB diff --git a/spring-data-mongodb-distribution/pom.xml b/spring-data-mongodb-distribution/pom.xml index fc88571622..d51dc07dcf 100644 --- a/spring-data-mongodb-distribution/pom.xml +++ b/spring-data-mongodb-distribution/pom.xml @@ -15,7 +15,7 @@ org.springframework.data spring-data-mongodb-parent - 5.0.0-SNAPSHOT + 5.0.x-GH-4985-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index 6f34da5660..ac0adc4269 100644 --- a/spring-data-mongodb/pom.xml +++ b/spring-data-mongodb/pom.xml @@ -13,7 +13,7 @@ org.springframework.data spring-data-mongodb-parent - 5.0.0-SNAPSHOT + 5.0.x-GH-4985-SNAPSHOT ../pom.xml From 2456f4acabef3fa8d72a7d772d9f494fc0b9d0c0 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 3 Jul 2025 11:37:12 +0200 Subject: [PATCH 2/4] Follow changes in data commons. --- .../mongodb/repository/aot/MongoRepositoryMetadataTests.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/MongoRepositoryMetadataTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/MongoRepositoryMetadataTests.java index aa069a2710..fe65d21084 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/MongoRepositoryMetadataTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/aot/MongoRepositoryMetadataTests.java @@ -23,6 +23,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.List; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -150,7 +151,7 @@ void shouldDocumentAggregation() throws IOException { assertThatJson(json).inPath("$.methods[?(@.name == 'findAllLastnames')].query").isArray().element(0).isObject() .containsEntry("pipeline", - "[{ '$match' : { 'last_name' : { '$ne' : null } } }, { '$project': { '_id' : '$last_name' } }]"); + List.of("{ '$match' : { 'last_name' : { '$ne' : null } } }", "{ '$project': { '_id' : '$last_name' } }")); } @Test // GH-4964 @@ -165,7 +166,7 @@ void shouldDocumentPipelineUpdate() throws IOException { assertThatJson(json).inPath("$.methods[?(@.name == 'findAndIncrementVisitsViaPipelineByLastname')].query").isArray() .element(0).isObject().containsEntry("filter", "{'lastname':?0}").containsEntry("update-pipeline", - "[{ '$set' : { 'visits' : { '$ifNull' : [ {'$add' : [ '$visits', ?1 ] }, ?1 ] } } }]"); + List.of("{ '$set' : { 'visits' : { '$ifNull' : [ {'$add' : [ '$visits', ?1 ] }, ?1 ] } } }")); } @Test // GH-4964 From cc84195174d8e782a13155086792590267605584 Mon Sep 17 00:00:00 2001 From: Hyunsang Han Date: Fri, 6 Jun 2025 14:54:32 +0900 Subject: [PATCH 3/4] Add time series collection support to `$out` aggregation operation. MongoDB `$out` stage now supports creating time series collections with configurable time field, metadata field, and granularity options. Signed-off-by: Hyunsang Han --- .../mongodb/core/aggregation/Aggregation.java | 44 +++++++++ .../core/aggregation/OutOperation.java | 82 ++++++++++++++-- .../aggregation/OutOperationUnitTest.java | 98 +++++++++++++++++++ 3 files changed, 218 insertions(+), 6 deletions(-) diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java index 45de38ed21..d82db54e9c 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java @@ -22,8 +22,10 @@ import org.bson.Document; import org.bson.conversions.Bson; +import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.mongodb.core.CollectionOptions.TimeSeriesOptions; import org.springframework.data.mongodb.core.aggregation.AddFieldsOperation.AddFieldsOperationBuilder; import org.springframework.data.mongodb.core.aggregation.CountOperation.CountOperationBuilder; import org.springframework.data.mongodb.core.aggregation.FacetOperation.FacetOperationBuilder; @@ -37,6 +39,7 @@ import org.springframework.data.mongodb.core.query.CriteriaDefinition; import org.springframework.data.mongodb.core.query.NearQuery; import org.springframework.data.mongodb.core.query.SerializationUtils; +import org.springframework.data.mongodb.core.timeseries.Granularity; import org.springframework.util.Assert; /** @@ -53,6 +56,7 @@ * @author Gustavo de Geus * @author Jérôme Guyon * @author Sangyong Choi + * @author Hyunsang Han * @since 1.3 */ public class Aggregation { @@ -586,6 +590,46 @@ public static OutOperation out(String outCollectionName) { return new OutOperation(outCollectionName); } + /** + * Creates a new {@link OutOperation} for time series collections using the given collection name and time series + * options. + * + * @param outCollectionName collection name to export aggregation results. Must not be {@literal null}. + * @param timeSeriesOptions must not be {@literal null}. + * @return new instance of {@link OutOperation}. + * @since 5.0 + */ + public static OutOperation out(String outCollectionName, TimeSeriesOptions timeSeriesOptions) { + return new OutOperation(outCollectionName).timeSeries(timeSeriesOptions); + } + + /** + * Creates a new {@link OutOperation} for time series collections using the given collection name and time field. + * + * @param outCollectionName collection name to export aggregation results. Must not be {@literal null}. + * @param timeField must not be {@literal null} or empty. + * @return new instance of {@link OutOperation}. + * @since 5.0 + */ + public static OutOperation out(String outCollectionName, String timeField) { + return new OutOperation(outCollectionName).timeSeries(timeField); + } + + /** + * Creates a new {@link OutOperation} for time series collections using the given collection name, time field, meta + * field, and granularity. + * + * @param outCollectionName collection name to export aggregation results. Must not be {@literal null}. + * @param timeField must not be {@literal null} or empty. + * @param metaField can be {@literal null}. + * @param granularity can be {@literal null}. + * @return new instance of {@link OutOperation}. + * @since 5.0 + */ + public static OutOperation out(String outCollectionName, String timeField, @Nullable String metaField, @Nullable Granularity granularity) { + return new OutOperation(outCollectionName).timeSeries(timeField, metaField, granularity); + } + /** * Creates a new {@link BucketOperation} given {@literal groupByField}. * 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 9d0ddf6d67..c9c1a3cd8d 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 @@ -18,6 +18,8 @@ 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; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -30,6 +32,7 @@ * * @author Nikolay Bogdanov * @author Christoph Strobl + * @author Hyunsang Han * @see MongoDB Aggregation Framework: * $out */ @@ -37,25 +40,28 @@ public class OutOperation implements AggregationOperation { private final @Nullable String databaseName; private final String collectionName; + private final @Nullable TimeSeriesOptions timeSeriesOptions; /** * @param outCollectionName Collection name to export the results. Must not be {@literal null}. */ public OutOperation(String outCollectionName) { - this(null, outCollectionName); + this(null, outCollectionName, 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}. - * @since 2.2 + * @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) { + private OutOperation(@Nullable String databaseName, String collectionName, @Nullable TimeSeriesOptions timeSeriesOptions) { Assert.notNull(collectionName, "Collection name must not be null"); this.databaseName = databaseName; this.collectionName = collectionName; + this.timeSeriesOptions = timeSeriesOptions; } /** @@ -68,17 +74,81 @@ private OutOperation(@Nullable String databaseName, String collectionName) { */ @Contract("_ -> new") public OutOperation in(@Nullable String database) { - return new OutOperation(database, collectionName); + return new OutOperation(database, collectionName, timeSeriesOptions); + } + + /** + * Set the time series options for creating a time series collection. + * + * @param timeSeriesOptions must not be {@literal null}. + * @return new instance of {@link OutOperation}. + * @since 5.0 + */ + @Contract("_ -> new") + public OutOperation timeSeries(TimeSeriesOptions timeSeriesOptions) { + + Assert.notNull(timeSeriesOptions, "TimeSeriesOptions must not be null"); + return new OutOperation(databaseName, collectionName, timeSeriesOptions); + } + + /** + * Set the time series options for creating a time series collection with only the time field. + * + * @param timeField must not be {@literal null} or empty. + * @return new instance of {@link OutOperation}. + * @since 5.0 + */ + @Contract("_ -> new") + public OutOperation timeSeries(String timeField) { + + Assert.hasText(timeField, "TimeField must not be null or empty"); + return timeSeries(TimeSeriesOptions.timeSeries(timeField)); + } + + /** + * Set the time series options for creating a time series collection with time field, meta field, and granularity. + * + * @param timeField must not be {@literal null} or empty. + * @param metaField can be {@literal null}. + * @param granularity can be {@literal null}. + * @return new instance of {@link OutOperation}. + * @since 5.0 + */ + @Contract("_, _, _ -> new") + 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)); } @Override public Document toDocument(AggregationOperationContext context) { - if (!StringUtils.hasText(databaseName)) { + if (!StringUtils.hasText(databaseName) && timeSeriesOptions == null) { return new Document(getOperator(), collectionName); } - return new Document(getOperator(), new Document("db", databaseName).append("coll", collectionName)); + Document outDocument = new Document("coll", collectionName); + + if (StringUtils.hasText(databaseName)) { + outDocument.put("db", databaseName); + } + + if (timeSeriesOptions != null) { + Document timeSeriesDoc = new Document("timeField", timeSeriesOptions.getTimeField()); + + if (StringUtils.hasText(timeSeriesOptions.getMetaField())) { + timeSeriesDoc.put("metaField", timeSeriesOptions.getMetaField()); + } + + if (timeSeriesOptions.getGranularity() != null && timeSeriesOptions.getGranularity() != Granularity.DEFAULT) { + timeSeriesDoc.put("granularity", timeSeriesOptions.getGranularity().name().toLowerCase()); + } + + outDocument.put("timeseries", timeSeriesDoc); + } + + return new Document(getOperator(), outDocument); } @Override 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 a86433175a..55f168aef8 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 @@ -20,6 +20,8 @@ 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; /** * Unit tests for {@link OutOperation}. @@ -27,6 +29,7 @@ * @author Nikolay Bogdanov * @author Christoph Strobl * @author Mark Paluch + * @author Hyunsang Han */ class OutOperationUnitTest { @@ -48,4 +51,99 @@ void shouldRenderDocument() { .containsEntry("$out.db", "database-2"); } + @Test // GH-4985 + void shouldRenderTimeSeriesCollectionWithTimeFieldOnly() { + + Document result = out("timeseries-col").timeSeries("timestamp").toDocument(Aggregation.DEFAULT_CONTEXT); + + assertThat(result).containsEntry("$out.coll", "timeseries-col"); + assertThat(result).containsEntry("$out.timeseries.timeField", "timestamp"); + assertThat(result).doesNotContainKey("$out.timeseries.metaField"); + assertThat(result).doesNotContainKey("$out.timeseries.granularity"); + } + + @Test // GH-4985 + void shouldRenderTimeSeriesCollectionWithAllOptions() { + + Document result = out("timeseries-col").timeSeries("timestamp", "metadata", Granularity.SECONDS) + .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.granularity", "seconds"); + } + + @Test // GH-4985 + void shouldRenderTimeSeriesCollectionWithDatabaseAndAllOptions() { + + Document result = out("timeseries-col").in("test-db").timeSeries("timestamp", "metadata", Granularity.MINUTES) + .toDocument(Aggregation.DEFAULT_CONTEXT); + + assertThat(result).containsEntry("$out.coll", "timeseries-col"); + assertThat(result).containsEntry("$out.db", "test-db"); + assertThat(result).containsEntry("$out.timeseries.timeField", "timestamp"); + assertThat(result).containsEntry("$out.timeseries.metaField", "metadata"); + assertThat(result).containsEntry("$out.timeseries.granularity", "minutes"); + } + + @Test // GH-4985 + void shouldRenderTimeSeriesCollectionWithTimeSeriesOptions() { + + TimeSeriesOptions options = TimeSeriesOptions.timeSeries("timestamp").metaField("metadata").granularity(Granularity.HOURS); + Document result = out("timeseries-col").timeSeries(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.granularity", "hours"); + } + + @Test // GH-4985 + void shouldRenderTimeSeriesCollectionWithPartialOptions() { + + Document result = out("timeseries-col").timeSeries("timestamp", "metadata", null) + .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).doesNotContainKey("$out.timeseries.granularity"); + } + + @Test // GH-4985 + void outWithTimeSeriesOptionsShouldRenderCorrectly() { + + TimeSeriesOptions options = TimeSeriesOptions.timeSeries("timestamp").metaField("metadata").granularity(Granularity.SECONDS); + 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.granularity", "seconds"); + } + + @Test // GH-4985 + void outWithTimeFieldOnlyShouldRenderCorrectly() { + + Document result = Aggregation.out("timeseries-col", "timestamp").toDocument(Aggregation.DEFAULT_CONTEXT); + + assertThat(result).containsEntry("$out.coll", "timeseries-col"); + assertThat(result).containsEntry("$out.timeseries.timeField", "timestamp"); + assertThat(result).doesNotContainKey("$out.timeseries.metaField"); + assertThat(result).doesNotContainKey("$out.timeseries.granularity"); + } + + @Test // GH-4985 + void outWithAllOptionsShouldRenderCorrectly() { + + Document result = Aggregation.out("timeseries-col", "timestamp", "metadata", Granularity.MINUTES) + .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.granularity", "minutes"); + } + } From 7470e6112c6e2c4fa3df6aab47e552485dd460f8 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 3 Jul 2025 11:38:00 +0200 Subject: [PATCH 4/4] Allow setting bucket maxSpan & rounding for time series collection. --- .../data/mongodb/core/CollectionOptions.java | 54 ++++++++++++++++--- .../data/mongodb/core/EntityOperations.java | 9 +++- .../core/aggregation/OutOperation.java | 15 ++++-- .../data/mongodb/core/timeseries/Span.java | 30 +++++++++++ .../core/CollectionOptionsUnitTests.java | 12 +++++ .../aggregation/OutOperationUnitTest.java | 16 ++++++ 6 files changed, 125 insertions(+), 11 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 d7e47eac04..3ccf6fe4e8 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.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.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 static class TimeSeriesOptions { 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 @@ private TimeSeriesOptions(String timeField, @Nullable String metaField, Granular * @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)); } /** @@ -1018,7 +1026,7 @@ public static TimeSeriesOptions timeSeries(String timeField) { */ @Contract("_ -> new") public TimeSeriesOptions metaField(String metaField) { - return new TimeSeriesOptions(timeField, metaField, granularity, expireAfter); + return new TimeSeriesOptions(timeField, metaField, granularity, span, expireAfter); } /** @@ -1030,7 +1038,20 @@ public TimeSeriesOptions metaField(String metaField) { */ @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)}. + * + * @return new instance of {@link TimeSeriesOptions}. + * @see Span + * @since 5.0 + */ + @Contract("_ -> new") + public TimeSeriesOptions span(Span span) { + return new TimeSeriesOptions(timeField, metaField, granularity, span, expireAfter); } /** @@ -1043,7 +1064,7 @@ public TimeSeriesOptions granularity(GranularityDefinition granularity) { */ @Contract("_ -> new") public TimeSeriesOptions expireAfter(Duration ttl) { - return new TimeSeriesOptions(timeField, metaField, granularity, ttl); + return new TimeSeriesOptions(timeField, metaField, granularity, span, ttl); } /** @@ -1079,11 +1100,21 @@ public Duration getExpireAfter() { 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 +1134,13 @@ public boolean equals(@Nullable Object o) { 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 +1149,8 @@ public int hashCode() { 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 1327656356..eab2dc59f9 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 @@ public CreateCollectionOptions convertToCreateCollectionOptions(@Nullable Collec 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 @@ public TimeSeriesOptions mapTimeSeriesOptions(TimeSeriesOptions source) { 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 c9c1a3cd8d..7867b3c0a0 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 @@ 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 OutOperation(String outCollectionName) { /** * @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"); @@ -135,6 +136,7 @@ public Document toDocument(AggregationOperationContext context) { } if (timeSeriesOptions != null) { + Document timeSeriesDoc = new Document("timeField", timeSeriesOptions.getTimeField()); if (StringUtils.hasText(timeSeriesOptions.getMetaField())) { @@ -145,6 +147,13 @@ public Document toDocument(AggregationOperationContext context) { 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 0000000000..597aea54ff --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/timeseries/Span.java @@ -0,0 +1,30 @@ +/* + * 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(); +} 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 fd8c9fb972..1586e272e1 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.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,7 @@ 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.validation.Validator; /** @@ -79,6 +83,14 @@ void timeSeriesEquals() { .isNotEqualTo(empty().timeSeries(TimeSeriesOptions.timeSeries("other"))); } + @Test // GH-4985 + void timeSeriesValidatesGranularityAndSpanSettings() { + + assertThatNoException().isThrownBy(() -> empty().timeSeries(TimeSeriesOptions.timeSeries("tf").span(() -> Duration.ofSeconds(1)).granularity(Granularity.DEFAULT))); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> TimeSeriesOptions.timeSeries("tf").granularity(Granularity.HOURS).span(() -> Duration.ofSeconds(1))); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> TimeSeriesOptions.timeSeries("tf").span(() -> 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 55f168aef8..2433424d03 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,6 +18,8 @@ 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; @@ -123,6 +125,20 @@ void outWithTimeSeriesOptionsShouldRenderCorrectly() { assertThat(result).containsEntry("$out.timeseries.granularity", "seconds"); } + @Test // GH-4985 + void outWithTimeSeriesOptionsUsingSpanShouldRenderCorrectly() { + + TimeSeriesOptions options = TimeSeriesOptions.timeSeries("timestamp").metaField("metadata").span(() -> 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() {