diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java index f2c690afe..f1bac2f05 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java @@ -1413,17 +1413,32 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware { CommandResult commandResult = executeCommand(command); handleCommandError(commandResult, command); - // map results + return new AggregationResults(returnPotentiallyMappedResults(outputType, commandResult), commandResult); + } + + /** + * Returns the potentially mapped results of the given {@commandResult} contained some. + * + * @param outputType + * @param commandResult + * @return + */ + private List returnPotentiallyMappedResults(Class outputType, CommandResult commandResult) { + @SuppressWarnings("unchecked") Iterable resultSet = (Iterable) commandResult.get("result"); - List mappedResults = new ArrayList(); + if (resultSet == null) { + return Collections.emptyList(); + } + DbObjectCallback callback = new UnwrapAndReadDbObjectCallback(mongoConverter, outputType); + List mappedResults = new ArrayList(); for (DBObject dbObject : resultSet) { mappedResults.add(callback.doWith(dbObject)); } - return new AggregationResults(mappedResults, commandResult); + return mappedResults; } protected String replaceWithResourceIfNecessary(String function) { 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 dd81685fc..0341d7398 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 @@ -45,8 +45,11 @@ import com.mongodb.DBObject; public class Aggregation { public static final AggregationOperationContext DEFAULT_CONTEXT = new NoOpAggregationOperationContext(); + public static final AggregationOptions DEFAULT_OPTIONS = newAggregationOptions().build(); - private final List operations; + protected final List operations; + + private final AggregationOptions options; /** * Creates a new {@link Aggregation} from the given {@link AggregationOperation}s. @@ -66,6 +69,20 @@ public class Aggregation { return new Aggregation(operations); } + /** + * Returns a copy of this {@link Aggregation} with the given {@link AggregationOptions} set. Note that options are + * supported in MongoDB version 2.6+. + * + * @param options must not be {@literal null}. + * @return + * @since 1.6 + */ + public Aggregation withOptions(AggregationOptions options) { + + Assert.notNull(options, "AggregationOptions must not be null."); + return new Aggregation(this.operations, options); + } + /** * Creates a new {@link TypedAggregation} for the given type and {@link AggregationOperation}s. * @@ -92,11 +109,43 @@ public class Aggregation { * @param aggregationOperations must not be {@literal null} or empty. */ protected Aggregation(AggregationOperation... aggregationOperations) { + this(asAggregationList(aggregationOperations)); + } + + /** + * @param aggregationOperations must not be {@literal null} or empty. + * @return + */ + protected static List asAggregationList(AggregationOperation... aggregationOperations) { + + Assert.notEmpty(aggregationOperations, "AggregationOperations must not be null or empty!"); + + return Arrays.asList(aggregationOperations); + } + + /** + * Creates a new {@link Aggregation} from the given {@link AggregationOperation}s. + * + * @param aggregationOperations must not be {@literal null} or empty. + */ + protected Aggregation(List aggregationOperations) { + this(aggregationOperations, DEFAULT_OPTIONS); + } + + /** + * Creates a new {@link Aggregation} from the given {@link AggregationOperation}s. + * + * @param aggregationOperations must not be {@literal null} or empty. + * @param options must not be {@literal null} or empty. + */ + protected Aggregation(List aggregationOperations, AggregationOptions options) { Assert.notNull(aggregationOperations, "AggregationOperations must not be null!"); - Assert.isTrue(aggregationOperations.length > 0, "At least one AggregationOperation has to be provided"); + Assert.isTrue(aggregationOperations.size() > 0, "At least one AggregationOperation has to be provided"); + Assert.notNull(options, "AggregationOptions must not be null!"); - this.operations = Arrays.asList(aggregationOperations); + this.operations = aggregationOperations; + this.options = options; } /** @@ -231,6 +280,16 @@ public class Aggregation { return Fields.from(field(name, target)); } + /** + * Returns a new {@link AggregationOptions.Builder}. + * + * @return + * @since 1.6 + */ + public static AggregationOptions.Builder newAggregationOptions() { + return new AggregationOptions.Builder(); + } + /** * Converts this {@link Aggregation} specification to a {@link DBObject}. * @@ -255,6 +314,8 @@ public class Aggregation { DBObject command = new BasicDBObject("aggregate", inputCollectionName); command.put("pipeline", operationDocuments); + command = options.applyAndReturnPotentiallyChangedCommand(command); + return command; } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOptions.java new file mode 100644 index 000000000..c7c17b835 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOptions.java @@ -0,0 +1,189 @@ +/* + * Copyright 2014 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 + * + * http://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 com.mongodb.BasicDBObject; +import com.mongodb.DBObject; + +/** + * Holds a set of configurable aggregation options that can be used within an aggregation pipeline. A list of support + * aggregation options can be found in the MongoDB reference documentation + * http://docs.mongodb.org/manual/reference/command/aggregate/#aggregate + * + * @author Thomas Darimont + * @author Oliver Gierke + * @see Aggregation#withOptions(AggregationOptions) + * @see TypedAggregation#withOptions(AggregationOptions) + * @since 1.6 + */ +public class AggregationOptions { + + private static final String CURSOR = "cursor"; + private static final String EXPLAIN = "explain"; + private static final String ALLOW_DISK_USE = "allowDiskUse"; + + private final boolean allowDiskUse; + private final boolean explain; + private final DBObject cursor; + + /** + * Creates a new {@link AggregationOptions}. + * + * @param allowDiskUse whether to off-load intensive sort-operations to disk. + * @param explain whether to get the execution plan for the aggregation instead of the actual results. + * @param cursor can be {@literal null}, used to pass additional options to the aggregation. + */ + public AggregationOptions(boolean allowDiskUse, boolean explain, DBObject cursor) { + + this.allowDiskUse = allowDiskUse; + this.explain = explain; + this.cursor = cursor; + } + + /** + * Enables writing to temporary files. When set to true, aggregation stages can write data to the _tmp subdirectory in + * the dbPath directory. + * + * @return + */ + public boolean isAllowDiskUse() { + return allowDiskUse; + } + + /** + * Specifies to return the information on the processing of the pipeline. + * + * @return + */ + public boolean isExplain() { + return explain; + } + + /** + * Specify a document that contains options that control the creation of the cursor object. + * + * @return + */ + public DBObject getCursor() { + return cursor; + } + + /** + * Returns a new potentially adjusted copy for the given {@code aggregationCommandObject} with the configuration + * applied. + * + * @param command the aggregation command. + * @return + */ + DBObject applyAndReturnPotentiallyChangedCommand(DBObject command) { + + DBObject result = new BasicDBObject(command.toMap()); + + if (allowDiskUse && !result.containsField(ALLOW_DISK_USE)) { + result.put(ALLOW_DISK_USE, allowDiskUse); + } + + if (explain && !result.containsField(EXPLAIN)) { + result.put(EXPLAIN, explain); + } + + if (cursor != null && !result.containsField(CURSOR)) { + result.put("cursor", cursor); + } + + return result; + } + + /** + * Returns a {@link DBObject} representation of this {@link AggregationOptions}. + * + * @return + */ + public DBObject toDbObject() { + + DBObject dbo = new BasicDBObject(); + dbo.put(ALLOW_DISK_USE, allowDiskUse); + dbo.put(EXPLAIN, explain); + dbo.put(CURSOR, cursor); + + return dbo; + } + + /* (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return toDbObject().toString(); + } + + /** + * A Builder for {@link AggregationOptions}. + * + * @author Thomas Darimont + */ + public static class Builder { + + private boolean allowDiskUse; + private boolean explain; + private DBObject cursor; + + /** + * Defines whether to off-load intensive sort-operations to disk. + * + * @param allowDiskUse + * @return + */ + public Builder allowDiskUse(boolean allowDiskUse) { + + this.allowDiskUse = allowDiskUse; + return this; + } + + /** + * Defines whether to get the execution plan for the aggregation instead of the actual results. + * + * @param explain + * @return + */ + public Builder explain(boolean explain) { + + this.explain = explain; + return this; + } + + /** + * Additional options to the aggregation. + * + * @param cursor + * @return + */ + public Builder cursor(DBObject cursor) { + + this.cursor = cursor; + return this; + } + + /** + * Returns a new {@link AggregationOptions} instance with the given configuration. + * + * @return + */ + public AggregationOptions build() { + return new AggregationOptions(allowDiskUse, explain, cursor); + } + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationResults.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationResults.java index 8c0fac644..2fbf96c4c 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationResults.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationResults.java @@ -1,5 +1,5 @@ /* - * Copyright 2013 the original author or authors. + * Copyright 2013-2014 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. @@ -28,6 +28,7 @@ import com.mongodb.DBObject; * * @author Tobias Trelle * @author Oliver Gierke + * @author Thomas Darimont * @param The class in which the results are mapped onto. * @since 1.3 */ @@ -90,6 +91,16 @@ public class AggregationResults implements Iterable { return serverUsed; } + /** + * Returns the raw result that was returned by the server. + * + * @return + * @since 1.6 + */ + public DBObject getRawResults() { + return rawResults; + } + private String parseServerUsed() { Object object = rawResults.get("serverUsed"); 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 9a13dc2a6..0841de225 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 @@ -1,5 +1,5 @@ /* - * Copyright 2013 the original author or authors. + * Copyright 2013-2014 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. @@ -15,6 +15,8 @@ */ package org.springframework.data.mongodb.core.aggregation; +import java.util.List; + import org.springframework.util.Assert; /** @@ -30,11 +32,34 @@ public class TypedAggregation extends Aggregation { /** * 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, AggregationOperation... operations) { + this(inputType, asAggregationList(operations)); + } - super(operations); + /** + * 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) { + this(inputType, operations, DEFAULT_OPTIONS); + } + + /** + * Creates a new {@link TypedAggregation} from the given {@link AggregationOperation}s and the given + * {@link AggregationOptions}. + * + * @param inputType must not be {@literal null}. + * @param operations must not be {@literal null} or empty. + * @param options must not be {@literal null}. + */ + public TypedAggregation(Class inputType, List operations, AggregationOptions options) { + + super(operations, options); Assert.notNull(inputType, "Input type must not be null!"); this.inputType = inputType; @@ -48,4 +73,14 @@ public class TypedAggregation extends Aggregation { public Class getInputType() { return inputType; } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.Aggregation#withOptions(org.springframework.data.mongodb.core.aggregation.AggregationOptions) + */ + public TypedAggregation withOptions(AggregationOptions options) { + + Assert.notNull(options, "AggregationOptions must not be null."); + return new TypedAggregation(inputType, operations, options); + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationOptionsTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationOptionsTests.java new file mode 100644 index 000000000..ea3f556d4 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationOptionsTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2014 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 + * + * http://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.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; +import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; + +import org.junit.Before; +import org.junit.Test; + +import com.mongodb.BasicDBObject; +import com.mongodb.DBObject; + +/** + * Unit tests for {@link AggregationOptions}. + * + * @author Thomas Darimont + * @since 1.6 + */ +public class AggregationOptionsTests { + + AggregationOptions aggregationOptions; + + @Before + public void setup() { + aggregationOptions = newAggregationOptions().explain(true).cursor(new BasicDBObject("foo", 1)).allowDiskUse(true) + .build(); + + } + + /** + * @see DATAMONGO-960 + */ + @Test + public void aggregationOptionsBuilderShouldSetOptionsAccordingly() { + + assertThat(aggregationOptions.isAllowDiskUse(), is(true)); + assertThat(aggregationOptions.isExplain(), is(true)); + assertThat(aggregationOptions.getCursor(), is((DBObject) new BasicDBObject("foo", 1))); + } + + /** + * @see DATAMONGO-960 + */ + @Test + public void aggregationOptionsToString() { + assertThat(aggregationOptions.toString(), + is("{ \"allowDiskUse\" : true , \"explain\" : true , \"cursor\" : { \"foo\" : 1}}")); + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationTests.java index 25cbd3b89..018c0c5fc 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationTests.java @@ -75,6 +75,7 @@ public class AggregationTests { private static final String INPUT_COLLECTION = "aggregation_test_collection"; private static final Logger LOGGER = LoggerFactory.getLogger(AggregationTests.class); private static final Version TWO_DOT_FOUR = new Version(2, 4); + private static final Version TWO_DOT_SIX = new Version(2, 6); private static boolean initialized = false; @@ -414,23 +415,7 @@ public class AggregationTests { createUserWithLikesDocuments(); - /* - ... - $group: { - _id:"$like", - number:{ $sum:1} - } - ... - - */ - - TypedAggregation agg = newAggregation(UserWithLikes.class, // - unwind("likes"), // - group("likes").count().as("number"), // - sort(DESC, "number"), // - limit(5), // - sort(ASC, previousOperation()) // - ); + TypedAggregation agg = createUsersWithCommonLikesAggregation(); assertThat(agg, is(notNullValue())); assertThat(agg.toString(), is(notNullValue())); @@ -447,6 +432,16 @@ public class AggregationTests { assertLikeStats(result.getMappedResults().get(4), "e", 3); } + protected TypedAggregation createUsersWithCommonLikesAggregation() { + return newAggregation(UserWithLikes.class, // + unwind("likes"), // + group("likes").count().as("number"), // + sort(DESC, "number"), // + limit(5), // + sort(ASC, previousOperation()) // + ); + } + @Test public void arithmenticOperatorsInProjectionExample() { @@ -857,6 +852,57 @@ public class AggregationTests { assertThat(result.getMappedResults(), hasSize(3)); } + /** + * @see DATAMONGO-960 + */ + @Test + public void returnFiveMostCommonLikesAggregationFrameworkExampleWithSortOnDiskOptionEnabled() { + + assumeTrue(mongoVersion.isGreaterThanOrEqualTo(TWO_DOT_SIX)); + + createUserWithLikesDocuments(); + + TypedAggregation agg = createUsersWithCommonLikesAggregation() // + .withOptions(newAggregationOptions().allowDiskUse(true).build()); + + assertThat(agg, is(notNullValue())); + assertThat(agg.toString(), is(notNullValue())); + + AggregationResults result = mongoTemplate.aggregate(agg, LikeStats.class); + assertThat(result, is(notNullValue())); + assertThat(result.getMappedResults(), is(notNullValue())); + assertThat(result.getMappedResults().size(), is(5)); + + assertLikeStats(result.getMappedResults().get(0), "a", 4); + assertLikeStats(result.getMappedResults().get(1), "b", 2); + assertLikeStats(result.getMappedResults().get(2), "c", 4); + assertLikeStats(result.getMappedResults().get(3), "d", 2); + assertLikeStats(result.getMappedResults().get(4), "e", 3); + } + + /** + * @see DATAMONGO-960 + */ + @Test + public void returnFiveMostCommonLikesShouldReturnStageExecutionInformationWithExplainOptionEnabled() { + + assumeTrue(mongoVersion.isGreaterThanOrEqualTo(TWO_DOT_SIX)); + + createUserWithLikesDocuments(); + + TypedAggregation agg = createUsersWithCommonLikesAggregation() // + .withOptions(newAggregationOptions().explain(true).build()); + + AggregationResults result = mongoTemplate.aggregate(agg, LikeStats.class); + + assertThat(result.getMappedResults(), is(empty())); + + DBObject rawResult = result.getRawResults(); + + assertThat(rawResult, is(notNullValue())); + assertThat(rawResult.containsField("stages"), is(true)); + } + private void assertLikeStats(LikeStats like, String id, long count) { assertThat(like, is(notNullValue())); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationUnitTests.java index 95635bc66..2cb7cf6c4 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationUnitTests.java @@ -219,6 +219,43 @@ public class AggregationUnitTests { assertThat(projection1, is((DBObject) new BasicDBObject("b", "$ba"))); } + /** + * @see DATAMONGO-960 + */ + @Test + public void shouldRenderAggregationWithDefaultOptionsCorrectly() { + + DBObject agg = newAggregation( // + project().and("a").as("aa") // + ).toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + + assertThat(agg.toString(), + is("{ \"aggregate\" : \"foo\" , \"pipeline\" : [ { \"$project\" : { \"aa\" : \"$a\"}}]}")); + } + + /** + * @see DATAMONGO-960 + */ + @Test + public void shouldRenderAggregationWithCustomOptionsCorrectly() { + + AggregationOptions aggregationOptions = newAggregationOptions().explain(true).cursor(new BasicDBObject("foo", 1)) + .allowDiskUse(true).build(); + + DBObject agg = newAggregation( // + project().and("a").as("aa") // + ) // + .withOptions(aggregationOptions) // + .toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + + assertThat(agg.toString(), is("{ \"aggregate\" : \"foo\" , " // + + "\"pipeline\" : [ { \"$project\" : { \"aa\" : \"$a\"}}] , " // + + "\"allowDiskUse\" : true , " // + + "\"explain\" : true , " // + + "\"cursor\" : { \"foo\" : 1}}" // + )); + } + private DBObject extractPipelineElement(DBObject agg, int index, String operation) { List pipeline = (List) agg.get("pipeline"); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/TypeBasedAggregationOperationContextUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/TypeBasedAggregationOperationContextUnitTests.java index 3ec62b847..c0c37bb38 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/TypeBasedAggregationOperationContextUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/TypeBasedAggregationOperationContextUnitTests.java @@ -146,6 +146,29 @@ public class TypeBasedAggregationOperationContextUnitTests { assertThat(age, is((DBObject) new BasicDBObject("v", 10))); } + /** + * @see DATAMONGO-960 + */ + @Test + public void rendersAggregationOptionsInTypedAggregationContextCorrectly() { + + AggregationOperationContext context = getContext(FooPerson.class); + TypedAggregation agg = newAggregation(FooPerson.class, project("name", "age")) // + .withOptions( + newAggregationOptions().allowDiskUse(true).explain(true).cursor(new BasicDBObject("foo", 1)).build()); + + DBObject dbo = agg.toDbObject("person", context); + + DBObject projection = getPipelineElementFromAggregationAt(dbo, 0); + assertThat(projection.containsField("$project"), is(true)); + + assertThat(projection.get("$project"), is((Object) new BasicDBObject("name", 1).append("age", 1))); + + assertThat(dbo.get("allowDiskUse"), is((Object) true)); + assertThat(dbo.get("explain"), is((Object) true)); + assertThat(dbo.get("cursor"), is((Object) new BasicDBObject("foo", 1))); + } + @Document(collection = "person") public static class FooPerson {