DATAMONGO-1552 - Add $bucketAuto aggregation stage.

Original Pull Request: #426
This commit is contained in:
Mark Paluch
2016-12-09 09:55:40 +01:00
committed by Christoph Strobl
parent e992d813fb
commit 2c4377c9a6
6 changed files with 500 additions and 5 deletions

View File

@@ -419,7 +419,7 @@ public class Aggregation {
}
/**
* Creates a new {@link BucketOperation} using given {@literal groupByField}.
* Creates a new {@link BucketOperation} given {@literal groupByField}.
*
* @param groupByField must not be {@literal null} or empty.
* @return
@@ -429,7 +429,7 @@ public class Aggregation {
}
/**
* Creates a new {@link BucketOperation} using given {@link AggregationExpression group-by expression}.
* Creates a new {@link BucketOperation} given {@link AggregationExpression group-by expression}.
*
* @param groupByExpression must not be {@literal null}.
* @return
@@ -438,6 +438,28 @@ public class Aggregation {
return new BucketOperation(groupByExpression);
}
/**
* Creates a new {@link BucketAutoOperation} given {@literal groupByField}.
*
* @param groupByField must not be {@literal null} or empty.
* @param buckets number of buckets, must be a positive integer.
* @return
*/
public static BucketAutoOperation bucketAuto(String groupByField, int buckets) {
return new BucketAutoOperation(field(groupByField), buckets);
}
/**
* Creates a new {@link BucketAutoOperation} given {@link AggregationExpression group-by expression}.
*
* @param groupByExpression must not be {@literal null}.
* @param buckets number of buckets, must be a positive integer.
* @return
*/
public static BucketAutoOperation bucketAuto(AggregationExpression groupByExpression, int buckets) {
return new BucketAutoOperation(groupByExpression, buckets);
}
/**
* Creates a new {@link LookupOperation}.
*

View File

@@ -0,0 +1,273 @@
/*
* Copyright 2016 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 org.springframework.data.mongodb.core.aggregation.BucketAutoOperation.BucketAutoOperationOutputBuilder;
import org.springframework.data.mongodb.core.aggregation.BucketOperationSupport.OutputBuilder;
import org.springframework.util.Assert;
import com.mongodb.BasicDBObject;
import com.mongodb.DBObject;
/**
* Encapsulates the aggregation framework {@code $bucketAuto}-operation.
* <p>
* Bucket stage is typically used with {@link Aggregation} and {@code $facet}. Categorizes incoming documents into a
* specific number of groups, called buckets, based on a specified expression. Bucket boundaries are automatically
* determined in an attempt to evenly distribute the documents into the specified number of buckets.
* <p>
* We recommend to use the static factory method {@link Aggregation#bucketAuto(String, int)} instead of creating instances of
* this class directly.
*
* @see http://docs.mongodb.org/manual/reference/aggregation/bucketAuto/
* @see BucketOperationSupport
* @author Mark Paluch
* @since 1.10
*/
public class BucketAutoOperation extends BucketOperationSupport<BucketAutoOperation, BucketAutoOperationOutputBuilder>
implements FieldsExposingAggregationOperation {
private final int buckets;
private final String granularity;
/**
* Creates a new {@link BucketAutoOperation} given a {@link Field group-by field}.
*
* @param groupByField must not be {@literal null}.
* @param buckets number of buckets, must be a positive integer.
*/
public BucketAutoOperation(Field groupByField, int buckets) {
super(groupByField);
Assert.isTrue(buckets > 0, "Number of buckets must be greater 0!");
this.buckets = buckets;
this.granularity = null;
}
/**
* Creates a new {@link BucketAutoOperation} given a {@link AggregationExpression group-by expression}.
*
* @param groupByExpression must not be {@literal null}.
* @param buckets number of buckets, must be a positive integer.
*/
public BucketAutoOperation(AggregationExpression groupByExpression, int buckets) {
super(groupByExpression);
Assert.isTrue(buckets > 0, "Number of buckets must be greater 0!");
this.buckets = buckets;
this.granularity = null;
}
private BucketAutoOperation(BucketAutoOperation bucketOperation, Outputs outputs) {
super(bucketOperation, outputs);
this.buckets = bucketOperation.buckets;
this.granularity = bucketOperation.granularity;
}
private BucketAutoOperation(BucketAutoOperation bucketOperation, int buckets, String granularity) {
super(bucketOperation);
this.buckets = buckets;
this.granularity = granularity;
}
/* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.BucketOperationSupport#toDBObject(org.springframework.data.mongodb.core.aggregation.AggregationOperationContext)
*/
@Override
public DBObject toDBObject(AggregationOperationContext context) {
DBObject options = new BasicDBObject();
options.put("buckets", buckets);
if (granularity != null) {
options.put("granularity", granularity);
}
options.putAll(super.toDBObject(context));
return new BasicDBObject("$bucketAuto", options);
}
/**
* Configures a number of bucket {@literal buckets} and return a new {@link BucketAutoOperation}.
*
* @param buckets must be a positive number.
* @return
*/
public BucketAutoOperation withBuckets(int buckets) {
Assert.isTrue(buckets > 0, "Number of buckets must be greater 0!");
return new BucketAutoOperation(this, buckets, granularity);
}
/**
* Configures {@literal granularity} that specifies the preferred number series to use to ensure that the calculated
* boundary edges end on preferred round numbers or their powers of 10 and return a new {@link BucketAutoOperation}.
*
* @param granularity must not be {@literal null}.
* @return
*/
public BucketAutoOperation withGranularity(Granularity granularity) {
Assert.notNull(granularity, "Granularity must not be null!");
return new BucketAutoOperation(this, buckets, granularity.toMongoGranularity());
}
/* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.BucketOperationSupport#newBucketOperation(org.springframework.data.mongodb.core.aggregation.BucketOperationSupport.Outputs)
*/
@Override
protected BucketAutoOperation newBucketOperation(Outputs outputs) {
return new BucketAutoOperation(this, outputs);
}
/* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.BucketOperationSupport#andOutputExpression(java.lang.String, java.lang.Object[])
*/
@Override
public ExpressionBucketAutoOperationBuilder andOutputExpression(String expression, Object... params) {
return new ExpressionBucketAutoOperationBuilder(expression, this, params);
}
/* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.BucketOperationSupport#andOutput(org.springframework.data.mongodb.core.aggregation.AggregationExpression)
*/
@Override
public BucketAutoOperationOutputBuilder andOutput(AggregationExpression expression) {
return new BucketAutoOperationOutputBuilder(expression, this);
}
/* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.BucketOperationSupport#andOutput(java.lang.String)
*/
@Override
public BucketAutoOperationOutputBuilder andOutput(String fieldName) {
return new BucketAutoOperationOutputBuilder(Fields.field(fieldName), this);
}
/**
* {@link OutputBuilder} implementation for {@link BucketAutoOperation}.
*/
public static class BucketAutoOperationOutputBuilder
extends OutputBuilder<BucketAutoOperationOutputBuilder, BucketAutoOperation> {
/**
* Creates a new {@link BucketAutoOperationOutputBuilder} fot the given value and {@link BucketAutoOperation}.
*
* @param value must not be {@literal null}.
* @param operation must not be {@literal null}.
*/
protected BucketAutoOperationOutputBuilder(Object value, BucketAutoOperation operation) {
super(value, operation);
}
/* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.BucketOperationSupport.OutputBuilder#apply(org.springframework.data.mongodb.core.aggregation.BucketOperationSupport.OperationOutput)
*/
@Override
protected BucketAutoOperationOutputBuilder apply(OperationOutput operationOutput) {
return new BucketAutoOperationOutputBuilder(operationOutput, this.operation);
}
}
/**
* {@link ExpressionBucketOperationBuilderSupport} implementation for {@link BucketAutoOperation} using SpEL
* expression based {@link Output}.
*
* @author Mark Paluch
*/
public static class ExpressionBucketAutoOperationBuilder
extends ExpressionBucketOperationBuilderSupport<BucketAutoOperationOutputBuilder, BucketAutoOperation> {
/**
* Creates a new {@link ExpressionBucketAutoOperationBuilder} for the given value, {@link BucketAutoOperation} and
* parameters.
*
* @param expression must not be {@literal null}.
* @param operation must not be {@literal null}.
* @param parameters
*/
protected ExpressionBucketAutoOperationBuilder(String expression, BucketAutoOperation operation,
Object[] parameters) {
super(expression, operation, parameters);
}
/* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.BucketOperationSupport.OutputBuilder#apply(org.springframework.data.mongodb.core.aggregation.BucketOperationSupport.OperationOutput)
*/
@Override
protected BucketAutoOperationOutputBuilder apply(OperationOutput operationOutput) {
return new BucketAutoOperationOutputBuilder(operationOutput, this.operation);
}
}
/**
* @author Mark Paluch
*/
public static interface Granularity {
/**
* @return a String that represents a MongoDB granularity to be used with {@link BucketAutoOperation}.
*/
String toMongoGranularity();
}
/**
* Supported MongoDB granularities.
*
* @see https://en.wikipedia.org/wiki/Preferred_number
* @see https://docs.mongodb.com/manual/reference/operator/aggregation/bucketAuto/#granularity
* @author Mark Paluch
*/
public enum Granularities implements Granularity {
R5, R10, R20, R40, R80, //
SERIES_1_2_5("1-2-5"), //
E6, E12, E24, E48, E96, E192, //
POWERSOF2;
final String granularity;
Granularities() {
this.granularity = name();
}
Granularities(String granularity) {
this.granularity = granularity;
}
/* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.GranularitytoMongoGranularity()
*/
@Override
public String toMongoGranularity() {
return granularity;
}
}
}

