diff --git a/pom.xml b/pom.xml index dfb64f9ba3..2807000a4c 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-mongodb-parent - 1.10.0.BUILD-SNAPSHOT + 1.10.0.DATAMONGO-861-SNAPSHOT pom Spring Data MongoDB diff --git a/spring-data-mongodb-cross-store/pom.xml b/spring-data-mongodb-cross-store/pom.xml index ae0a5d6c8f..9ef00665c2 100644 --- a/spring-data-mongodb-cross-store/pom.xml +++ b/spring-data-mongodb-cross-store/pom.xml @@ -6,7 +6,7 @@ org.springframework.data spring-data-mongodb-parent - 1.10.0.BUILD-SNAPSHOT + 1.10.0.DATAMONGO-861-SNAPSHOT ../pom.xml @@ -48,7 +48,7 @@ org.springframework.data spring-data-mongodb - 1.10.0.BUILD-SNAPSHOT + 1.10.0.DATAMONGO-861-SNAPSHOT diff --git a/spring-data-mongodb-distribution/pom.xml b/spring-data-mongodb-distribution/pom.xml index 2d02722262..3e6b81bc9d 100644 --- a/spring-data-mongodb-distribution/pom.xml +++ b/spring-data-mongodb-distribution/pom.xml @@ -13,7 +13,7 @@ org.springframework.data spring-data-mongodb-parent - 1.10.0.BUILD-SNAPSHOT + 1.10.0.DATAMONGO-861-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb-log4j/pom.xml b/spring-data-mongodb-log4j/pom.xml index ee5e3336db..bddc5fdb6c 100644 --- a/spring-data-mongodb-log4j/pom.xml +++ b/spring-data-mongodb-log4j/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-mongodb-parent - 1.10.0.BUILD-SNAPSHOT + 1.10.0.DATAMONGO-861-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index 8072d3f665..26eefbaa82 100644 --- a/spring-data-mongodb/pom.xml +++ b/spring-data-mongodb/pom.xml @@ -11,7 +11,7 @@ org.springframework.data spring-data-mongodb-parent - 1.10.0.BUILD-SNAPSHOT + 1.10.0.DATAMONGO-861-SNAPSHOT ../pom.xml 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 6ebd8cdc3b..ea831b6119 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 @@ -25,7 +25,7 @@ import org.springframework.data.domain.Sort.Direction; 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.*; +import org.springframework.data.mongodb.core.aggregation.Fields.AggregationField; import org.springframework.data.mongodb.core.aggregation.FieldsExposingAggregationOperation.InheritsFieldsAggregationOperation; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.NearQuery; @@ -331,13 +331,13 @@ public static MatchOperation match(Criteria criteria) { } /** - * Creates a new {@link OutOperation} using the given collection name. This operation must be the last operation - * in the pipeline. + * Creates a new {@link OutOperation} using the given collection name. This operation must be the last operation in + * the pipeline. * * @param outCollectionName collection name to export aggregation results. The {@link OutOperation} creates a new - * collection in the current database if one does not already exist. The collection is - * not visible until the aggregation completes. If the aggregation fails, MongoDB does - * not create the collection. Must not be {@literal null}. + * collection in the current database if one does not already exist. The collection is not visible until the + * aggregation completes. If the aggregation fails, MongoDB does not create the collection. Must not be + * {@literal null}. * @return */ public static OutOperation out(String outCollectionName) { @@ -372,6 +372,68 @@ public static LookupOperation lookup(Field from, Field localField, Field foreign return new LookupOperation(from, localField, foreignField, as); } + /** + * Creates a new {@link IfNullOperator} for the given {@code field} and {@code replacement} value. + * + * @param field must not be {@literal null}. + * @param replacement must not be {@literal null}. + * @return never {@literal null}. + * @since 1.10 + */ + public static IfNullOperator ifNull(String field, Object replacement) { + return IfNullOperator.newBuilder().ifNull(field).thenReplaceWith(replacement); + } + + /** + * Creates a new {@link IfNullOperator} for the given {@link Field} and {@link Field} to obtain a value from. + * + * @param field must not be {@literal null}. + * @param replacement must not be {@literal null}. + * @return never {@literal null}. + * @since 1.10 + */ + public static IfNullOperator ifNull(Field field, Field replacement) { + return IfNullOperator.newBuilder().ifNull(field).thenReplaceWith(replacement); + } + + /** + * Creates a new {@link IfNullOperator} for the given {@link Field} and {@code replacement} value. + * + * @param field must not be {@literal null}. + * @param replacement must not be {@literal null}. + * @return never {@literal null}. + * @since 1.10 + */ + public static IfNullOperator ifNull(Field field, Object replacement) { + return IfNullOperator.newBuilder().ifNull(field).thenReplaceWith(replacement); + } + + /** + * Creates a new {@link ConditionalOperator} for the given {@link Field} that holds a {@literal boolean} value. + * + * @param booleanField must not be {@literal null}. + * @param then must not be {@literal null}. + * @param otherwise must not be {@literal null}. + * @return never {@literal null}. + * @since 1.10 + */ + public static ConditionalOperator conditional(Field booleanField, Object then, Object otherwise) { + return ConditionalOperator.newBuilder().when(booleanField).then(then).otherwise(otherwise); + } + + /** + * Creates a new {@link ConditionalOperator} for the given {@link Criteria}. + * + * @param criteria must not be {@literal null}. + * @param then must not be {@literal null}. + * @param otherwise must not be {@literal null}. + * @return never {@literal null}. + * @since 1.10 + */ + public static ConditionalOperator conditional(Criteria criteria, Object then, Object otherwise) { + return ConditionalOperator.newBuilder().when(criteria).then(then).otherwise(otherwise); + } + /** * Creates a new {@link Fields} instance for the given field names. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ConditionalOperator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ConditionalOperator.java new file mode 100644 index 0000000000..881894a14f --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ConditionalOperator.java @@ -0,0 +1,394 @@ +/* + * 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.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.mongodb.core.query.CriteriaDefinition; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +import com.mongodb.BasicDBObject; +import com.mongodb.DBObject; + +/** + * Encapsulates the aggregation framework {@code $cond} operator. A {@link ConditionalOperator} allows nested conditions + * {@code if-then[if-then-else]-else} using {@link Field}, {@link CriteriaDefinition} or a {@link DBObject custom} + * condition. Replacement values can be either {@link Field field references}, values of simple MongoDB types or values + * that can be converted to a simple MongoDB type. + * + * @see http://docs.mongodb.com/manual/reference/operator/aggregation/cond/ + * @author Mark Paluch + * @author Christoph Strobl + * @since 1.10 + */ +public class ConditionalOperator implements AggregationExpression { + + private final Object condition; + private final Object thenValue; + private final Object otherwiseValue; + + /** + * Creates a new {@link ConditionalOperator} for a given {@link Field} and {@code then}/{@code otherwise} values. + * + * @param condition must not be {@literal null}. + * @param thenValue must not be {@literal null}. + * @param otherwiseValue must not be {@literal null}. + */ + public ConditionalOperator(Field condition, Object thenValue, Object otherwiseValue) { + this((Object) condition, thenValue, otherwiseValue); + } + + /** + * Creates a new {@link ConditionalOperator} for a given {@link CriteriaDefinition} and {@code then}/{@code otherwise} + * values. + * + * @param condition must not be {@literal null}. + * @param thenValue must not be {@literal null}. + * @param otherwiseValue must not be {@literal null}. + */ + public ConditionalOperator(CriteriaDefinition condition, Object thenValue, Object otherwiseValue) { + this((Object) condition, thenValue, otherwiseValue); + } + + /** + * Creates a new {@link ConditionalOperator} for a given {@link DBObject criteria} and {@code then}/{@code otherwise} + * values. + * + * @param condition must not be {@literal null}. + * @param thenValue must not be {@literal null}. + * @param otherwiseValue must not be {@literal null}. + */ + public ConditionalOperator(DBObject condition, Object thenValue, Object otherwiseValue) { + this((Object) condition, thenValue, otherwiseValue); + } + + private ConditionalOperator(Object condition, Object thenValue, Object otherwiseValue) { + + Assert.notNull(condition, "Condition must not be null!"); + Assert.notNull(thenValue, "'Then value' must not be null!"); + Assert.notNull(otherwiseValue, "'Otherwise value' must not be null!"); + + assertNotBuilder(condition, "Condition"); + assertNotBuilder(thenValue, "'Then value'"); + assertNotBuilder(otherwiseValue, "'Otherwise value'"); + + this.condition = condition; + this.thenValue = thenValue; + this.otherwiseValue = otherwiseValue; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.AggregationExpression#toDbObject(org.springframework.data.mongodb.core.aggregation.AggregationOperationContext) + */ + @Override + public DBObject toDbObject(AggregationOperationContext context) { + + BasicDBObject condObject = new BasicDBObject(); + + condObject.append("if", resolveCriteria(context, condition)); + condObject.append("then", resolveValue(context, thenValue)); + condObject.append("else", resolveValue(context, otherwiseValue)); + + return new BasicDBObject("$cond", condObject); + } + + private Object resolveValue(AggregationOperationContext context, Object value) { + + if (value instanceof DBObject || value instanceof Field) { + return resolve(context, value); + } + + if (value instanceof ConditionalOperator) { + return ((ConditionalOperator) value).toDbObject(context); + } + + return context.getMappedObject(new BasicDBObject("$set", value)).get("$set"); + } + + private Object resolveCriteria(AggregationOperationContext context, Object value) { + + if (value instanceof DBObject || value instanceof Field) { + return resolve(context, value); + } + + if (value instanceof CriteriaDefinition) { + + DBObject mappedObject = context.getMappedObject(((CriteriaDefinition) value).getCriteriaObject()); + List clauses = new ArrayList(); + + clauses.addAll(getClauses(context, mappedObject)); + + if (clauses.size() == 1) { + return clauses.get(0); + } + + return clauses; + } + + throw new InvalidDataAccessApiUsageException( + String.format("Invalid value in condition. Supported: DBObject, Field references, Criteria, got: %s", value)); + } + + private List getClauses(AggregationOperationContext context, DBObject mappedObject) { + + List clauses = new ArrayList(); + + for (String key : mappedObject.keySet()) { + + Object predicate = mappedObject.get(key); + clauses.addAll(getClauses(context, key, predicate)); + } + + return clauses; + } + + private List getClauses(AggregationOperationContext context, String key, Object predicate) { + + List clauses = new ArrayList(); + + if (predicate instanceof List) { + + List args = new ArrayList(); + for (Object clause : (List) predicate) { + if (clause instanceof DBObject) { + args.addAll(getClauses(context, (DBObject) clause)); + } + } + + clauses.add(new BasicDBObject(key, args)); + + } else if (predicate instanceof DBObject) { + + DBObject nested = (DBObject) predicate; + + for (String s : nested.keySet()) { + + if (!isKeyword(s)) { + continue; + } + + List args = new ArrayList(); + args.add("$" + key); + args.add(nested.get(s)); + clauses.add(new BasicDBObject(s, args)); + } + + } else if (!isKeyword(key)) { + + List args = new ArrayList(); + args.add("$" + key); + args.add(predicate); + clauses.add(new BasicDBObject("$eq", args)); + } + + return clauses; + } + + /** + * Returns whether the given {@link String} is a MongoDB keyword. + * + * @param candidate + * @return + */ + private boolean isKeyword(String candidate) { + return candidate.startsWith("$"); + } + + private Object resolve(AggregationOperationContext context, Object value) { + + if (value instanceof DBObject) { + return context.getMappedObject((DBObject) value); + } + + return context.getReference((Field) value).toString(); + } + + private void assertNotBuilder(Object toCheck, String name) { + Assert.isTrue(!ClassUtils.isAssignableValue(ConditionalExpressionBuilder.class, toCheck), + String.format("%s must not be of type %s", name, ConditionalExpressionBuilder.class.getSimpleName())); + } + + /** + * Get a builder that allows fluent creation of {@link ConditionalOperator}. + * + * @return a new {@link ConditionalExpressionBuilder}. + */ + public static ConditionalExpressionBuilder newBuilder() { + return ConditionalExpressionBuilder.newBuilder(); + } + + /** + * @since 1.10 + */ + public static interface WhenBuilder { + + /** + * @param booleanExpression expression that yields in a boolean result, must not be {@literal null}. + * @return the {@link ThenBuilder} + */ + ThenBuilder when(DBObject booleanExpression); + + /** + * @param booleanField reference to a field holding a boolean value, must not be {@literal null}. + * @return the {@link ThenBuilder} + */ + ThenBuilder when(Field booleanField); + + /** + * @param booleanField name of a field holding a boolean value, must not be {@literal null}. + * @return the {@link ThenBuilder} + */ + ThenBuilder when(String booleanField); + + /** + * @param criteria criteria to evaluate, must not be {@literal null}. + * @return the {@link ThenBuilder} + */ + ThenBuilder when(CriteriaDefinition criteria); + } + + /** + * @since 1.10 + */ + public static interface ThenBuilder { + + /** + * @param value the value to be used if the condition evaluates {@literal true}. Can be a {@link DBObject}, a value + * that is supported by MongoDB or a value that can be converted to a MongoDB representation but must not + * be {@literal null}. + * @return the {@link OtherwiseBuilder} + */ + OtherwiseBuilder then(Object value); + } + + /** + * @since 1.10 + */ + public static interface OtherwiseBuilder { + + /** + * @param value the value to be used if the condition evaluates {@literal false}. Can be a {@link DBObject}, a value + * that is supported by MongoDB or a value that can be converted to a MongoDB representation but must not + * be {@literal null}. + * @return the {@link ConditionalOperator} + */ + ConditionalOperator otherwise(Object value); + } + + /** + * Builder for fluent {@link ConditionalOperator} creation. + * + * @author Mark Paluch + * @since 1.10 + */ + public static final class ConditionalExpressionBuilder implements WhenBuilder, ThenBuilder, OtherwiseBuilder { + + private Object condition; + private Object thenValue; + + private ConditionalExpressionBuilder() {} + + /** + * Creates a new builder for {@link ConditionalOperator}. + * + * @return never {@literal null}. + */ + public static ConditionalExpressionBuilder newBuilder() { + return new ConditionalExpressionBuilder(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.ConditionalOperator.WhenBuilder#when(com.mongodb.DBObject) + */ + @Override + public ConditionalExpressionBuilder when(DBObject booleanExpression) { + + Assert.notNull(booleanExpression, "'Boolean expression' must not be null!"); + + this.condition = booleanExpression; + return this; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.ConditionalOperator.WhenBuilder#when(org.springframework.data.mongodb.core.query.CriteriaDefinition) + */ + @Override + public ThenBuilder when(CriteriaDefinition criteria) { + + Assert.notNull(criteria, "Criteria must not be null!"); + + this.condition = criteria; + return this; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.ConditionalOperator.WhenBuilder#when(org.springframework.data.mongodb.core.aggregation.Field) + */ + @Override + public ThenBuilder when(Field booleanField) { + + Assert.notNull(booleanField, "Boolean field must not be null!"); + + this.condition = booleanField; + return this; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.ConditionalOperator.WhenBuilder#when(java.lang.String) + */ + @Override + public ThenBuilder when(String booleanField) { + + Assert.hasText(booleanField, "Boolean field name must not be null or empty!"); + + this.condition = Fields.field(booleanField); + return this; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.ConditionalOperator.ThenBuilder#then(java.lang.Object) + */ + @Override + public OtherwiseBuilder then(Object thenValue) { + + Assert.notNull(thenValue, "'Then-value' must not be null!"); + + this.thenValue = thenValue; + return this; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.ConditionalOperator.OtherwiseBuilder#otherwise(java.lang.Object) + */ + @Override + public ConditionalOperator otherwise(Object otherwiseValue) { + + Assert.notNull(otherwiseValue, "'Otherwise-value' must not be null!"); + + return new ConditionalOperator(condition, thenValue, otherwiseValue); + } + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/IfNullOperator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/IfNullOperator.java new file mode 100644 index 0000000000..b51aba01bb --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/IfNullOperator.java @@ -0,0 +1,197 @@ +/* + * 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.springframework.util.Assert; + +import com.mongodb.BasicDBObject; +import com.mongodb.DBObject; + +/** + * Encapsulates the aggregation framework {@code $ifNull} operator. Replacement values can be either {@link Field field + * references}, values of simple MongoDB types or values that can be converted to a simple MongoDB type. + * + * @see http://docs.mongodb.com/manual/reference/operator/aggregation/ifNull/ + * @author Mark Paluch + * @since 1.10 + */ +public class IfNullOperator implements AggregationExpression { + + private final Field field; + private final Object value; + + /** + * Creates a new {@link IfNullOperator} for the given {@link Field} and replacement {@code value}. + * + * @param field must not be {@literal null}. + * @param value must not be {@literal null}. + */ + public IfNullOperator(Field field, Object value) { + + Assert.notNull(field, "Field must not be null!"); + Assert.notNull(value, "'Replacement-value' must not be null!"); + + this.field = field; + this.value = value; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.AggregationExpression#toDbObject(org.springframework.data.mongodb.core.aggregation.AggregationOperationContext) + */ + @Override + public DBObject toDbObject(AggregationOperationContext context) { + + List list = new ArrayList(); + + list.add(context.getReference(field).toString()); + list.add(resolve(value, context)); + + return new BasicDBObject("$ifNull", list); + } + + private Object resolve(Object value, AggregationOperationContext context) { + + if (value instanceof Field) { + return context.getReference((Field) value).toString(); + } else if (value instanceof DBObject) { + return value; + } + + return context.getMappedObject(new BasicDBObject("$set", value)).get("$set"); + } + + /** + * Get a builder that allows fluent creation of {@link IfNullOperator}. + * + * @return a new {@link IfNullBuilder}. + */ + public static IfNullBuilder newBuilder() { + return IfNullOperatorBuilder.newBuilder(); + } + + /** + * @since 1.10 + */ + public static interface IfNullBuilder { + + /** + * @param field the field to check for a {@literal null} value, field reference must not be {@literal null}. + * @return the {@link ThenBuilder} + */ + ThenBuilder ifNull(Field field); + + /** + * @param field the field to check for a {@literal null} value, field name must not be {@literal null} or empty. + * @return the {@link ThenBuilder} + */ + ThenBuilder ifNull(String field); + } + + /** + * @since 1.10 + */ + public static interface ThenBuilder { + + /** + * @param field the field holding the replacement value, must not be {@literal null}. + * @return the {@link IfNullOperator} + */ + IfNullOperator thenReplaceWith(Field field); + + /** + * @param value the value to be used if the {@code $ifNull }condition evaluates {@literal true}. Can be a + * {@link DBObject}, a value that is supported by MongoDB or a value that can be converted to a MongoDB + * representation but must not be {@literal null}. + * @return the {@link IfNullOperator} + */ + IfNullOperator thenReplaceWith(Object value); + } + + /** + * Builder for fluent {@link IfNullOperator} creation. + * + * @author Mark Paluch + * @since 1.10 + */ + public static final class IfNullOperatorBuilder implements IfNullBuilder, ThenBuilder { + + private Field field; + + private IfNullOperatorBuilder() {} + + /** + * Creates a new builder for {@link IfNullOperator}. + * + * @return never {@literal null}. + */ + public static IfNullOperatorBuilder newBuilder() { + return new IfNullOperatorBuilder(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.IfNullOperator.IfNullBuilder#ifNull(org.springframework.data.mongodb.core.aggregation.Field) + */ + public ThenBuilder ifNull(Field field) { + + Assert.notNull(field, "Field must not be null!"); + + this.field = field; + return this; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.IfNullOperator.IfNullBuilder#ifNull(java.lang.String) + */ + public ThenBuilder ifNull(String name) { + + Assert.hasText(name, "Field name must not be null or empty!"); + + this.field = Fields.field(name); + return this; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.IfNullOperator.ThenReplaceBuilder#thenReplaceWith(org.springframework.data.mongodb.core.aggregation.Field) + */ + @Override + public IfNullOperator thenReplaceWith(Field replacementField) { + + Assert.notNull(replacementField, "Replacement field must not be null!"); + + return new IfNullOperator(this.field, replacementField); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.IfNullOperator.ThenReplaceBuilder#thenReplaceWith(java.lang.Object) + */ + @Override + public IfNullOperator thenReplaceWith(Object value) { + + Assert.notNull(value, "'Replacement-value' must not be null!"); + + return new IfNullOperator(this.field, value); + } + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ProjectionOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ProjectionOperation.java index 94fc9cb97a..73e11bf4dd 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ProjectionOperation.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ProjectionOperation.java @@ -42,6 +42,7 @@ * @author Thomas Darimont * @author Oliver Gierke * @author Christoph Strobl + * @author Mark Paluch * @since 1.3 */ public class ProjectionOperation implements FieldsExposingAggregationOperation { @@ -104,8 +105,8 @@ private ProjectionOperation and(Projection projection) { */ private ProjectionOperation andReplaceLastOneWith(Projection projection) { - List projections = this.projections.isEmpty() ? Collections. emptyList() : this.projections - .subList(0, this.projections.size() - 1); + List projections = this.projections.isEmpty() ? Collections. emptyList() + : this.projections.subList(0, this.projections.size() - 1); return new ProjectionOperation(projections, Arrays.asList(projection)); } @@ -240,6 +241,24 @@ public DBObject toDBObject(AggregationOperationContext context) { * @return */ public abstract ProjectionOperation as(String alias); + + /** + * Apply a conditional projection using {@link ConditionalOperator}. + * + * @param conditionalOperator must not be {@literal null}. + * @return never {@literal null}. + * @since 1.10 + */ + public abstract ProjectionOperation applyCondition(ConditionalOperator conditionalOperator); + + /** + * Apply a conditional value replacement for {@literal null} values using {@link IfNullOperator}. + * + * @param ifNullOperator must not be {@literal null}. + * @return never {@literal null}. + * @since 1.10 + */ + public abstract ProjectionOperation applyCondition(IfNullOperator ifNullOperator); } /** @@ -341,7 +360,8 @@ public DBObject toDBObject(AggregationOperationContext context) { return new BasicDBObject(getExposedField().getName(), toMongoExpression(context, expression, params)); } - protected static Object toMongoExpression(AggregationOperationContext context, String expression, Object[] params) { + protected static Object toMongoExpression(AggregationOperationContext context, String expression, + Object[] params) { return TRANSFORMER.transform(expression, context, params); } } @@ -370,7 +390,8 @@ public static class ProjectionOperationBuilder extends AbstractProjectionOperati * @param operation must not be {@literal null}. * @param previousProjection the previous operation projection, may be {@literal null}. */ - public ProjectionOperationBuilder(String name, ProjectionOperation operation, OperationProjection previousProjection) { + public ProjectionOperationBuilder(String name, ProjectionOperation operation, + OperationProjection previousProjection) { super(name, operation); this.name = name; @@ -419,7 +440,7 @@ public ProjectionOperation nested(Fields fields) { /** * Allows to specify an alias for the previous projection operation. * - * @param string + * @param alias * @return */ @Override @@ -436,6 +457,28 @@ public ProjectionOperation as(String alias) { return this.operation.and(new FieldProjection(Fields.field(alias, name), null)); } + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.ProjectionOperation.AbstractProjectionOperationBuilder#transform(org.springframework.data.mongodb.core.aggregation.ConditionalOperator) + */ + @Override + public ProjectionOperation applyCondition(ConditionalOperator conditionalOperator) { + + Assert.notNull(conditionalOperator, "ConditionalOperator must not be null!"); + return this.operation.and(new ExpressionProjection(Fields.field(name), conditionalOperator)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.ProjectionOperation.AbstractProjectionOperationBuilder#transform(org.springframework.data.mongodb.core.aggregation.IfNullOperator) + */ + @Override + public ProjectionOperation applyCondition(IfNullOperator ifNullOperator) { + + Assert.notNull(ifNullOperator, "IfNullOperator must not be null!"); + return this.operation.and(new ExpressionProjection(Fields.field(name), ifNullOperator)); + } + /** * Generates an {@code $add} expression that adds the given number to the previously mentioned field. * @@ -764,7 +807,7 @@ public OperationProjection(Field field, String operation, Object[] values) { this.values = Arrays.asList(values); } - /* + /* * (non-Javadoc) * @see org.springframework.data.mongodb.core.aggregation.ProjectionOperation.Projection#toDBObject(org.springframework.data.mongodb.core.aggregation.AggregationOperationContext) */ 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 b9ec9d853f..9acc010010 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 @@ -20,6 +20,7 @@ import static org.junit.Assume.*; import static org.springframework.data.domain.Sort.Direction.*; import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; +import static org.springframework.data.mongodb.core.aggregation.Fields.*; import static org.springframework.data.mongodb.core.query.Criteria.*; import static org.springframework.data.mongodb.test.util.IsBsonObject.*; @@ -55,6 +56,7 @@ import org.springframework.data.mongodb.core.Venue; 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; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.NearQuery; import org.springframework.data.mongodb.core.query.Query; @@ -131,6 +133,8 @@ private void cleanDb() { mongoTemplate.dropCollection(Reservation.class); mongoTemplate.dropCollection(Venue.class); mongoTemplate.dropCollection(MeterData.class); + mongoTemplate.dropCollection(LineItem.class); + mongoTemplate.dropCollection(InventoryItem.class); } /** @@ -484,6 +488,250 @@ public void findStatesWithPopulationOver10MillionAggregationExample() { assertThat(stateStats.totalPopulation, is(29760021)); } + /** + * @see DATAMONGO-861 + * @see https://docs.mongodb.com/manual/reference/operator/aggregation/cond/#example + */ + @Test + public void aggregationUsingConditionalProjectionToCalculateDiscount() { + + /* + db.inventory.aggregate( + [ + { + $project: + { + item: 1, + discount: + { + $cond: { if: { $gte: [ "$qty", 250 ] }, then: 30, else: 20 } + } + } + } + ] + ) + */ + + mongoTemplate.insert(new InventoryItem(1, "abc1", 300)); + mongoTemplate.insert(new InventoryItem(2, "abc2", 200)); + mongoTemplate.insert(new InventoryItem(3, "xyz1", 250)); + + TypedAggregation aggregation = newAggregation(InventoryItem.class, // + project("item") // + .and("discount")// + .applyCondition(ConditionalOperator.newBuilder().when(Criteria.where("qty").gte(250)) // + .then(30) // + .otherwise(20))); + + assertThat(aggregation.toString(), is(notNullValue())); + + AggregationResults result = mongoTemplate.aggregate(aggregation, DBObject.class); + assertThat(result.getMappedResults().size(), is(3)); + + DBObject first = result.getMappedResults().get(0); + assertThat(first.get("_id"), is((Object) 1)); + assertThat(first.get("discount"), is((Object) 30)); + + DBObject second = result.getMappedResults().get(1); + assertThat(second.get("_id"), is((Object) 2)); + assertThat(second.get("discount"), is((Object) 20)); + + DBObject third = result.getMappedResults().get(2); + assertThat(third.get("_id"), is((Object) 3)); + assertThat(third.get("discount"), is((Object) 30)); + } + + /** + * @see DATAMONGO-861 + * @see https://docs.mongodb.com/manual/reference/operator/aggregation/ifNull/#example + */ + @Test + public void aggregationUsingIfNullToProjectSaneDefaults() { + + /* + db.inventory.aggregate( + [ + { + $project: { + item: 1, + description: { $ifNull: [ "$description", "Unspecified" ] } + } + } + ] + ) + */ + + mongoTemplate.insert(new InventoryItem(1, "abc1", "product 1", 300)); + mongoTemplate.insert(new InventoryItem(2, "abc2", 200)); + mongoTemplate.insert(new InventoryItem(3, "xyz1", 250)); + + TypedAggregation aggregation = newAggregation(InventoryItem.class, // + project("item") // + .and(ifNull("description", "Unspecified")) // + .as("description")// + ); + + assertThat(aggregation.toString(), is(notNullValue())); + + AggregationResults result = mongoTemplate.aggregate(aggregation, DBObject.class); + assertThat(result.getMappedResults().size(), is(3)); + + DBObject first = result.getMappedResults().get(0); + assertThat(first.get("_id"), is((Object) 1)); + assertThat(first.get("description"), is((Object) "product 1")); + + DBObject second = result.getMappedResults().get(1); + assertThat(second.get("_id"), is((Object) 2)); + assertThat(second.get("description"), is((Object) "Unspecified")); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void aggregationUsingConditionalProjection() { + + TypedAggregation aggregation = newAggregation(ZipInfo.class, // + project() // + .and("largePopulation")// + .applyCondition(ConditionalOperator.newBuilder().when(Criteria.where("population").gte(20000)) // + .then(true) // + .otherwise(false)) // + .and("population").as("population")); + + assertThat(aggregation, is(notNullValue())); + assertThat(aggregation.toString(), is(notNullValue())); + + AggregationResults result = mongoTemplate.aggregate(aggregation, DBObject.class); + assertThat(result.getMappedResults().size(), is(29467)); + + DBObject firstZipInfoStats = result.getMappedResults().get(0); + assertThat(firstZipInfoStats.get("largePopulation"), is((Object) false)); + assertThat(firstZipInfoStats.get("population"), is((Object) 6055)); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void aggregationUsingNestedConditionalProjection() { + + TypedAggregation aggregation = newAggregation(ZipInfo.class, // + project() // + .and("size")// + .applyCondition(ConditionalOperator.newBuilder().when(Criteria.where("population").gte(20000)) // + .then(ConditionalOperator.newBuilder().when(Criteria.where("population").gte(200000)).then("huge") + .otherwise("small")) // + .otherwise("small")) // + .and("population").as("population")); + + assertThat(aggregation, is(notNullValue())); + assertThat(aggregation.toString(), is(notNullValue())); + + AggregationResults result = mongoTemplate.aggregate(aggregation, DBObject.class); + assertThat(result.getMappedResults().size(), is(29467)); + + DBObject firstZipInfoStats = result.getMappedResults().get(0); + assertThat(firstZipInfoStats.get("size"), is((Object) "small")); + assertThat(firstZipInfoStats.get("population"), is((Object) 6055)); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void aggregationUsingIfNullProjection() { + + mongoTemplate.insert(new LineItem("id", "caption", 0)); + mongoTemplate.insert(new LineItem("idonly", null, 0)); + + TypedAggregation aggregation = newAggregation(LineItem.class, // + project("id") // + .and("caption")// + .applyCondition(ifNull(field("caption"), "unknown")), + sort(ASC, "id")); + + assertThat(aggregation.toString(), is(notNullValue())); + + AggregationResults result = mongoTemplate.aggregate(aggregation, DBObject.class); + assertThat(result.getMappedResults().size(), is(2)); + + DBObject id = result.getMappedResults().get(0); + assertThat((String) id.get("caption"), is(equalTo("caption"))); + + DBObject idonly = result.getMappedResults().get(1); + assertThat((String) idonly.get("caption"), is(equalTo("unknown"))); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void aggregationUsingIfNullReplaceWithFieldReferenceProjection() { + + mongoTemplate.insert(new LineItem("id", "caption", 0)); + mongoTemplate.insert(new LineItem("idonly", null, 0)); + + TypedAggregation aggregation = newAggregation(LineItem.class, // + project("id") // + .and("caption")// + .applyCondition(ifNull(field("caption"), field("id"))), + sort(ASC, "id")); + + assertThat(aggregation.toString(), is(notNullValue())); + + AggregationResults result = mongoTemplate.aggregate(aggregation, DBObject.class); + assertThat(result.getMappedResults().size(), is(2)); + + DBObject id = result.getMappedResults().get(0); + assertThat((String) id.get("caption"), is(equalTo("caption"))); + + DBObject idonly = result.getMappedResults().get(1); + assertThat((String) idonly.get("caption"), is(equalTo("idonly"))); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void shouldAllowGroupingUsingConditionalExpressions() { + + mongoTemplate.dropCollection(CarPerson.class); + + CarPerson person1 = new CarPerson("first1", "last1", new CarDescriptor.Entry("MAKE1", "MODEL1", 2000), + new CarDescriptor.Entry("MAKE1", "MODEL2", 2001)); + + CarPerson person2 = new CarPerson("first2", "last2", new CarDescriptor.Entry("MAKE3", "MODEL4", 2014)); + CarPerson person3 = new CarPerson("first3", "last3", new CarDescriptor.Entry("MAKE2", "MODEL5", 2015)); + + mongoTemplate.save(person1); + mongoTemplate.save(person2); + mongoTemplate.save(person3); + + TypedAggregation agg = Aggregation.newAggregation(CarPerson.class, + unwind("descriptors.carDescriptor.entries"), // + project() // + .and(new ConditionalOperator(Criteria.where("descriptors.carDescriptor.entries.make").is("MAKE1"), "good", + "meh")) + .as("make") // + .and("descriptors.carDescriptor.entries.model").as("model") // + .and("descriptors.carDescriptor.entries.year").as("year"), // + group("make").avg(new ConditionalOperator(Criteria.where("year").gte(2012), 1, 9000)).as("score"), + sort(ASC, "make")); + + AggregationResults result = mongoTemplate.aggregate(agg, DBObject.class); + + assertThat(result.getMappedResults(), hasSize(2)); + + DBObject meh = result.getMappedResults().get(0); + assertThat((String) meh.get("_id"), is(equalTo("meh"))); + assertThat(((Number) meh.get("score")).longValue(), is(equalTo(1L))); + + DBObject good = result.getMappedResults().get(1); + assertThat((String) good.get("_id"), is(equalTo("good"))); + assertThat(((Number) good.get("score")).longValue(), is(equalTo(9000L))); + } + /** * @see http://docs.mongodb.org/manual/tutorial/aggregation-examples/#return-the-five-most-common-likes */ @@ -550,11 +798,12 @@ public void arithmenticOperatorsInProjectionExample() { assertThat((Double) resultList.get(0).get("netPriceMul2"), is(product.netPrice * 2)); assertThat((Double) resultList.get(0).get("netPriceDiv119"), is(product.netPrice / 1.19)); assertThat((Integer) resultList.get(0).get("spaceUnitsMod2"), is(product.spaceUnits % 2)); - assertThat((Integer) resultList.get(0).get("spaceUnitsPlusSpaceUnits"), is(product.spaceUnits + product.spaceUnits)); + assertThat((Integer) resultList.get(0).get("spaceUnitsPlusSpaceUnits"), + is(product.spaceUnits + product.spaceUnits)); assertThat((Integer) resultList.get(0).get("spaceUnitsMinusSpaceUnits"), is(product.spaceUnits - product.spaceUnits)); - assertThat((Integer) resultList.get(0).get("spaceUnitsMultiplySpaceUnits"), is(product.spaceUnits - * product.spaceUnits)); + assertThat((Integer) resultList.get(0).get("spaceUnitsMultiplySpaceUnits"), + is(product.spaceUnits * product.spaceUnits)); assertThat((Double) resultList.get(0).get("spaceUnitsDivideSpaceUnits"), is((double) (product.spaceUnits / product.spaceUnits))); assertThat((Integer) resultList.get(0).get("spaceUnitsModSpaceUnits"), is(product.spaceUnits % product.spaceUnits)); @@ -643,8 +892,8 @@ public void expressionsInProjectionExampleShowcase() { DBObject firstItem = resultList.get(0); assertThat((String) firstItem.get("_id"), is(product.id)); assertThat((String) firstItem.get("name"), is(product.name)); - assertThat((Double) firstItem.get("salesPrice"), is((product.netPrice * (1 - product.discountRate) + shippingCosts) - * (1 + product.taxRate))); + assertThat((Double) firstItem.get("salesPrice"), + is((product.netPrice * (1 - product.discountRate) + shippingCosts) * (1 + product.taxRate))); } @Test @@ -666,7 +915,7 @@ public void shouldThrowExceptionIfUnknownFieldIsReferencedInArithmenticExpressio /** * @see DATAMONGO-753 - * @see http + * @see http * ://stackoverflow.com/questions/18653574/spring-data-mongodb-aggregation-framework-invalid-reference-in-group * -operati */ @@ -696,7 +945,7 @@ public void allowsNestedFieldReferencesAsGroupIdsInGroupExpressions() { /** * @see DATAMONGO-753 - * @see http + * @see http * ://stackoverflow.com/questions/18653574/spring-data-mongodb-aggregation-framework-invalid-reference-in-group * -operati */ @@ -731,12 +980,13 @@ public void shouldPerformDateProjectionOperatorsCorrectly() throws ParseExceptio data.stringValue = "ABC"; mongoTemplate.insert(data); - TypedAggregation agg = newAggregation(Data.class, project() // - .andExpression("concat(stringValue, 'DE')").as("concat") // - .andExpression("strcasecmp(stringValue,'XYZ')").as("strcasecmp") // - .andExpression("substr(stringValue,1,1)").as("substr") // - .andExpression("toLower(stringValue)").as("toLower") // - .andExpression("toUpper(toLower(stringValue))").as("toUpper") // + TypedAggregation agg = newAggregation(Data.class, + project() // + .andExpression("concat(stringValue, 'DE')").as("concat") // + .andExpression("strcasecmp(stringValue,'XYZ')").as("strcasecmp") // + .andExpression("substr(stringValue,1,1)").as("substr") // + .andExpression("toLower(stringValue)").as("toLower") // + .andExpression("toUpper(toLower(stringValue))").as("toUpper") // ); AggregationResults results = mongoTemplate.aggregate(agg, DBObject.class); @@ -762,17 +1012,18 @@ public void shouldPerformStringProjectionOperatorsCorrectly() throws ParseExcept data.dateValue = new SimpleDateFormat("dd.MM.yyyy HH:mm:ss.SSSZ").parse("29.08.1983 12:34:56.789+0000"); mongoTemplate.insert(data); - TypedAggregation agg = newAggregation(Data.class, project() // - .andExpression("dayOfYear(dateValue)").as("dayOfYear") // - .andExpression("dayOfMonth(dateValue)").as("dayOfMonth") // - .andExpression("dayOfWeek(dateValue)").as("dayOfWeek") // - .andExpression("year(dateValue)").as("year") // - .andExpression("month(dateValue)").as("month") // - .andExpression("week(dateValue)").as("week") // - .andExpression("hour(dateValue)").as("hour") // - .andExpression("minute(dateValue)").as("minute") // - .andExpression("second(dateValue)").as("second") // - .andExpression("millisecond(dateValue)").as("millisecond") // + TypedAggregation agg = newAggregation(Data.class, + project() // + .andExpression("dayOfYear(dateValue)").as("dayOfYear") // + .andExpression("dayOfMonth(dateValue)").as("dayOfMonth") // + .andExpression("dayOfWeek(dateValue)").as("dayOfWeek") // + .andExpression("year(dateValue)").as("year") // + .andExpression("month(dateValue)").as("month") // + .andExpression("week(dateValue)").as("week") // + .andExpression("hour(dateValue)").as("hour") // + .andExpression("minute(dateValue)").as("minute") // + .andExpression("second(dateValue)").as("second") // + .andExpression("millisecond(dateValue)").as("millisecond") // ); AggregationResults results = mongoTemplate.aggregate(agg, DBObject.class); @@ -884,7 +1135,7 @@ public void shouldAggregateOrderDataToAnInvoice() { .and("orderId").previousOperation() // .andExpression("netAmount * [0]", taxRate).as("taxAmount") // .andExpression("netAmount * (1 + [0])", taxRate).as("totalAmount") // - ), Invoice.class); + ), Invoice.class); Invoice invoice = results.getUniqueMappedResult(); @@ -1460,4 +1711,33 @@ public ObjectWithDate(Date dateValue) { this.dateValue = dateValue; } } + + /** + * @see DATAMONGO-861 + */ + @Document(collection = "inventory") + static class InventoryItem { + + int id; + String item; + String description; + int qty; + + public InventoryItem() {} + + public InventoryItem(int id, String item, int qty) { + + this.id = id; + this.item = item; + this.qty = qty; + } + + public InventoryItem(int id, String item, String description, int qty) { + + this.id = id; + this.item = item; + this.description = description; + this.qty = qty; + } + } } 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 03387417c2..84408bbcb8 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 @@ -19,16 +19,19 @@ import static org.junit.Assert.*; import static org.springframework.data.mongodb.core.DBObjectTestUtils.*; import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; +import static org.springframework.data.mongodb.core.aggregation.Fields.*; import static org.springframework.data.mongodb.core.query.Criteria.*; import static org.springframework.data.mongodb.test.util.IsBsonObject.*; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.mongodb.core.query.Criteria; import com.mongodb.BasicDBObject; import com.mongodb.BasicDBObjectBuilder; @@ -308,15 +311,16 @@ public void shouldRenderAggregationWithCustomOptionsCorrectly() { DBObject agg = newAggregation( // project().and("a").as("aa") // ) // - .withOptions(aggregationOptions) // + .withOptions(aggregationOptions) // .toDbObject("foo", Aggregation.DEFAULT_CONTEXT); - assertThat(agg.toString(), is("{ \"aggregate\" : \"foo\" , " // - + "\"pipeline\" : [ { \"$project\" : { \"aa\" : \"$a\"}}] , " // - + "\"allowDiskUse\" : true , " // - + "\"explain\" : true , " // - + "\"cursor\" : { \"foo\" : 1}}" // - )); + assertThat(agg.toString(), + is("{ \"aggregate\" : \"foo\" , " // + + "\"pipeline\" : [ { \"$project\" : { \"aa\" : \"$a\"}}] , " // + + "\"allowDiskUse\" : true , " // + + "\"explain\" : true , " // + + "\"cursor\" : { \"foo\" : 1}}" // + )); } /** @@ -334,8 +338,8 @@ public void shouldSupportReferencingSystemVariables() { ).toDbObject("foo", Aggregation.DEFAULT_CONTEXT); DBObject projection0 = extractPipelineElement(agg, 0, "$project"); - assertThat(projection0, is((DBObject) new BasicDBObject("someKey", 1).append("a1", "$a") - .append("a2", "$$CURRENT.a"))); + assertThat(projection0, + is((DBObject) new BasicDBObject("someKey", 1).append("a1", "$a").append("a2", "$$CURRENT.a"))); DBObject sort = extractPipelineElement(agg, 1, "$sort"); assertThat(sort, is((DBObject) new BasicDBObject("a", -1))); @@ -356,7 +360,7 @@ public void shouldExposeAliasedFieldnameForProjectionsIncludingOperationsDownThe .and("tags").minus(10).as("tags_count")// , group("date")// .sum("tags_count").as("count")// - ).toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + ).toDbObject("foo", Aggregation.DEFAULT_CONTEXT); DBObject group = extractPipelineElement(agg, 1, "$group"); assertThat(getAsDBObject(group, "count"), is(new BasicDBObjectBuilder().add("$sum", "$tags_count").get())); @@ -373,12 +377,179 @@ public void shouldUseAliasedFieldnameForProjectionsIncludingOperationsDownThePip .andExpression("tags-10")// , group("date")// .sum("tags_count").as("count")// - ).toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + ).toDbObject("foo", Aggregation.DEFAULT_CONTEXT); DBObject group = extractPipelineElement(agg, 1, "$group"); assertThat(getAsDBObject(group, "count"), is(new BasicDBObjectBuilder().add("$sum", "$tags_count").get())); } + /** + * @see DATAMONGO-861 + */ + @Test + public void conditionExpressionBasedFieldsShouldBeReferencableInFollowingOperations() { + + DBObject agg = newAggregation( // + project("a"), // + group("a").first(conditional(Criteria.where("a").gte(42), "answer", "no-answer")).as("foosum") // + ).toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + + @SuppressWarnings("unchecked") + DBObject secondProjection = ((List) agg.get("pipeline")).get(1); + DBObject fields = getAsDBObject(secondProjection, "$group"); + assertThat(getAsDBObject(fields, "foosum"), isBsonObject().containing("$first")); + assertThat(getAsDBObject(fields, "foosum"), isBsonObject().containing("$first.$cond.then", "answer")); + assertThat(getAsDBObject(fields, "foosum"), isBsonObject().containing("$first.$cond.else", "no-answer")); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void shouldRenderProjectionConditionalExpressionCorrectly() { + + DBObject agg = Aggregation.newAggregation(// + project().and(ConditionalOperator.newBuilder() // + .when("isYellow") // + .then("bright") // + .otherwise("dark")).as("color")) + .toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + + DBObject project = extractPipelineElement(agg, 0, "$project"); + DBObject expectedCondition = new BasicDBObject() // + .append("if", "$isYellow") // + .append("then", "bright") // + .append("else", "dark"); + + assertThat(getAsDBObject(project, "color"), isBsonObject().containing("$cond", expectedCondition)); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void shouldRenderProjectionConditionalCorrectly() { + + DBObject agg = Aggregation.newAggregation(// + project().and("color") + .applyCondition(ConditionalOperator.newBuilder() // + .when("isYellow") // + .then("bright") // + .otherwise("dark"))) + .toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + + DBObject project = extractPipelineElement(agg, 0, "$project"); + DBObject expectedCondition = new BasicDBObject() // + .append("if", "$isYellow") // + .append("then", "bright") // + .append("else", "dark"); + + assertThat(getAsDBObject(project, "color"), isBsonObject().containing("$cond", expectedCondition)); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void shouldRenderProjectionConditionalWithCriteriaCorrectly() { + + DBObject agg = Aggregation + .newAggregation(project()// + .and("color")// + .applyCondition(conditional(Criteria.where("key").gt(5), "bright", "dark"))) // + .toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + + DBObject project = extractPipelineElement(agg, 0, "$project"); + DBObject expectedCondition = new BasicDBObject() // + .append("if", new BasicDBObject("$gt", Arrays. asList("$key", 5))) // + .append("then", "bright") // + .append("else", "dark"); + + assertThat(getAsDBObject(project, "color"), isBsonObject().containing("$cond", expectedCondition)); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void referencingProjectionAliasesShouldRenderProjectionConditionalWithFieldReferenceCorrectly() { + + DBObject agg = Aggregation + .newAggregation(// + project().and("color").as("chroma"), + project().and("luminosity") // + .applyCondition(conditional(field("chroma"), "bright", "dark"))) // + .toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + + DBObject project = extractPipelineElement(agg, 1, "$project"); + DBObject expectedCondition = new BasicDBObject() // + .append("if", "$chroma") // + .append("then", "bright") // + .append("else", "dark"); + + assertThat(getAsDBObject(project, "luminosity"), isBsonObject().containing("$cond", expectedCondition)); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void referencingProjectionAliasesShouldRenderProjectionConditionalWithCriteriaReferenceCorrectly() { + + DBObject agg = Aggregation + .newAggregation(// + project().and("color").as("chroma"), + project().and("luminosity") // + .applyCondition(conditional(Criteria.where("chroma").is(100), "bright", "dark"))) // + .toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + + DBObject project = extractPipelineElement(agg, 1, "$project"); + DBObject expectedCondition = new BasicDBObject() // + .append("if", new BasicDBObject("$eq", Arrays. asList("$chroma", 100))) // + .append("then", "bright") // + .append("else", "dark"); + + assertThat(getAsDBObject(project, "luminosity"), isBsonObject().containing("$cond", expectedCondition)); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void shouldRenderProjectionIfNullWithFieldReferenceCorrectly() { + + DBObject agg = Aggregation + .newAggregation(// + project().and("color"), // + project().and("luminosity") // + .applyCondition(ifNull(field("chroma"), "unknown"))) // + .toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + + DBObject project = extractPipelineElement(agg, 1, "$project"); + + assertThat(getAsDBObject(project, "luminosity"), + isBsonObject().containing("$ifNull", Arrays. asList("$chroma", "unknown"))); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void shouldRenderProjectionIfNullWithFallbackFieldReferenceCorrectly() { + + DBObject agg = Aggregation + .newAggregation(// + project("fallback").and("color").as("chroma"), + project().and("luminosity") // + .applyCondition(ifNull(field("chroma"), field("fallback")))) // + .toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + + DBObject project = extractPipelineElement(agg, 1, "$project"); + + assertThat(getAsDBObject(project, "luminosity"), + isBsonObject().containing("$ifNull", Arrays.asList("$chroma", "$fallback"))); + } + 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/ConditionalOperatorUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/ConditionalOperatorUnitTests.java new file mode 100644 index 0000000000..475559a0a1 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/ConditionalOperatorUnitTests.java @@ -0,0 +1,210 @@ +/* + * 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.junit.Assert.*; +import static org.springframework.data.mongodb.core.aggregation.ConditionalOperator.*; +import static org.springframework.data.mongodb.test.util.IsBsonObject.*; + +import java.util.Arrays; + +import org.junit.Test; +import org.springframework.data.mongodb.core.query.Criteria; + +import com.mongodb.BasicDBObject; +import com.mongodb.DBObject; + +/** + * Unit tests for {@link ConditionalOperator}. + * + * @author Mark Paluch + * @author Christoph Strobl + */ +public class ConditionalOperatorUnitTests { + + /** + * @see DATAMONGO-861 + */ + @Test(expected = IllegalArgumentException.class) + public void shouldRejectNullCondition() { + new ConditionalOperator((Field) null, "", ""); + } + + /** + * @see DATAMONGO-861 + */ + @Test(expected = IllegalArgumentException.class) + public void shouldRejectThenValue() { + new ConditionalOperator(Fields.field("field"), null, ""); + } + + /** + * @see DATAMONGO-861 + */ + @Test(expected = IllegalArgumentException.class) + public void shouldRejectOtherwiseValue() { + new ConditionalOperator(Fields.field("field"), "", null); + } + + /** + * @see DATAMONGO-861 + */ + @Test(expected = IllegalArgumentException.class) + public void builderRejectsEmptyFieldName() { + newBuilder().when(""); + } + + /** + * @see DATAMONGO-861 + */ + @Test(expected = IllegalArgumentException.class) + public void builderRejectsNullFieldName() { + newBuilder().when((DBObject) null); + } + + /** + * @see DATAMONGO-861 + */ + @Test(expected = IllegalArgumentException.class) + public void builderRejectsNullCriteriaName() { + newBuilder().when((Criteria) null); + } + + /** + * @see DATAMONGO-861 + */ + @Test(expected = IllegalArgumentException.class) + public void builderRejectsBuilderAsThenValue() { + newBuilder().when("isYellow").then(newBuilder().when("field").then("then-value")).otherwise("otherwise"); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void simpleBuilderShouldRenderCorrectly() { + + ConditionalOperator operator = newBuilder().when("isYellow").then("bright").otherwise("dark"); + DBObject dbObject = operator.toDbObject(Aggregation.DEFAULT_CONTEXT); + + DBObject expectedCondition = new BasicDBObject() // + .append("if", "$isYellow") // + .append("then", "bright") // + .append("else", "dark"); + + assertThat(dbObject, isBsonObject().containing("$cond", expectedCondition)); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void simpleCriteriaShouldRenderCorrectly() { + + ConditionalOperator operator = newBuilder().when(Criteria.where("luminosity").gte(100)).then("bright") + .otherwise("dark"); + DBObject dbObject = operator.toDbObject(Aggregation.DEFAULT_CONTEXT); + + DBObject expectedCondition = new BasicDBObject() // + .append("if", new BasicDBObject("$gte", Arrays. asList("$luminosity", 100))) // + .append("then", "bright") // + .append("else", "dark"); + + assertThat(dbObject, isBsonObject().containing("$cond", expectedCondition)); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void andCriteriaShouldRenderCorrectly() { + + ConditionalOperator operator = newBuilder() // + .when(Criteria.where("luminosity").gte(100) // + .andOperator(Criteria.where("hue").is(50), // + Criteria.where("saturation").lt(11))) + .then("bright").otherwise("dark"); + + DBObject dbObject = operator.toDbObject(Aggregation.DEFAULT_CONTEXT); + + BasicDBObject luminosity = new BasicDBObject("$gte", Arrays. asList("$luminosity", 100)); + BasicDBObject hue = new BasicDBObject("$eq", Arrays. asList("$hue", 50)); + BasicDBObject saturation = new BasicDBObject("$lt", Arrays. asList("$saturation", 11)); + + DBObject expectedCondition = new BasicDBObject() // + .append("if", Arrays. asList(luminosity, new BasicDBObject("$and", Arrays.asList(hue, saturation)))) // + .append("then", "bright") // + .append("else", "dark"); + + assertThat(dbObject, isBsonObject().containing("$cond", expectedCondition)); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void twoArgsCriteriaShouldRenderCorrectly() { + + Criteria criteria = Criteria.where("luminosity").gte(100) // + .and("saturation").and("chroma").is(200); + ConditionalOperator operator = newBuilder().when(criteria).then("bright").otherwise("dark"); + + DBObject dbObject = operator.toDbObject(Aggregation.DEFAULT_CONTEXT); + + BasicDBObject gte = new BasicDBObject("$gte", Arrays. asList("$luminosity", 100)); + BasicDBObject is = new BasicDBObject("$eq", Arrays. asList("$chroma", 200)); + + DBObject expectedCondition = new BasicDBObject() // + .append("if", Arrays.asList(gte, is)) // + .append("then", "bright") // + .append("else", "dark"); + + assertThat(dbObject, isBsonObject().containing("$cond", expectedCondition)); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void nestedCriteriaShouldRenderCorrectly() { + + ConditionalOperator operator = newBuilder() // + .when(Criteria.where("luminosity").gte(100)) // + .then(newBuilder() // + .when(Criteria.where("luminosity").gte(200)) // + .then("verybright") // + .otherwise("not-so-bright")) // + .otherwise(newBuilder() // + .when(Criteria.where("luminosity").lt(50)) // + .then("very-dark") // + .otherwise("not-so-dark")); + + DBObject dbObject = operator.toDbObject(Aggregation.DEFAULT_CONTEXT); + + DBObject trueCondition = new BasicDBObject() // + .append("if", new BasicDBObject("$gte", Arrays. asList("$luminosity", 200))) // + .append("then", "verybright") // + .append("else", "not-so-bright"); + + DBObject falseCondition = new BasicDBObject() // + .append("if", new BasicDBObject("$lt", Arrays. asList("$luminosity", 50))) // + .append("then", "very-dark") // + .append("else", "not-so-dark"); + + assertThat(dbObject, isBsonObject().containing("$cond.then.$cond", trueCondition)); + assertThat(dbObject, isBsonObject().containing("$cond.else.$cond", falseCondition)); + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/IfNullOperatorUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/IfNullOperatorUnitTests.java new file mode 100644 index 0000000000..a7809644cb --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/IfNullOperatorUnitTests.java @@ -0,0 +1,82 @@ +/* + * 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.junit.Assert.*; +import static org.springframework.data.mongodb.test.util.IsBsonObject.*; + +import java.util.Arrays; + +import org.junit.Test; + +import com.mongodb.DBObject; + +/** + * Unit tests for {@link IfNullOperator}. + * + * @author Mark Paluch + * @author Christoph Strobl + */ +public class IfNullOperatorUnitTests { + + /** + * @see DATAMONGO-861 + */ + @Test(expected = IllegalArgumentException.class) + public void shouldRejectNullCondition() { + new IfNullOperator(null, ""); + } + + /** + * @see DATAMONGO-861 + */ + @Test(expected = IllegalArgumentException.class) + public void shouldRejectThenValue() { + new IfNullOperator(Fields.field("aa"), null); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void simpleIfNullShouldRenderCorrectly() { + + IfNullOperator operator = IfNullOperator.newBuilder() // + .ifNull("optional") // + .thenReplaceWith("a more sophisticated value"); + + DBObject dbObject = operator.toDbObject(Aggregation.DEFAULT_CONTEXT); + + assertThat(dbObject, + isBsonObject().containing("$ifNull", Arrays. asList("$optional", "a more sophisticated value"))); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void fieldReplacementIfNullShouldRenderCorrectly() { + + IfNullOperator operator = IfNullOperator.newBuilder() // + .ifNull(Fields.field("optional")) // + .thenReplaceWith(Fields.field("never-null")); + + DBObject dbObject = operator.toDbObject(Aggregation.DEFAULT_CONTEXT); + + assertThat(dbObject, isBsonObject().containing("$ifNull", Arrays. asList("$optional", "$never-null"))); + } +} 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 eece18bca2..b1371c00c3 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 @@ -18,6 +18,8 @@ import static org.hamcrest.CoreMatchers.*; import static org.junit.Assert.*; import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; +import static org.springframework.data.mongodb.core.aggregation.Fields.*; +import static org.springframework.data.mongodb.test.util.IsBsonObject.*; import java.util.Arrays; import java.util.List; @@ -89,7 +91,7 @@ public void returnsReferencesToNestedFieldsCorrectly() { AggregationOperationContext context = getContext(Foo.class); - Field field = Fields.field("bar.name"); + Field field = field("bar.name"); assertThat(context.getReference("bar.name"), is(notNullValue())); assertThat(context.getReference(field), is(notNullValue())); @@ -103,7 +105,7 @@ public void returnsReferencesToNestedFieldsCorrectly() { public void aliasesIdFieldCorrectly() { AggregationOperationContext context = getContext(Foo.class); - assertThat(context.getReference("id"), is(new FieldReference(new ExposedField(Fields.field("id", "_id"), true)))); + assertThat(context.getReference("id"), is(new FieldReference(new ExposedField(field("id", "_id"), true)))); } /** @@ -279,6 +281,58 @@ public void lookupGroupAggregationShouldFailInvalidFieldReference() { agg.toDbObject("meterData", context); } + /** + * @see DATAMONGO-861 + */ + @Test + public void rendersAggregationConditionalInTypedAggregationContextCorrectly() { + + AggregationOperationContext context = getContext(FooPerson.class); + TypedAggregation agg = newAggregation(FooPerson.class, + project("name") // + .and("age") // + .applyCondition(conditional(Criteria.where("age.value").lt(10), new Age(0), field("age"))) // + ); + + DBObject dbo = agg.toDbObject("person", context); + + DBObject projection = getPipelineElementFromAggregationAt(dbo, 0); + assertThat(projection.containsField("$project"), is(true)); + + DBObject project = getValue(projection, "$project"); + DBObject age = getValue(project, "age"); + + assertThat((DBObject) getValue(age, "$cond"), isBsonObject().containing("then.value", 0)); + assertThat((DBObject) getValue(age, "$cond"), isBsonObject().containing("then._class", Age.class.getName())); + assertThat((DBObject) getValue(age, "$cond"), isBsonObject().containing("else", "$age")); + } + + /** + * @see DATAMONGO-861 + */ + @Test + public void rendersAggregationIfNullInTypedAggregationContextCorrectly() { + + AggregationOperationContext context = getContext(FooPerson.class); + TypedAggregation agg = newAggregation(FooPerson.class, + project("name") // + .and("age") // + .applyCondition(ifNull("age", new Age(0))) // + ); + + DBObject dbo = agg.toDbObject("person", context); + + DBObject projection = getPipelineElementFromAggregationAt(dbo, 0); + assertThat(projection.containsField("$project"), is(true)); + + DBObject project = getValue(projection, "$project"); + DBObject age = getValue(project, "age"); + + assertThat(age, isBsonObject().containing("$ifNull.[0]", "$age")); + assertThat(age, isBsonObject().containing("$ifNull.[1].value", 0)); + assertThat(age, isBsonObject().containing("$ifNull.[1]._class", Age.class.getName())); + } + @Document(collection = "person") public static class FooPerson { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/IsBsonObject.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/IsBsonObject.java index d42cec2845..9857ad379b 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/IsBsonObject.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/IsBsonObject.java @@ -83,7 +83,15 @@ protected boolean matchesSafely(T item) { } if (expectation.type != null && !ClassUtils.isAssignable(expectation.type, o.getClass())) { - return false; + + if (o instanceof List) { + if (!ClassUtils.isAssignable(List.class, expectation.type)) { + return false; + } + } else { + return false; + } + } if (expectation.value != null && !new IsEqual(expectation.value).matches(o)) { diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index 032b8df74b..8066c6a086 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -4,6 +4,7 @@ [[new-features.1-10-0]] == What's new in Spring Data MongoDB 1.10 * Support for `$min`, `$max` and `$slice` operators via `Update`. +* Support for `$cond` and `$ifNull` operators via `Aggregation`. [[new-features.1-9-0]] == What's new in Spring Data MongoDB 1.9 diff --git a/src/main/asciidoc/reference/mongodb.adoc b/src/main/asciidoc/reference/mongodb.adoc index 42a4075bf4..c8b1553e41 100644 --- a/src/main/asciidoc/reference/mongodb.adoc +++ b/src/main/asciidoc/reference/mongodb.adoc @@ -1688,6 +1688,9 @@ At the time of this writing we provide support for the following Aggregation Ope | Array Aggregation Operators | size, slice +| Conditional Aggregation Operators +| cond, ifNull + |=== Note that the aggregation operations not listed here are currently not supported by Spring Data MongoDB. Comparison aggregation operators are expressed as `Criteria` expressions. @@ -1985,6 +1988,50 @@ List resultList = result.getMappedResults(); Note that we can also refer to other fields of the document within the SpEL expression. +[[mongo.aggregation.examples.example7]] +.Aggregation Framework Example 7 + +This example uses conditional projection. It's derived from the https://docs.mongodb.com/manual/reference/operator/aggregation/cond/[$cond reference documentation]. + +[source,java] +---- +public class InventoryItem { + + @Id int id; + String item; + String description; + int qty; +} + +public class InventoryItemProjection { + + @Id int id; + String item; + String description; + int qty; + int discount +} +---- + +[source,java] +---- +import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; + +TypedAggregation agg = newAggregation(InventoryItem.class, + project("item").and("discount") + .applyCondition(ConditionalOperator.newBuilder().when(Criteria.where("qty").gte(250)) + .then(30) + .otherwise(20)) + .and(ifNull("description", "Unspecified")).as("description") +); + +AggregationResults result = mongoTemplate.aggregate(agg, "inventory", InventoryItemProjection.class); +List stateStatsList = result.getMappedResults(); +---- + +* This one-step aggregation uses a projection operation with the `inventory` collection. We project the `discount` field using a conditional operation for all inventory items that have a `qty` greater or equal to `250`. An second conditional projection is performed for the `description` field. We apply the description `Unspecified` to all items that either do not have a `description` field of items that have a `null` description. + + [[mongo.custom-converters]] == Overriding default mapping with custom converters