DATAMONGO-1552 - Add $facet aggregation stage.

Original Pull Request: #426
This commit is contained in:
Mark Paluch
2016-12-09 15:08:40 +01:00
committed by Christoph Strobl
parent b7a0b1d523
commit 450549150d
6 changed files with 543 additions and 59 deletions

View File

@@ -28,6 +28,7 @@ import org.springframework.data.mongodb.core.aggregation.CountOperation.CountOpe
import org.springframework.data.mongodb.core.aggregation.ExposedFields.DirectFieldReference;
import org.springframework.data.mongodb.core.aggregation.ExposedFields.ExposedField;
import org.springframework.data.mongodb.core.aggregation.ExposedFields.FieldReference;
import org.springframework.data.mongodb.core.aggregation.FacetOperation.FacetOperationBuilder;
import org.springframework.data.mongodb.core.aggregation.FieldsExposingAggregationOperation.InheritsFieldsAggregationOperation;
import org.springframework.data.mongodb.core.aggregation.GraphLookupOperation.StartWithBuilder;
import org.springframework.data.mongodb.core.aggregation.ReplaceRootOperation.ReplaceRootDocumentOperationBuilder;
@@ -66,7 +67,7 @@ public class Aggregation {
*/
public static final String CURRENT = SystemVariable.CURRENT.toString();
public static final AggregationOperationContext DEFAULT_CONTEXT = new NoOpAggregationOperationContext();
public static final AggregationOperationContext DEFAULT_CONTEXT = AggregationOperationRenderer.DEFAULT_CONTEXT;
public static final AggregationOptions DEFAULT_OPTIONS = newAggregationOptions().build();
protected final List<AggregationOperation> operations;
@@ -458,6 +459,25 @@ public class Aggregation {
return new BucketAutoOperation(groupByExpression, buckets);
}
/**
* Creates a new {@link FacetOperation}.
*
* @return
*/
public static FacetOperation facet() {
return FacetOperation.EMPTY;
}
/**
* Creates a new {@link FacetOperationBuilder} given {@link Aggregation}.
*
* @param aggregationOperations the sub-pipeline, must not be {@literal null}.
* @return
*/
public static FacetOperationBuilder facet(AggregationOperation... aggregationOperations) {
return facet().and(aggregationOperations);
}
/**
* Creates a new {@link LookupOperation}.
*
@@ -549,26 +569,7 @@ public class Aggregation {
*/
public Document toDocument(String inputCollectionName, AggregationOperationContext rootContext) {
AggregationOperationContext context = rootContext;
List<Document> operationDocuments = new ArrayList<Document>(operations.size());
for (AggregationOperation operation : operations) {
operationDocuments.add(operation.toDocument(context));
if (operation instanceof FieldsExposingAggregationOperation) {
FieldsExposingAggregationOperation exposedFieldsOperation = (FieldsExposingAggregationOperation) operation;
ExposedFields fields = exposedFieldsOperation.getFields();
if (operation instanceof InheritsFieldsAggregationOperation) {
context = new InheritingExposedFieldsAggregationOperationContext(fields, context);
} else {
context = fields.exposesNoFields() ? DEFAULT_CONTEXT
: new ExposedFieldsAggregationOperationContext(fields, context);
}
}
}
List<Document> operationDocuments = AggregationOperationRenderer.toDocument(operations, rootContext);
Document command = new Document("aggregate", inputCollectionName);
command.put("pipeline", operationDocuments);
@@ -584,43 +585,7 @@ public class Aggregation {
*/
@Override
public String toString() {
return SerializationUtils
.serializeToJsonSafely(toDocument("__collection__", new NoOpAggregationOperationContext()));
}
/**
* Simple {@link AggregationOperationContext} that just returns {@link FieldReference}s as is.
*
* @author Oliver Gierke
*/
private static class NoOpAggregationOperationContext implements AggregationOperationContext {
/*
* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getMappedObject(com.mongodb.Document)
*/
@Override
public Document getMappedObject(Document document) {
return document;
}
/*
* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getReference(org.springframework.data.mongodb.core.aggregation.ExposedFields.AvailableField)
*/
@Override
public FieldReference getReference(Field field) {
return new DirectFieldReference(new ExposedField(field, true));
}
/*
* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getReference(java.lang.String)
*/
@Override
public FieldReference getReference(String name) {
return new DirectFieldReference(new ExposedField(new AggregationField(name), true));
}
return SerializationUtils.serializeToJsonSafely(toDocument("__collection__", DEFAULT_CONTEXT));
}
/**
@@ -660,7 +625,7 @@ public class Aggregation {
return false;
}
/*
/*
* (non-Javadoc)
* @see java.lang.Enum#toString()
*/

View File

@@ -0,0 +1,108 @@
/*
* 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 java.util.ArrayList;
import java.util.List;
import org.bson.Document;
import org.springframework.data.mongodb.core.aggregation.ExposedFields.DirectFieldReference;
import org.springframework.data.mongodb.core.aggregation.ExposedFields.ExposedField;
import org.springframework.data.mongodb.core.aggregation.ExposedFields.FieldReference;
import org.springframework.data.mongodb.core.aggregation.Fields.AggregationField;
import org.springframework.data.mongodb.core.aggregation.FieldsExposingAggregationOperation.InheritsFieldsAggregationOperation;
/**
* Rendering support for {@link AggregationOperation} into a {@link List} of {@link com.mongodb.Document}.
*
* @author Mark Paluch
* @author Christoph Strobl
* @since 1.10
*/
class AggregationOperationRenderer {
static final AggregationOperationContext DEFAULT_CONTEXT = new NoOpAggregationOperationContext();
/**
* Render a {@link List} of {@link AggregationOperation} given {@link AggregationOperationContext} into their
* {@link Document} representation.
*
* @param operations must not be {@literal null}.
* @param context must not be {@literal null}.
* @return the {@link List} of {@link Document}.
*/
static List<Document> toDocument(List<AggregationOperation> operations, AggregationOperationContext rootContext) {
List<Document> operationDocuments = new ArrayList<Document>(operations.size());
AggregationOperationContext contextToUse = rootContext;
for (AggregationOperation operation : operations) {
operationDocuments.add(operation.toDocument(contextToUse));
if (operation instanceof FieldsExposingAggregationOperation) {
FieldsExposingAggregationOperation exposedFieldsOperation = (FieldsExposingAggregationOperation) operation;
ExposedFields fields = exposedFieldsOperation.getFields();
if (operation instanceof InheritsFieldsAggregationOperation) {
contextToUse = new InheritingExposedFieldsAggregationOperationContext(fields, contextToUse);
} else {
contextToUse = fields.exposesNoFields() ? DEFAULT_CONTEXT
: new ExposedFieldsAggregationOperationContext(exposedFieldsOperation.getFields(), contextToUse);
}
}
}
return operationDocuments;
}
/**
* Simple {@link AggregationOperationContext} that just returns {@link FieldReference}s as is.
*
* @author Oliver Gierke
*/
private static class NoOpAggregationOperationContext implements AggregationOperationContext {
/*
* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getMappedObject(org.bson.Document)
*/
@Override
public Document getMappedObject(Document document) {
return document;
}
/*
* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getReference(org.springframework.data.mongodb.core.aggregation.ExposedFields.AvailableField)
*/
@Override
public FieldReference getReference(Field field) {
return new DirectFieldReference(new ExposedField(field, true));
}
/*
* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getReference(java.lang.String)
*/
@Override
public FieldReference getReference(String name) {
return new DirectFieldReference(new ExposedField(new AggregationField(name), true));
}
}
}

View File

@@ -0,0 +1,230 @@
/*
* 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 java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.springframework.data.mongodb.core.aggregation.BucketOperationSupport.Output;
import org.springframework.data.mongodb.core.aggregation.ExposedFields.ExposedField;
import org.springframework.util.Assert;
import org.bson.Document;
/**
* Encapsulates the aggregation framework {@code $facet}-operation.
* <p>
* Facet of {@link AggregationOperation}s to be used in an {@link Aggregation}. Processes multiple
* {@link AggregationOperation} pipelines within a single stage on the same set of input documents. Each sub-pipeline
* has its own field in the output document where its results are stored as an array of documents.
* {@link FacetOperation} enables various aggregations on the same set of input documents, without needing to retrieve
* the input documents multiple times.
* <p>
* As of MongoDB 3.4, {@link FacetOperation} cannot be used with nested pipelines containing {@link GeoNearOperation},
* {@link OutOperation} and {@link FacetOperation}.
* <p>
* We recommend to use the static factory method {@link Aggregation#facet()} instead of creating instances of this class
* directly.
*
* @see http://docs.mongodb.org/manual/reference/aggregation/facet/
* @author Mark Paluch
* @since 1.10
*/
public class FacetOperation implements FieldsExposingAggregationOperation {
/**
* Empty (initial) {@link FacetOperation}.
*/
public static final FacetOperation EMPTY = new FacetOperation();
private final Facets facets;
/**
* Creates a new {@link FacetOperation}.
*/
public FacetOperation() {
this(Facets.EMPTY);
}
private FacetOperation(Facets facets) {
this.facets = facets;
}
/**
* Creates a new {@link FacetOperationBuilder} to append a new facet using {@literal operations}.
* <p>
* {@link FacetOperationBuilder} takes a pipeline of {@link AggregationOperation} to categorize documents into a
* single facet.
*
* @param operations must not be {@literal null} or empty.
* @return
*/
public FacetOperationBuilder and(AggregationOperation... operations) {
Assert.notNull(operations, "AggregationOperations must not be null!");
Assert.notEmpty(operations, "AggregationOperations must not be empty!");
return new FacetOperationBuilder(facets, Arrays.asList(operations));
}
/* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.AggregationOperation#toDocument(org.springframework.data.mongodb.core.aggregation.AggregationOperationContext)
*/
@Override
public Document toDocument(AggregationOperationContext context) {
return new Document("$facet", facets.toDocument(context));
}
/* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.FieldsExposingAggregationOperation#getFields()
*/
@Override
public ExposedFields getFields() {
return facets.asExposedFields();
}
/**
* Builder for {@link FacetOperation} by adding existing and the new pipeline of {@link AggregationOperation} to the
* new {@link FacetOperation}.
*
* @author Mark Paluch
*/
public static class FacetOperationBuilder {
private final Facets current;
private final List<AggregationOperation> operations;
private FacetOperationBuilder(Facets current, List<AggregationOperation> operations) {
this.current = current;
this.operations = operations;
}
/**
* Creates a new {@link FacetOperation} that contains the configured pipeline of {@link AggregationOperation}
* exposed as {@literal fieldName} in the resulting facet document.
*
* @param fieldName must not be {@literal null} or empty.
* @return
*/
public FacetOperation as(String fieldName) {
Assert.hasText(fieldName, "FieldName must not be null or empty!");
return new FacetOperation(current.and(fieldName, operations));
}
}
/**
* Encapsulates multiple {@link Facet}s
*
* @author Mark Paluch
*/
private static class Facets {
private static final Facets EMPTY = new Facets(Collections.<Facet> emptyList());
private List<Facet> facets;
/**
* Creates a new {@link Facets} given {@link List} of {@link Facet}.
*
* @param facets
*/
private Facets(List<Facet> facets) {
this.facets = facets;
}
/**
* @return the {@link ExposedFields} derived from {@link Output}.
*/
protected ExposedFields asExposedFields() {
ExposedFields fields = ExposedFields.from();
for (Facet facet : facets) {
fields = fields.and(facet.getExposedField());
}
return fields;
}
protected Document toDocument(AggregationOperationContext context) {
Document document = new Document();
for (Facet facet : facets) {
document.put(facet.getExposedField().getName(), facet.toDocuments(context));
}
return document;
}
/**
* Adds a facet to this {@link Facets}.
*
* @param fieldName must not be {@literal null}.
* @param operations must not be {@literal null}.
* @return the new {@link Facets}.
*/
public Facets and(String fieldName, List<AggregationOperation> operations) {
Assert.hasText(fieldName, "FieldName must not be null or empty!");
Assert.notNull(operations, "AggregationOperations must not be null!");
List<Facet> facets = new ArrayList<Facet>(this.facets.size() + 1);
facets.addAll(this.facets);
facets.add(new Facet(new ExposedField(fieldName, true), operations));
return new Facets(facets);
}
}
/**
* A single facet with a {@link ExposedField} and its {@link AggregationOperation} pipeline.
*
* @author Mark Paluch
*/
private static class Facet {
private final ExposedField exposedField;
private final List<AggregationOperation> operations;
/**
* Creates a new {@link Facet} given {@link ExposedField} and {@link AggregationOperation} pipeline.
*
* @param exposedField must not be {@literal null}.
* @param operations must not be {@literal null}.
*/
protected Facet(ExposedField exposedField, List<AggregationOperation> operations) {
Assert.notNull(exposedField, "ExposedField must not be null!");
Assert.notNull(operations, "AggregationOperations must not be null!");
this.exposedField = exposedField;
this.operations = operations;
}
protected ExposedField getExposedField() {
return exposedField;
}
protected List<Document> toDocuments(AggregationOperationContext context) {
return AggregationOperationRenderer.toDocument(operations, context);
}
}
}

View File

@@ -1741,6 +1741,54 @@ public class AggregationTests {
assertThat((Double) bound1.get("sum"), is(closeTo(1673.0, 0.1)));
}
/**
* @see DATAMONGO-1552
*/
@Test
public void facetShouldCreateFacets() {
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);
BucketAutoOperation bucketPrice = bucketAuto(Multiply.valueOf("price").multiplyBy(10), 3) //
.withGranularity(Granularities.E12) //
.andOutputCount().as("count") //
.andOutput("title").push().as("titles") //
.andOutputExpression("price * 10") //
.sum().as("sum");
TypedAggregation<Art> aggregation = newAggregation(Art.class, //
project("title", "artist", "year", "price"), //
facet(bucketPrice).as("categorizeByPrice") //
.and(bucketAuto("year", 3)).as("categorizeByYear"));
AggregationResults<Document> result = mongoTemplate.aggregate(aggregation, Document.class);
assertThat(result.getMappedResults().size(), is(1));
Document mappedResult = result.getUniqueMappedResult();
// [ { "_id" : { "min" : 680.0 , "max" : 820.0} , "count" : 1 , "titles" : [ "Dancer"] , "sum" : 760.4000000000001}
// ,
// { "_id" : { "min" : 820.0 , "max" : 1800.0} , "count" : 1 , "titles" : [ "The Great Wave off Kanagawa"] , "sum" :
// 1673.0} ,
// { "_id" : { "min" : 1800.0 , "max" : 3300.0} , "count" : 2 , "titles" : [ "The Pillars of Society" , "Melancholy
// III"] , "sum" : 4799.9}]
List<Object> categorizeByPrice = (List<Object>) mappedResult.get("categorizeByPrice");
assertThat(categorizeByPrice, hasSize(3));
// [ { "_id" : { "min" : null , "max" : 1902} , "count" : 1} ,
// { "_id" : { "min" : 1902 , "max" : 1925} , "count" : 1} ,
// { "_id" : { "min" : 1925 , "max" : 1926} , "count" : 2}]
List<Object> categorizeByYear = (List<Object>) mappedResult.get("categorizeByYear");
assertThat(categorizeByYear, hasSize(3));
}
private void createUsersWithReferencedPersons() {
mongoTemplate.dropCollection(User.class);

View File

@@ -573,6 +573,23 @@ public class AggregationUnitTests {
isBsonObject().containing("$ifNull", Arrays.asList("$chroma", "$fallback")));
}
/**
* @see DATAMONGO-1552
*/
@Test
public void shouldHonorDefaultCountField() {
Document agg = Aggregation
.newAggregation(//
bucket("year"), //
project("count")) //
.toDocument("foo", Aggregation.DEFAULT_CONTEXT);
Document project = extractPipelineElement(agg, 1, "$project");
assertThat(project, isBsonObject().containing("count", 1));
}
private Document extractPipelineElement(Document agg, int index, String operation) {
List<Document> pipeline = (List<Document>) agg.get("pipeline");

View File

@@ -0,0 +1,116 @@
/*
* 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.MatcherAssert.*;
import static org.hamcrest.Matchers.*;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
import org.junit.Test;
import org.springframework.data.mongodb.core.query.Criteria;
import org.bson.Document;
/**
* Unit tests for {@link FacetOperation}.
*
* @author Mark Paluch
* @soundtrack Stanley Foort - You Make Me Believe In Magic (Extended Mix)
*/
public class FacetOperationUnitTests {
/**
* @see DATAMONGO-1552
*/
@Test
public void shouldRenderCorrectly() throws Exception {
FacetOperation facetOperation = new FacetOperation()
.and(match(Criteria.where("price").exists(true)), //
bucket("price") //
.withBoundaries(0, 150, 200, 300, 400) //
.withDefaultBucket("Other") //
.andOutputCount().as("count") //
.andOutput("title").push().as("titles")) //
.as("categorizedByPrice") //
.and(bucketAuto("year", 5)).as("categorizedByYears");
Document agg = facetOperation.toDocument(Aggregation.DEFAULT_CONTEXT);
assertThat(agg,
is(Document.parse("{ $facet: { categorizedByPrice: [" + "{ $match: { price: { $exists: true } } }, "
+ "{ $bucket: { boundaries: [ 0, 150, 200, 300, 400 ], groupBy: \"$price\", default: \"Other\", "
+ "output: { count: { $sum: 1 }, titles: { $push: \"$title\" } } } } ],"
+ "categorizedByYears: [ { $bucketAuto: { buckets: 5, groupBy: \"$year\" } } ] } }")));
}
/**
* @see DATAMONGO-1552
*/
@Test
public void shouldRenderEmpty() throws Exception {
FacetOperation facetOperation = facet();
Document agg = facetOperation.toDocument(Aggregation.DEFAULT_CONTEXT);
assertThat(agg, is(Document.parse("{ $facet: { } }")));
}
/**
* @see DATAMONGO-1552
*/
@Test(expected = IllegalArgumentException.class)
public void shouldRejectNonExistingFields() throws Exception {
FacetOperation facetOperation = new FacetOperation()
.and(project("price"), //
bucket("price") //
.withBoundaries(0, 150, 200, 300, 400) //
.withDefaultBucket("Other") //
.andOutputCount().as("count") //
.andOutput("title").push().as("titles")) //
.as("categorizedByPrice");
Document agg = facetOperation.toDocument(Aggregation.DEFAULT_CONTEXT);
assertThat(agg,
is(Document.parse("{ $facet: { categorizedByPrice: [" + "{ $match: { price: { $exists: true } } }, "
+ "{ $bucket: {boundaries: [ 0, 150, 200, 300, 400 ], groupBy: \"$price\", default: \"Other\", "
+ "output: { count: { $sum: 1 }, titles: { $push: \"$title\" } } } } ],"
+ "categorizedByYears: [ { $bucketAuto: { buckets: 5, groupBy: \"$year\" } } ] } }")));
}
/**
* @see DATAMONGO-1552
*/
@Test
public void shouldHonorProjectedFields() {
FacetOperation facetOperation = new FacetOperation()
.and(project("price").and("title").as("name"), //
bucketAuto("price", 5) //
.andOutput("name").push().as("titles")) //
.as("categorizedByPrice");
Document agg = facetOperation.toDocument(Aggregation.DEFAULT_CONTEXT);
assertThat(agg,
is(Document.parse("{ $facet: { categorizedByPrice: [" + "{ $project: { price: 1, name: \"$title\" } }, "
+ "{ $bucketAuto: { buckets: 5, groupBy: \"$price\", "
+ "output: { titles: { $push: \"$name\" } } } } ] } }")));
}
}