toDocuments(AggregationOperationContext context) {
- return AggregationOperationRenderer.toDocument(operations, context);
+ return AggregationOperationRenderer.toDocument(stages, context);
}
}
}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/LookupOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/LookupOperation.java
index 44d0f1569e..2bd20f509c 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/LookupOperation.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/LookupOperation.java
@@ -230,7 +230,7 @@ public interface PipelineBuilder extends LetBuilder {
AsBuilder pipeline(AggregationPipeline pipeline);
/**
- * Specifies the {@link AggregationPipeline#getOperations() stages} that determine the resulting documents.
+ * Specifies the {@link AggregationPipeline#getStages() stages} that determine the resulting documents.
*
* @param stages must not be {@literal null} can be empty.
* @return never {@literal null}.
@@ -239,6 +239,17 @@ default AsBuilder pipeline(AggregationOperation... stages) {
return pipeline(AggregationPipeline.of(stages));
}
+ /**
+ * Specifies the {@link AggregationPipeline#getStages() stages} that determine the resulting documents.
+ *
+ * @param stages must not be {@literal null} can be empty.
+ * @return never {@literal null}.
+ * @since 4.1
+ */
+ default AsBuilder pipeline(AggregationStage... stages) {
+ return pipeline(AggregationPipeline.of(stages));
+ }
+
/**
* @param name the name of the new array field to add to the input documents, must not be {@literal null} or empty.
* @return new instance of {@link LookupOperation}.
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/MultiOperationAggregationStage.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/MultiOperationAggregationStage.java
new file mode 100644
index 0000000000..e594b2b3b0
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/MultiOperationAggregationStage.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2023 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.aggregation;
+
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.stream.Collectors;
+
+import org.bson.Document;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+/**
+ * An {@link AggregationStage} that may consist of a main operation and potential follow up stages for eg. {@code $sort}
+ * or {@code $limit}.
+ *
+ * The {@link MultiOperationAggregationStage} may operate upon domain specific types but will render to the store native
+ * representation within a given {@link AggregationOperationContext context}.
+ *
+ * {@link #toDocument(AggregationOperationContext)} will render a synthetic {@link Document} that contains the ordered
+ * stages. The list returned from {@link #toPipelineStages(AggregationOperationContext)}
+ *
+ *
+ * [
+ * { $match: { $text: { $search: "operating" } } },
+ * { $sort: { score: { $meta: "textScore" }, posts: -1 } }
+ * ]
+ *
+ *
+ * will be represented as
+ *
+ *
+ * {
+ * $match: { $text: { $search: "operating" } },
+ * $sort: { score: { $meta: "textScore" }, posts: -1 }
+ * }
+ *
+ *
+ * In case stages appear multiple times the order no longer can be guaranteed when calling
+ * {@link #toDocument(AggregationOperationContext)}, so consumers of the API should rely on
+ * {@link #toPipelineStages(AggregationOperationContext)}. Nevertheless, by default the values will be collected into a
+ * list rendering to
+ *
+ *
+ * {
+ * $match: [{ $text: { $search: "operating" } }, { $text: ... }],
+ * $sort: { score: { $meta: "textScore" }, posts: -1 }
+ * }
+ *
+ *
+ * @author Christoph Strobl
+ * @since 4.1
+ */
+public interface MultiOperationAggregationStage extends AggregationStage {
+
+ /**
+ * Returns a synthetic {@link Document stage} that contains the {@link #toPipelineStages(AggregationOperationContext)
+ * actual stages} by folding them into a single {@link Document}. In case of colliding entries, those used multiple
+ * times thus having the same key, the entries will be held as a list for the given operator.
+ *
+ * @param context the {@link AggregationOperationContext} to operate within. Must not be {@literal null}.
+ * @return never {@literal null}.
+ */
+ @Override
+ default Document toDocument(AggregationOperationContext context) {
+
+ List documents = toPipelineStages(context);
+ if (documents.size() == 1) {
+ return documents.get(0);
+ }
+
+ MultiValueMap stages = new LinkedMultiValueMap<>(documents.size());
+ for (Document current : documents) {
+ String key = current.keySet().iterator().next();
+ stages.add(key, current.get(key, Document.class));
+ }
+ return new Document(stages.entrySet().stream()
+ .collect(Collectors.toMap(Entry::getKey, v -> v.getValue().size() == 1 ? v.getValue().get(0) : v.getValue())));
+ }
+
+ /**
+ * Turns the {@link MultiOperationAggregationStage} into list of {@link Document stages} by using the given
+ * {@link AggregationOperationContext}. This allows an {@link AggregationStage} to add follow up stages for eg.
+ * {@code $sort} or {@code $limit}.
+ *
+ * @param context the {@link AggregationOperationContext} to operate within. Must not be {@literal null}.
+ * @return the pipeline stages to run through. Never {@literal null}.
+ */
+ List toPipelineStages(AggregationOperationContext context);
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/TypedAggregation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/TypedAggregation.java
index a600d3f69f..f73e1119da 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/TypedAggregation.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/TypedAggregation.java
@@ -15,6 +15,7 @@
*/
package org.springframework.data.mongodb.core.aggregation;
+import java.util.Arrays;
import java.util.List;
import org.springframework.util.Assert;
@@ -24,6 +25,7 @@
*
* @author Thomas Darimont
* @author Oliver Gierke
+ * @author Christoph Strobl
*/
public class TypedAggregation extends Aggregation {
@@ -39,13 +41,24 @@ public TypedAggregation(Class inputType, AggregationOperation... operations)
this(inputType, asAggregationList(operations));
}
+ /**
+ * Creates a new {@link TypedAggregation} from the given {@link AggregationStage stages}.
+ *
+ * @param inputType must not be {@literal null}.
+ * @param stages must not be {@literal null} or empty.
+ * @since 4.1
+ */
+ public TypedAggregation(Class inputType, AggregationStage... stages) {
+ this(inputType, Arrays.asList(stages));
+ }
+
/**
* Creates a new {@link TypedAggregation} from the given {@link AggregationOperation}s.
*
* @param inputType must not be {@literal null}.
* @param operations must not be {@literal null} or empty.
*/
- public TypedAggregation(Class inputType, List operations) {
+ public TypedAggregation(Class inputType, List extends AggregationStage> operations) {
this(inputType, operations, DEFAULT_OPTIONS);
}
@@ -57,7 +70,7 @@ public TypedAggregation(Class inputType, List operation
* @param operations must not be {@literal null} or empty.
* @param options must not be {@literal null}.
*/
- public TypedAggregation(Class inputType, List operations, AggregationOptions options) {
+ public TypedAggregation(Class inputType, List extends AggregationStage> operations, AggregationOptions options) {
super(operations, options);
@@ -77,6 +90,6 @@ public Class getInputType() {
public TypedAggregation withOptions(AggregationOptions options) {
Assert.notNull(options, "AggregationOptions must not be null");
- return new TypedAggregation(inputType, pipeline.getOperations(), options);
+ return new TypedAggregation<>(inputType, pipeline.getStages(), options);
}
}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AggregationUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AggregationUtils.java
index 0ee488c336..2d24bd597b 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AggregationUtils.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AggregationUtils.java
@@ -27,6 +27,7 @@
import org.springframework.data.mongodb.core.aggregation.Aggregation;
import org.springframework.data.mongodb.core.aggregation.AggregationOperation;
import org.springframework.data.mongodb.core.aggregation.AggregationOptions;
+import org.springframework.data.mongodb.core.aggregation.AggregationStage;
import org.springframework.data.mongodb.core.convert.MongoConverter;
import org.springframework.data.mongodb.core.query.Collation;
import org.springframework.data.mongodb.core.query.Meta;
@@ -109,14 +110,14 @@ static AggregationOptions.Builder applyMeta(AggregationOptions.Builder builder,
* @param accessor
* @param targetType
*/
- static void appendSortIfPresent(List aggregationPipeline, ConvertingParameterAccessor accessor,
+ static void appendSortIfPresent(List extends AggregationStage> aggregationPipeline, ConvertingParameterAccessor accessor,
Class> targetType) {
if (accessor.getSort().isUnsorted()) {
return;
}
- aggregationPipeline.add(ctx -> {
+ ((List) aggregationPipeline).add(ctx -> {
Document sort = new Document();
for (Order order : accessor.getSort()) {
@@ -134,7 +135,7 @@ static void appendSortIfPresent(List aggregationPipeline,
* @param aggregationPipeline
* @param accessor
*/
- static void appendLimitAndOffsetIfPresent(List aggregationPipeline,
+ static void appendLimitAndOffsetIfPresent(List extends AggregationStage> aggregationPipeline,
ConvertingParameterAccessor accessor) {
appendLimitAndOffsetIfPresent(aggregationPipeline, accessor, LongUnaryOperator.identity(),
IntUnaryOperator.identity());
@@ -150,7 +151,7 @@ static void appendLimitAndOffsetIfPresent(List aggregation
* @param limitOperator
* @since 3.3
*/
- static void appendLimitAndOffsetIfPresent(List aggregationPipeline,
+ static void appendLimitAndOffsetIfPresent(List extends AggregationStage> aggregationPipeline,
ConvertingParameterAccessor accessor, LongUnaryOperator offsetOperator, IntUnaryOperator limitOperator) {
Pageable pageable = accessor.getPageable();
@@ -159,10 +160,10 @@ static void appendLimitAndOffsetIfPresent(List aggregation
}
if (pageable.getOffset() > 0) {
- aggregationPipeline.add(Aggregation.skip(offsetOperator.applyAsLong(pageable.getOffset())));
+ ((List) aggregationPipeline).add(Aggregation.skip(offsetOperator.applyAsLong(pageable.getOffset())));
}
- aggregationPipeline.add(Aggregation.limit(limitOperator.applyAsInt(pageable.getPageSize())));
+ ((List) aggregationPipeline).add(Aggregation.limit(limitOperator.applyAsInt(pageable.getPageSize())));
}
/**
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationUnitTests.java
new file mode 100644
index 0000000000..27246fbbcc
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationUnitTests.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2023 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.aggregation;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.List;
+
+import org.bson.Document;
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author Christoph Strobl
+ */
+class AggregationOperationUnitTests {
+
+ @Test // GH-4306
+ void getOperatorShouldFavorToPipelineStages() {
+
+ AggregationOperation op = new AggregationOperation() {
+
+ @Override
+ public Document toDocument(AggregationOperationContext context) {
+ return new Document();
+ }
+
+ @Override
+ public List toPipelineStages(AggregationOperationContext context) {
+ return List.of(new Document("$spring", "data"));
+ }
+ };
+
+ assertThat(op.getOperator()).isEqualTo("$spring");
+ }
+
+}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationPipelineUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationPipelineUnitTests.java
new file mode 100644
index 0000000000..df8775f6b2
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationPipelineUnitTests.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2023 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.aggregation;
+
+import java.util.List;
+
+import org.bson.Document;
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author Christoph Strobl
+ */
+class AggregationPipelineUnitTests {
+
+ @Test // Gh-4306
+ void verifyMustNotFailIfOnlyPipelineStagesUsed() {
+ AggregationPipeline.of(new OverridesPipelineStagesAndThrowsErrorOnToDocument()).verify();
+ }
+
+ static class OverridesPipelineStagesAndThrowsErrorOnToDocument implements AggregationOperation {
+
+ @Override
+ public Document toDocument(AggregationOperationContext context) {
+ throw new IllegalStateException("oh no");
+ }
+
+ @Override
+ public List toPipelineStages(AggregationOperationContext context) {
+ return List.of(Aggregation.project("data").toDocument(context));
+ }
+ }
+
+}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/MultiOperationAggregationStageUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/MultiOperationAggregationStageUnitTests.java
new file mode 100644
index 0000000000..dee36bc146
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/MultiOperationAggregationStageUnitTests.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2023 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.aggregation;
+
+import static org.springframework.data.mongodb.test.util.Assertions.*;
+
+import java.util.List;
+
+import org.bson.Document;
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author Christoph Strobl
+ */
+class MultiOperationAggregationStageUnitTests {
+
+ @Test // GH-4306
+ void toDocumentRendersSingleOperation() {
+
+ MultiOperationAggregationStage stage = (ctx) -> List.of(Document.parse("{ $text: { $search: 'operating' } }"));
+
+ assertThat(stage.toDocument(Aggregation.DEFAULT_CONTEXT)).isEqualTo("{ $text: { $search: 'operating' } }");
+ }
+
+ @Test // GH-4306
+ void toDocumentRendersMultiOperation() {
+
+ MultiOperationAggregationStage stage = (ctx) -> List.of(Document.parse("{ $text: { $search: 'operating' } }"),
+ Document.parse("{ $sort: { score: { $meta: 'textScore' } } }"));
+
+ assertThat(stage.toDocument(Aggregation.DEFAULT_CONTEXT)).isEqualTo("""
+ {
+ $text: { $search: 'operating' },
+ $sort: { score: { $meta: 'textScore' } }
+ }
+ """);
+ }
+
+ @Test // GH-4306
+ void toDocumentCollectsDuplicateOperation() {
+
+ MultiOperationAggregationStage stage = (ctx) -> List.of(Document.parse("{ $text: { $search: 'operating' } }"),
+ Document.parse("{ $sort: { score: { $meta: 'textScore' } } }"), Document.parse("{ $sort: { posts: -1 } }"));
+
+ assertThat(stage.toDocument(Aggregation.DEFAULT_CONTEXT)).isEqualTo("""
+ {
+ $text: { $search: 'operating' },
+ $sort: [
+ { score: { $meta: 'textScore' } },
+ { posts: -1 }
+ ]
+ }
+ """);
+ }
+}