DATAMONGO-1538 - Add support for $let to aggregation.

We now support $let in aggregation $project stage.

ExpressionVariable total = newExpressionVariable("total").forExpression(ADD.of(field("price"), field("tax")));
ExpressionVariable discounted = newExpressionVariable("discounted").forExpression(Cond.when("applyDiscount").then(0.9D).otherwise(1.0D));

newAggregation(Sales.class,
	project()
		.and(define(total, discounted)
			.andApply(MULTIPLY.of(field("total"), field("discounted"))))
		.as("finalTotal"));

Original pull request: #417.
This commit is contained in:
Christoph Strobl
2016-11-24 15:25:20 +01:00
committed by Mark Paluch
parent c9dfeea0c7
commit 68db0d4cb0
5 changed files with 353 additions and 19 deletions

View File

@@ -17,6 +17,7 @@ package org.springframework.data.mongodb.core.aggregation;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
@@ -25,6 +26,7 @@ import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.Cond.OtherwiseBuilder;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.Cond.ThenBuilder;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.Filter.AsBuilder;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.Let.ExpressionVariable;
import org.springframework.data.mongodb.core.aggregation.ExposedFields.ExposedField;
import org.springframework.data.mongodb.core.aggregation.ExposedFields.FieldReference;
import org.springframework.data.mongodb.core.query.CriteriaDefinition;
@@ -1986,6 +1988,28 @@ public interface AggregationExpressions {
public static Map.AsBuilder mapItemsOf(AggregationExpression expression) {
return Map.itemsOf(expression);
}
/**
* Start creating new {@link Let} that allows definition of {@link ExpressionVariable} that can be used within a
* nested {@link AggregationExpression}.
*
* @param variables must not be {@literal null}.
* @return
*/
public static Let.LetBuilder define(ExpressionVariable... variables) {
return Let.define(variables);
}
/**
* Start creating new {@link Let} that allows definition of {@link ExpressionVariable} that can be used within a
* nested {@link AggregationExpression}.
*
* @param variables must not be {@literal null}.
* @return
*/
public static Let.LetBuilder define(Collection<ExpressionVariable> variables) {
return Let.define(variables);
}
}
/**
@@ -6696,4 +6720,185 @@ public interface AggregationExpressions {
}
}
}
/**
* {@link AggregationExpression} for {@code $let} that binds {@link AggregationExpression} to variables for use in the
* specified {@code in} expression, and returns the result of the expression.
*
* @author Christoph Strobl
* @since 1.10
*/
class Let implements AggregationExpression {
private final List<ExpressionVariable> vars;
private final AggregationExpression expression;
private Let(List<ExpressionVariable> vars, AggregationExpression expression) {
this.vars = vars;
this.expression = expression;
}
/**
* Start creating new {@link Let} by defining the variables for {@code $vars}.
*
* @param variables must not be {@literal null}.
* @return
*/
public static LetBuilder define(final Collection<ExpressionVariable> variables) {
Assert.notNull(variables, "Variables must not be null!");
return new LetBuilder() {
@Override
public Let andApply(final AggregationExpression expression) {
Assert.notNull(expression, "Expression must not be null!");
return new Let(new ArrayList<ExpressionVariable>(variables), expression);
}
};
}
/**
* Start creating new {@link Let} by defining the variables for {@code $vars}.
*
* @param variables must not be {@literal null}.
* @return
*/
public static LetBuilder define(final ExpressionVariable... variables) {
Assert.notNull(variables, "Variables must not be null!");
return new LetBuilder() {
@Override
public Let andApply(final AggregationExpression expression) {
Assert.notNull(expression, "Expression must not be null!");
return new Let(Arrays.asList(variables), expression);
}
};
}
public interface LetBuilder {
/**
* Define the {@link AggregationExpression} to evaluate.
*
* @param expression must not be {@literal null}.
* @return
*/
Let andApply(AggregationExpression expression);
}
@Override
public DBObject toDbObject(final AggregationOperationContext context) {
return toLet(new ExposedFieldsAggregationOperationContext(
ExposedFields.synthetic(Fields.fields(getVariableNames())), context) {
@Override
public FieldReference getReference(Field field) {
FieldReference ref = null;
try {
ref = context.getReference(field);
} catch (Exception e) {
// just ignore that one.
}
return ref != null ? ref : super.getReference(field);
}
});
}
private String[] getVariableNames() {
String[] varNames = new String[this.vars.size()];
for (int i = 0; i < this.vars.size(); i++) {
varNames[i] = this.vars.get(i).variableName;
}
return varNames;
}
private DBObject toLet(AggregationOperationContext context) {
DBObject letExpression = new BasicDBObject();
DBObject mappedVars = new BasicDBObject();
for (ExpressionVariable var : this.vars) {
mappedVars.putAll(getMappedVariable(var, context));
}
letExpression.put("vars", mappedVars);
letExpression.put("in", getMappedIn(context));
return new BasicDBObject("$let", letExpression);
}
private DBObject getMappedVariable(ExpressionVariable var, AggregationOperationContext context) {
return new BasicDBObject(var.variableName, var.expression instanceof AggregationExpression
? ((AggregationExpression) var.expression).toDbObject(context) : var.expression);
}
private Object getMappedIn(AggregationOperationContext context) {
return expression.toDbObject(new NestedDelegatingExpressionAggregationOperationContext(context));
}
/**
* @author Christoph Strobl
*/
public static class ExpressionVariable {
private final String variableName;
private final Object expression;
/**
* Creates new {@link ExpressionVariable}.
*
* @param variableName can be {@literal null}.
* @param expression can be {@literal null}.
*/
private ExpressionVariable(String variableName, Object expression) {
this.variableName = variableName;
this.expression = expression;
}
/**
* Create a new {@link ExpressionVariable} with given name.
*
* @param variableName must not be {@literal null}.
* @return never {@literal null}.
*/
public static ExpressionVariable newVariable(String variableName) {
Assert.notNull(variableName, "VariableName must not be null!");
return new ExpressionVariable(variableName, null);
}
/**
* Create a new {@link ExpressionVariable} with current name and given {@literal expression}.
*
* @param expression must not be {@literal null}.
* @return never {@literal null}.
*/
public ExpressionVariable forExpression(AggregationExpression expression) {
Assert.notNull(expression, "Expression must not be null!");
return new ExpressionVariable(variableName, expression);
}
/**
* Create a new {@link ExpressionVariable} with current name and given {@literal expressionObject}.
*
* @param expressionObject must not be {@literal null}.
* @return never {@literal null}.
*/
public ExpressionVariable forExpression(DBObject expressionObject) {
Assert.notNull(expressionObject, "Expression must not be null!");
return new ExpressionVariable(variableName, expressionObject);
}
}
}
}