View File

@@ -222,7 +222,7 @@ public abstract class BucketOperationSupport<T extends BucketOperationSupport<T,
* @param value must not be {@literal null}.
* @param operation must not be {@literal null}.
*/
public OutputBuilder(Object value, T operation) {
protected OutputBuilder(Object value, T operation) {
Assert.notNull(value, "Value must not be null or empty!");
Assert.notNull(operation, "ProjectionOperation must not be null!");
@@ -433,6 +433,11 @@ public abstract class BucketOperationSupport<T extends BucketOperationSupport<T,
*/
protected ExposedFields asExposedFields() {
// The count field is included by default when the output is not specified.
if (isEmpty()) {
return ExposedFields.from(new ExposedField("count", true));
}
ExposedFields fields = ExposedFields.from();
for (Output output : outputs) {

View File

@@ -61,7 +61,9 @@ import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.ConditionalOperators;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.Let;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.Let.ExpressionVariable;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.Multiply;
import org.springframework.data.mongodb.core.aggregation.AggregationTests.CarDescriptor.Entry;
import org.springframework.data.mongodb.core.aggregation.BucketAutoOperation.Granularities;
import org.springframework.data.mongodb.core.index.GeospatialIndex;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.query.Criteria;
@@ -1708,6 +1710,43 @@ public class AggregationTests {
assertThat((Double) bound100.get("sum"), is(closeTo(3672.9, 0.1)));
}
/**
* @see DATAMONGO-1552
*/
@Test
public void bucketAutoShouldCollectDocumentsIntoABucket() {
assumeTrue(mongoVersion.isGreaterThanOrEqualTo(THREE_DOT_FOUR));
Art a1 = Art.builder().id(1).title("The Pillars of Society").artist("Grosz").year(1926).price(199.99).build();
Art a2 = Art.builder().id(2).title("Melancholy III").artist("Munch").year(1902).price(280.00).build();
Art a3 = Art.builder().id(3).title("Dancer").artist("Miro").year(1925).price(76.04).build();
Art a4 = Art.builder().id(4).title("The Great Wave off Kanagawa").artist("Hokusai").price(167.30).build();
mongoTemplate.insert(Arrays.asList(a1, a2, a3, a4), Art.class);
TypedAggregation<Art> aggregation = newAggregation(Art.class, //
bucketAuto(Multiply.valueOf("price").multiplyBy(10), 3) //
.withGranularity(Granularities.E12) //
.andOutputCount().as("count") //
.andOutput("title").push().as("titles") //
.andOutputExpression("price * 10").sum().as("sum"));
AggregationResults<DBObject> result = mongoTemplate.aggregate(aggregation, DBObject.class);
assertThat(result.getMappedResults().size(), is(3));
// { "min" : 680.0 , "max" : 820.0 , "count" : 1 , "titles" : [ "Dancer"] , "sum" : 760.4000000000001}
DBObject bound0 = result.getMappedResults().get(0);
assertThat(bound0, isBsonObject().containing("count", 1).containing("titles.[0]", "Dancer").containing("min", 680.0)
.containing("max"));
// { "min" : 820.0 , "max" : 1800.0 , "count" : 1 , "titles" : [ "The Great Wave off Kanagawa"] , "sum" : 1673.0}
DBObject bound1 = result.getMappedResults().get(1);
assertThat(bound1, isBsonObject().containing("count", 1).containing("min", 820.0));
assertThat((List<String>) bound1.get("titles"), hasItems("The Great Wave off Kanagawa"));
assertThat((Double) bound1.get("sum"), is(closeTo(1673.0, 0.1)));
}
private void createUsersWithReferencedPersons() {
mongoTemplate.dropCollection(User.class);

View File

@@ -0,0 +1,142 @@
/*
* Copyright 2016 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.core.Is.*;
import static org.junit.Assert.*;
import static org.springframework.data.mongodb.core.DBObjectTestUtils.getAsDBObject;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
import org.junit.Test;
import org.springframework.data.mongodb.core.aggregation.BucketAutoOperation.Granularities;
import com.mongodb.DBObject;
import com.mongodb.util.JSON;
/**
* Unit tests for {@link BucketAutoOperation}.
*
* @author Mark Paluch
*/
public class BucketAutoOperationUnitTests {
/**
* @see DATAMONGO-1552
*/
@Test(expected = IllegalArgumentException.class)
public void rejectsNullFields() {
new BucketAutoOperation((Field) null, 0);
}
/**
* @see DATAMONGO-1552
*/
@Test(expected = IllegalArgumentException.class)
public void rejectsNonPositiveIntegerNullFields() {
new BucketAutoOperation(Fields.field("field"), 0);
}
/**
* @see DATAMONGO-1552
*/
@Test
public void shouldRenderBucketOutputExpressions() {
BucketAutoOperation operation = Aggregation.bucketAuto("field", 5) //
.andOutputExpression("(netPrice + surCharge) * taxrate * [0]", 2).as("grossSalesPrice") //
.andOutput("title").push().as("titles");
DBObject dbObject = operation.toDBObject(Aggregation.DEFAULT_CONTEXT);
assertThat(extractOutput(dbObject), is(JSON.parse(
"{ \"grossSalesPrice\" : { \"$multiply\" : [ { \"$add\" : [ \"$netPrice\" , \"$surCharge\"]} , \"$taxrate\" , 2]} , \"titles\" : { $push: \"$title\" } }}")));
}
/**
* @see DATAMONGO-1552
*/
@Test(expected = IllegalStateException.class)
public void shouldRenderEmptyAggregationExpression() {
bucket("groupby").andOutput("field").as("alias");
}
/**
* @see DATAMONGO-1552
*/
@Test
public void shouldRenderBucketOutputOperators() {
BucketAutoOperation operation = Aggregation.bucketAuto("field", 5) //
.andOutputCount().as("titles");
DBObject dbObject = operation.toDBObject(Aggregation.DEFAULT_CONTEXT);
assertThat(extractOutput(dbObject), is(JSON.parse("{ titles : { $sum: 1 } }")));
}
/**
* @see DATAMONGO-1552
*/
@Test
public void shouldRenderCorrectly() {
DBObject agg = bucketAuto("field", 1).withBuckets(5).toDBObject(Aggregation.DEFAULT_CONTEXT);
assertThat(agg, is(JSON.parse("{ $bucketAuto: { groupBy: \"$field\", buckets: 5 } }")));
}
/**
* @see DATAMONGO-1552
*/
@Test
public void shouldRenderGranulariy() {
DBObject agg = bucketAuto("field", 1) //
.withGranularity(Granularities.E24) //
.toDBObject(Aggregation.DEFAULT_CONTEXT);
assertThat(agg, is(JSON.parse("{ $bucketAuto: { buckets: 1, granularity: \"E24\", groupBy: \"$field\" } }")));
}
/**
* @see DATAMONGO-1552
*/
@Test
public void shouldRenderSumOperator() {
BucketAutoOperation operation = bucketAuto("field", 5) //
.andOutput("score").sum().as("cummulated_score");
DBObject dbObject = operation.toDBObject(Aggregation.DEFAULT_CONTEXT);
assertThat(extractOutput(dbObject), is(JSON.parse("{ cummulated_score : { $sum: \"$score\" } }")));
}
/**
* @see DATAMONGO-1552
*/
@Test
public void shouldRenderSumWithOwnOutputExpression() {
BucketAutoOperation operation = bucketAuto("field", 5) //
.andOutputExpression("netPrice + tax").apply("$multiply", 5).as("total");
DBObject dbObject = operation.toDBObject(Aggregation.DEFAULT_CONTEXT);
assertThat(extractOutput(dbObject),
is(JSON.parse("{ total : { $multiply: [ {$add : [\"$netPrice\", \"$tax\"]}, 5] } }")));
}
private static DBObject extractOutput(DBObject fromBucketClause) {
return getAsDBObject(getAsDBObject(fromBucketClause, "$bucketAuto"), "output");
}
}

View File

@@ -15,8 +15,10 @@
*/
package org.springframework.data.mongodb.core.aggregation;
import static org.hamcrest.core.Is.*;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.core.Is.is;
import static org.junit.Assert.*;
import static org.springframework.data.mongodb.core.DBObjectTestUtils.*;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
import org.junit.Test;
@@ -248,7 +250,19 @@ public class BucketOperationUnitTests {
is(JSON.parse("{ total : { $multiply: [ {$add : [\"$netPrice\", \"$tax\"]}, 5] } }")));
}
/**
* @see DATAMONGO-1552
*/
@Test
public void shouldExposeDefaultCountField() {
BucketOperation operation = bucket("field");
assertThat(operation.getFields().exposesSingleFieldOnly(), is(true));
assertThat(operation.getFields().getField("count"), is(notNullValue()));
}
private static DBObject extractOutput(DBObject fromBucketClause) {
return (DBObject) ((DBObject) fromBucketClause.get("$bucket")).get("output");
return getAsDBObject(getAsDBObject(fromBucketClause, "$bucket"), "output");
}
}