View File

@@ -36,7 +36,7 @@ import com.mongodb.DBObject;
@Deprecated
public enum AggregationFunctionExpressions {
SIZE, CMP, EQ, GT, GTE, LT, LTE, NE, SUBTRACT, ADD;
SIZE, CMP, EQ, GT, GTE, LT, LTE, NE, SUBTRACT, ADD, MULTIPLY;
/**
* Returns an {@link AggregationExpression} build from the current {@link Enum} name and the given parameters.

View File

@@ -17,9 +17,11 @@ package org.springframework.data.mongodb.core.aggregation;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.Let.ExpressionVariable;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.Cond;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.IfNull;
import org.springframework.data.mongodb.core.aggregation.ExposedFields.ExposedField;
@@ -852,7 +854,7 @@ public class ProjectionOperation implements FieldsExposingAggregationOperation {
/**
* Generates a {@code $setIsSubset} expression that takes array of the previously mentioned field and returns
* {@literal true} if it is a subset of the given {@literal array}.
*
*
* @param array must not be {@literal null}.
* @return never {@literal null}.
* @since 1.10
@@ -1195,7 +1197,35 @@ public class ProjectionOperation implements FieldsExposingAggregationOperation {
return this.operation.and(AggregationExpressions.DateToString.dateOf(name).toString(format));
}
/*
/**
* Generates a {@code $let} expression that binds variables for use in the specified expression, and returns the
* result of the expression.
*
* @param valueExpression The {@link AggregationExpression} bound to {@literal variableName}.
* @param variableName The variable name to be used in the {@literal in} {@link AggregationExpression}.
* @param in The {@link AggregationExpression} to evaluate.
* @return never {@literal null}.
* @since 1.10
*/
public ProjectionOperationBuilder let(AggregationExpression valueExpression, String variableName,
AggregationExpression in) {
return this.operation.and(AggregationExpressions.Let.define(ExpressionVariable.newVariable(variableName).forExpression(valueExpression)).andApply(in));
}
/**
* Generates a {@code $let} expression that binds variables for use in the specified expression, and returns the
* result of the expression.
*
* @param variables The bound {@link ExpressionVariable}s.
* @param in The {@link AggregationExpression} to evaluate.
* @return never {@literal null}.
* @since 1.10
*/
public ProjectionOperationBuilder let(Collection<ExpressionVariable> variables, AggregationExpression in) {
return this.operation.and(AggregationExpressions.Let.define(variables).andApply(in));
}
/*
* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.AggregationOperation#toDBObject(org.springframework.data.mongodb.core.aggregation.AggregationOperationContext)
*/

View File

@@ -59,6 +59,8 @@ import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.Venue;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.Cond;
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.AggregationTests.CarDescriptor.Entry;
import org.springframework.data.mongodb.core.index.GeospatialIndex;
import org.springframework.data.mongodb.core.mapping.Document;
@@ -71,6 +73,7 @@ import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import com.mongodb.BasicDBObject;
import com.mongodb.BasicDBObjectBuilder;
import com.mongodb.CommandResult;
import com.mongodb.DBCollection;
import com.mongodb.DBObject;
@@ -651,9 +654,9 @@ public class AggregationTests {
mongoTemplate.insert(new LineItem("idonly", null, 0));
TypedAggregation<LineItem> aggregation = newAggregation(LineItem.class, //
project("id") //
.and("caption")//
.applyCondition(ConditionalOperators.ifNull("caption").then("unknown")),
project("id") //
.and("caption")//
.applyCondition(ConditionalOperators.ifNull("caption").then("unknown")),
sort(ASC, "id"));
assertThat(aggregation.toString(), is(notNullValue()));
@@ -1545,6 +1548,36 @@ project("id") //
Sales.builder().id("2").items(Collections.<Item> emptyList()).build()));
}
/**
* @see DATAMONGO-1538
*/
@Test
public void letShouldBeAppliedCorrectly() {
assumeTrue(mongoVersion.isGreaterThanOrEqualTo(THREE_DOT_TWO));
Sales2 sales1 = Sales2.builder().id("1").price(10).tax(0.5F).applyDiscount(true).build();
Sales2 sales2 = Sales2.builder().id("2").price(10).tax(0.25F).applyDiscount(false).build();
mongoTemplate.insert(Arrays.asList(sales1, sales2), Sales2.class);
ExpressionVariable total = ExpressionVariable.newVariable("total")
.forExpression(AggregationFunctionExpressions.ADD.of(Fields.field("price"), Fields.field("tax")));
ExpressionVariable discounted = ExpressionVariable.newVariable("discounted")
.forExpression(Cond.when("applyDiscount").then(0.9D).otherwise(1.0D));
TypedAggregation<Sales2> agg = Aggregation.newAggregation(Sales2.class,
Aggregation.project()
.and(Let.define(total, discounted).andApply(
AggregationFunctionExpressions.MULTIPLY.of(Fields.field("total"), Fields.field("discounted"))))
.as("finalTotal"));
AggregationResults<DBObject> result = mongoTemplate.aggregate(agg, DBObject.class);
assertThat(result.getMappedResults(),
contains(new BasicDBObjectBuilder().add("_id", "1").add("finalTotal", 9.450000000000001D).get(),
new BasicDBObjectBuilder().add("_id", "2").add("finalTotal", 10.25D).get()));
}
private void createUsersWithReferencedPersons() {
mongoTemplate.dropCollection(User.class);
@@ -1786,6 +1819,9 @@ project("id") //
}
}
/**
* @DATAMONGO-1491
*/
@lombok.Data
@Builder
static class Sales {
@@ -1794,6 +1830,9 @@ project("id") //
List<Item> items;
}
/**
* @DATAMONGO-1491
*/
@lombok.Data
@Builder
static class Item {
@@ -1803,4 +1842,17 @@ project("id") //
Integer quantity;
Long price;
}
/**
* @DATAMONGO-1538
*/
@lombok.Data
@Builder
static class Sales2 {
String id;
Integer price;
Float tax;
boolean applyDiscount;
}
}

View File

@@ -16,8 +16,10 @@
package org.springframework.data.mongodb.core.aggregation;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.core.Is.is;
import static org.junit.Assert.*;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
import static org.springframework.data.mongodb.core.aggregation.AggregationExpressions.Let.ExpressionVariable.*;
import static org.springframework.data.mongodb.core.aggregation.AggregationFunctionExpressions.*;
import static org.springframework.data.mongodb.core.aggregation.Fields.*;
import static org.springframework.data.mongodb.test.util.IsBsonObject.*;
@@ -28,17 +30,9 @@ import java.util.List;
import org.junit.Test;
import org.springframework.data.mongodb.core.DBObjectTestUtils;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.ArithmeticOperators;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.ArrayOperators;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.BooleanOperators;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.ComparisonOperators;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.ConditionalOperators;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.DateOperators;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.LiteralOperators;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.SetOperators;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.StringOperators;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.VariableOperators;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.Let.ExpressionVariable;
import org.springframework.data.mongodb.core.aggregation.ProjectionOperation.ProjectionOperationBuilder;
import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.*;
import com.mongodb.BasicDBObject;
import com.mongodb.DBObject;
@@ -1712,11 +1706,12 @@ public class ProjectionOperationUnitTests {
@Test
public void shouldRenderIfNullConditionAggregationExpression() {
DBObject agg = project().and(ConditionalOperators.ifNull(ArrayOperators.arrayOf("array").elementAt(1)).then("a more sophisticated value"))
DBObject agg = project().and(
ConditionalOperators.ifNull(ArrayOperators.arrayOf("array").elementAt(1)).then("a more sophisticated value"))
.as("result").toDBObject(Aggregation.DEFAULT_CONTEXT);
assertThat(agg,
is(JSON.parse("{ $project: { result: { $ifNull: [ { $arrayElemAt: [\"$array\", 1] }, \"a more sophisticated value\" ] } } }")));
assertThat(agg, is(JSON.parse(
"{ $project: { result: { $ifNull: [ { $arrayElemAt: [\"$array\", 1] }, \"a more sophisticated value\" ] } } }")));
}
/**
@@ -1745,6 +1740,58 @@ public class ProjectionOperationUnitTests {
assertThat(agg, is(JSON.parse("{ $project: { result: { $ifNull: [ \"$optional\", \"$never-null\" ] } } }")));
}
/**
* @see DATAMONGO-1538
*/
@Test
public void shouldRenderLetExpressionCorrectly() {
DBObject agg = Aggregation.project()
.and(VariableOperators
.define(
newVariable("total")
.forExpression(AggregationFunctionExpressions.ADD.of(Fields.field("price"), Fields.field("tax"))),
newVariable("discounted").forExpression(Cond.when("applyDiscount").then(0.9D).otherwise(1.0D)))
.andApply(AggregationFunctionExpressions.MULTIPLY.of(Fields.field("total"), Fields.field("discounted")))) //
.as("finalTotal").toDBObject(Aggregation.DEFAULT_CONTEXT);
assertThat(agg,
is(JSON.parse("{ $project:{ \"finalTotal\" : { \"$let\": {" + //
"\"vars\": {" + //
"\"total\": { \"$add\": [ \"$price\", \"$tax\" ] }," + //
"\"discounted\": { \"$cond\": { \"if\": \"$applyDiscount\", \"then\": 0.9, \"else\": 1.0 } }" + //
"}," + //
"\"in\": { \"$multiply\": [ \"$$total\", \"$$discounted\" ] }" + //
"}}}}")));
}
/**
* @see DATAMONGO-1538
*/
@Test
public void shouldRenderLetExpressionCorrectlyWhenUsingLetOnProjectionBuilder() {
ExpressionVariable var1 = newVariable("total")
.forExpression(AggregationFunctionExpressions.ADD.of(Fields.field("price"), Fields.field("tax")));
ExpressionVariable var2 = newVariable("discounted")
.forExpression(Cond.when("applyDiscount").then(0.9D).otherwise(1.0D));
DBObject agg = Aggregation.project().and("foo")
.let(Arrays.asList(var1, var2),
AggregationFunctionExpressions.MULTIPLY.of(Fields.field("total"), Fields.field("discounted")))
.as("finalTotal").toDBObject(Aggregation.DEFAULT_CONTEXT);
assertThat(agg,
is(JSON.parse("{ $project:{ \"finalTotal\" : { \"$let\": {" + //
"\"vars\": {" + //
"\"total\": { \"$add\": [ \"$price\", \"$tax\" ] }," + //
"\"discounted\": { \"$cond\": { \"if\": \"$applyDiscount\", \"then\": 0.9, \"else\": 1.0 } }" + //
"}," + //
"\"in\": { \"$multiply\": [ \"$$total\", \"$$discounted\" ] }" + //
"}}}}")));
}
private static DBObject exctractOperation(String field, DBObject fromProjectClause) {
return (DBObject) fromProjectClause.get(field);
}