From ef114d7dfa62f03c72629f4f9011d4925e9799fb Mon Sep 17 00:00:00 2001 From: alessiofachechi Date: Sun, 21 Feb 2016 01:58:49 +0100 Subject: [PATCH] DATAMONGO-1326 - Add support for $lookup to aggregation. --- ...nalFieldsExposingAggregationOperation.java | 27 ++++++ .../mongodb/core/aggregation/Aggregation.java | 84 ++++++++++++------- ...osedFieldsAggregationOperationContext.java | 38 ++++++++- .../core/aggregation/LookupOperation.java | 76 +++++++++++++++++ .../aggregation/LookupOperationUnitTests.java | 58 +++++++++++++ 5 files changed, 249 insertions(+), 34 deletions(-) create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AdditionalFieldsExposingAggregationOperation.java create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/LookupOperation.java create mode 100644 spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/LookupOperationUnitTests.java diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AdditionalFieldsExposingAggregationOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AdditionalFieldsExposingAggregationOperation.java new file mode 100644 index 0000000000..52b851f534 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AdditionalFieldsExposingAggregationOperation.java @@ -0,0 +1,27 @@ +/* + * Copyright 2013 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; + +/** + * {@link AggregationOperation} that exposes additional {@link ExposedFields} that can be used for later + * aggregation pipeline {@code AggregationOperation}s, e.g. lookup operation produces a field which has to be added to + * the current ones. + * + * @author Alessio Fachechi + */ +public interface AdditionalFieldsExposingAggregationOperation extends FieldsExposingAggregationOperation { + +} 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 a57db53034..f6f42b6ff2 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 @@ -37,10 +37,11 @@ /** * An {@code Aggregation} is a representation of a list of aggregation steps to be performed by the MongoDB Aggregation * Framework. - * + * * @author Tobias Trelle * @author Thomas Darimont * @author Oliver Gierke + * @author Alessio Fachechi * @since 1.3 */ public class Aggregation { @@ -65,7 +66,7 @@ public class Aggregation { /** * Creates a new {@link Aggregation} from the given {@link AggregationOperation}s. - * + * * @param operations must not be {@literal null} or empty. */ public static Aggregation newAggregation(List operations) { @@ -74,7 +75,7 @@ public static Aggregation newAggregation(List op /** * Creates a new {@link Aggregation} from the given {@link AggregationOperation}s. - * + * * @param operations must not be {@literal null} or empty. */ public static Aggregation newAggregation(AggregationOperation... operations) { @@ -84,7 +85,7 @@ public static Aggregation newAggregation(AggregationOperation... operations) { /** * Returns a copy of this {@link Aggregation} with the given {@link AggregationOptions} set. Note that options are * supported in MongoDB version 2.6+. - * + * * @param options must not be {@literal null}. * @return * @since 1.6 @@ -97,7 +98,7 @@ public Aggregation withOptions(AggregationOptions options) { /** * Creates a new {@link TypedAggregation} for the given type and {@link AggregationOperation}s. - * + * * @param type must not be {@literal null}. * @param operations must not be {@literal null} or empty. */ @@ -107,7 +108,7 @@ public static TypedAggregation newAggregation(Class type, List TypedAggregation newAggregation(Class type, AggregationO /** * Creates a new {@link Aggregation} from the given {@link AggregationOperation}s. - * + * * @param aggregationOperations must not be {@literal null} or empty. */ protected Aggregation(AggregationOperation... aggregationOperations) { @@ -137,7 +138,7 @@ protected static List asAggregationList(AggregationOperati /** * Creates a new {@link Aggregation} from the given {@link AggregationOperation}s. - * + * * @param aggregationOperations must not be {@literal null} or empty. */ protected Aggregation(List aggregationOperations) { @@ -146,7 +147,7 @@ protected Aggregation(List aggregationOperations) { /** * Creates a new {@link Aggregation} from the given {@link AggregationOperation}s. - * + * * @param aggregationOperations must not be {@literal null} or empty. * @param options must not be {@literal null} or empty. */ @@ -162,7 +163,7 @@ protected Aggregation(List aggregationOperations, Aggregat /** * A pointer to the previous {@link AggregationOperation}. - * + * * @return */ public static String previousOperation() { @@ -171,7 +172,7 @@ public static String previousOperation() { /** * Creates a new {@link ProjectionOperation} including the given fields. - * + * * @param fields must not be {@literal null}. * @return */ @@ -181,7 +182,7 @@ public static ProjectionOperation project(String... fields) { /** * Creates a new {@link ProjectionOperation} includeing the given {@link Fields}. - * + * * @param fields must not be {@literal null}. * @return */ @@ -191,7 +192,7 @@ public static ProjectionOperation project(Fields fields) { /** * Factory method to create a new {@link UnwindOperation} for the field with the given name. - * + * * @param fieldName must not be {@literal null} or empty. * @return */ @@ -201,7 +202,7 @@ public static UnwindOperation unwind(String field) { /** * Creates a new {@link GroupOperation} for the given fields. - * + * * @param fields must not be {@literal null}. * @return */ @@ -211,7 +212,7 @@ public static GroupOperation group(String... fields) { /** * Creates a new {@link GroupOperation} for the given {@link Fields}. - * + * * @param fields must not be {@literal null}. * @return */ @@ -221,7 +222,7 @@ public static GroupOperation group(Fields fields) { /** * Factory method to create a new {@link SortOperation} for the given {@link Sort}. - * + * * @param sort must not be {@literal null}. * @return */ @@ -231,7 +232,7 @@ public static SortOperation sort(Sort sort) { /** * Factory method to create a new {@link SortOperation} for the given sort {@link Direction} and {@code fields}. - * + * * @param direction must not be {@literal null}. * @param fields must not be {@literal null}. * @return @@ -242,7 +243,7 @@ public static SortOperation sort(Direction direction, String... fields) { /** * Creates a new {@link SkipOperation} skipping the given number of elements. - * + * * @param elementsToSkip must not be less than zero. * @return */ @@ -252,7 +253,7 @@ public static SkipOperation skip(int elementsToSkip) { /** * Creates a new {@link LimitOperation} limiting the result to the given number of elements. - * + * * @param maxElements must not be less than zero. * @return */ @@ -262,7 +263,7 @@ public static LimitOperation limit(long maxElements) { /** * Creates a new {@link MatchOperation} using the given {@link Criteria}. - * + * * @param criteria must not be {@literal null}. * @return */ @@ -270,12 +271,32 @@ public static MatchOperation match(Criteria criteria) { return new MatchOperation(criteria); } + /** + * Creates a new {@link LookupOperation} for the given fields. + * + * @param fields must not be {@literal null}. + * @return + */ + public static LookupOperation lookup(String from, String localField, String foreignField, String as) { + return lookup(field(from), field(localField), field(foreignField), field(as)); + } + + /** + * Creates a new {@link LookupOperation} for the given {@link Fields}. + * + * @param fields must not be {@literal null}. + * @return + */ + public static LookupOperation lookup(Field from, Field localField, Field foreignField, Field as) { + return new LookupOperation(from, localField, foreignField, as); + } + /** * Creates a new {@link Fields} instance for the given field names. - * - * @see Fields#fields(String...) + * * @param fields must not be {@literal null}. * @return + * @see Fields#fields(String...) */ public static Fields fields(String... fields) { return Fields.fields(fields); @@ -283,7 +304,7 @@ public static Fields fields(String... fields) { /** * Creates a new {@link Fields} instance from the given field name and target reference. - * + * * @param name must not be {@literal null} or empty. * @param target must not be {@literal null} or empty. * @return @@ -295,7 +316,7 @@ public static Fields bind(String name, String target) { /** * Creates a new {@link GeoNearOperation} instance from the given {@link NearQuery} and the{@code distanceField}. The * {@code distanceField} defines output field that contains the calculated distance. - * + * * @param query must not be {@literal null}. * @param distanceField must not be {@literal null} or empty. * @return @@ -307,7 +328,7 @@ public static GeoNearOperation geoNear(NearQuery query, String distanceField) { /** * Returns a new {@link AggregationOptions.Builder}. - * + * * @return * @since 1.6 */ @@ -317,7 +338,7 @@ public static AggregationOptions.Builder newAggregationOptions() { /** * Converts this {@link Aggregation} specification to a {@link DBObject}. - * + * * @param inputCollectionName the name of the input collection * @return the {@code DBObject} representing this aggregation */ @@ -331,8 +352,11 @@ public DBObject toDbObject(String inputCollectionName, AggregationOperationConte operationDocuments.add(operation.toDBObject(context)); if (operation instanceof FieldsExposingAggregationOperation) { + boolean additional = operation instanceof AdditionalFieldsExposingAggregationOperation ? true : false; + FieldsExposingAggregationOperation exposedFieldsOperation = (FieldsExposingAggregationOperation) operation; - context = new ExposedFieldsAggregationOperationContext(exposedFieldsOperation.getFields(), rootContext); + context = new ExposedFieldsAggregationOperationContext(exposedFieldsOperation.getFields(), rootContext, + additional); } } @@ -356,7 +380,7 @@ public String toString() { /** * Simple {@link AggregationOperationContext} that just returns {@link FieldReference}s as is. - * + * * @author Oliver Gierke */ private static class NoOpAggregationOperationContext implements AggregationOperationContext { @@ -391,7 +415,7 @@ public FieldReference getReference(String name) { /** * Describes the system variables available in MongoDB aggregation framework pipeline expressions. - * + * * @author Thomas Darimont * @see http://docs.mongodb.org/manual/reference/aggregation-variables */ @@ -404,7 +428,7 @@ enum SystemVariable { /** * Return {@literal true} if the given {@code fieldRef} denotes a well-known system variable, {@literal false} * otherwise. - * + * * @param fieldRef may be {@literal null}. * @return */ diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ExposedFieldsAggregationOperationContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ExposedFieldsAggregationOperationContext.java index 0e3a05d3ce..3e23db9272 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ExposedFieldsAggregationOperationContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ExposedFieldsAggregationOperationContext.java @@ -17,37 +17,57 @@ 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.util.Assert; +import org.springframework.util.StringUtils; import com.mongodb.DBObject; /** * {@link AggregationOperationContext} that combines the available field references from a given * {@code AggregationOperationContext} and an {@link FieldsExposingAggregationOperation}. - * + * * @author Thomas Darimont * @author Oliver Gierke + * @author Alessio Fachechi * @since 1.4 */ class ExposedFieldsAggregationOperationContext implements AggregationOperationContext { private final ExposedFields exposedFields; private final AggregationOperationContext rootContext; + private final boolean additional; + + /** + * Creates a new {@link ExposedFieldsAggregationOperationContext} from the given {@link ExposedFields}. Uses the given + * {@link AggregationOperationContext} to perform a mapping to mongo types if necessary. + * + * @param exposedFields must not be {@literal null}. + * @param rootContext must not be {@literal null}. + */ + public ExposedFieldsAggregationOperationContext(ExposedFields exposedFields, + AggregationOperationContext rootContext) { + this(exposedFields, rootContext, false); + } /** * Creates a new {@link ExposedFieldsAggregationOperationContext} from the given {@link ExposedFields}. Uses the given * {@link AggregationOperationContext} to perform a mapping to mongo types if necessary. - * + * * @param exposedFields must not be {@literal null}. * @param rootContext must not be {@literal null}. + * @param additional {@literal true} if the context exposes new fields in addition to the previous ones, e.g. in the + * case of a lookup operation, {@literal false} otherwise. */ - public ExposedFieldsAggregationOperationContext(ExposedFields exposedFields, AggregationOperationContext rootContext) { + public ExposedFieldsAggregationOperationContext(ExposedFields exposedFields, AggregationOperationContext rootContext, + boolean additional) { Assert.notNull(exposedFields, "ExposedFields must not be null!"); Assert.notNull(rootContext, "RootContext must not be null!"); this.exposedFields = exposedFields; this.rootContext = rootContext; + this.additional = additional; } /* @@ -79,7 +99,7 @@ public FieldReference getReference(String name) { /** * Returns a {@link FieldReference} to the given {@link Field} with the given {@code name}. - * + * * @param field may be {@literal null} * @param name must not be {@literal null} * @return @@ -112,6 +132,16 @@ private FieldReference getReference(Field field, String name) { } } + if (additional) { + + // if no exposed fields found propagate to root context. + if (field != null) { + return rootContext.getReference(field); + } else if (StringUtils.hasText(name)) { + return rootContext.getReference(name); + } + } + throw new IllegalArgumentException(String.format("Invalid reference '%s'!", name)); } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/LookupOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/LookupOperation.java new file mode 100644 index 0000000000..141dff0c80 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/LookupOperation.java @@ -0,0 +1,76 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.core.aggregation; + +import org.springframework.data.mongodb.core.aggregation.ExposedFields.ExposedField; +import org.springframework.util.Assert; + +import com.mongodb.BasicDBObject; +import com.mongodb.DBObject; + +/** + * Encapsulates the aggregation framework {@code $lookup}-operation. + * We recommend to use the static factory method {@link Aggregation#lookup(String, String, String, String)} instead of + * creating instances of this class directly. + * + * @author Alessio Fachechi + * @see http://docs.mongodb.org/manual/reference/aggregation/lookup/#stage._S_lookup + * @since 1.9 + */ +public class LookupOperation implements AdditionalFieldsExposingAggregationOperation { + + private ExposedField from; + private ExposedField localField; + private ExposedField foreignField; + private ExposedField as; + + /** + * Creates a new {@link LookupOperation} for the given {@link Field}s. + * + * @param from must not be {@literal null}. + * @param localField must not be {@literal null}. + * @param foreignField must not be {@literal null}. + * @param as must not be {@literal null}. + */ + public LookupOperation(Field from, Field localField, Field foreignField, Field as) { + Assert.notNull(from, "From must not be null!"); + Assert.notNull(localField, "LocalField must not be null!"); + Assert.notNull(foreignField, "ForeignField must not be null!"); + Assert.notNull(as, "As must not be null!"); + + this.from = new ExposedField(from, true); + this.localField = new ExposedField(localField, true); + this.foreignField = new ExposedField(foreignField, true); + this.as = new ExposedField(as, true); + } + + @Override + public ExposedFields getFields() { + return ExposedFields.from(as); + } + + @Override + public DBObject toDBObject(AggregationOperationContext context) { + BasicDBObject lookupObject = new BasicDBObject(); + + lookupObject.append("from", from.getTarget()); + lookupObject.append("localField", localField.getTarget()); + lookupObject.append("foreignField", foreignField.getTarget()); + lookupObject.append("as", as.getTarget()); + + return new BasicDBObject("$lookup", lookupObject); + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/LookupOperationUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/LookupOperationUnitTests.java new file mode 100644 index 0000000000..1a9bc175ca --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/LookupOperationUnitTests.java @@ -0,0 +1,58 @@ +/* + * 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.CoreMatchers.*; +import static org.junit.Assert.*; + +import com.mongodb.DBObject; +import org.junit.Test; +import org.springframework.data.mongodb.core.DBObjectTestUtils; + +/** + * Unit tests for {@link LookupOperation}. + * + * @author Alessio Fachechi + */ +public class LookupOperationUnitTests { + + @Test(expected = IllegalArgumentException.class) + public void rejectsNullFields() { + new LookupOperation((Field) null, (Field) null, (Field) null, (Field) null); + } + + @Test + public void lookupOperationWithValues() { + + LookupOperation lookupOperation = Aggregation.lookup("a", "b", "c", "d"); + + DBObject lookupClause = extractDbObjectFromLookupOperation(lookupOperation); + + assertThat((String) lookupClause.get("from"), is(new String("a"))); + assertThat((String) lookupClause.get("localField"), is(new String("b"))); + assertThat((String) lookupClause.get("foreignField"), is(new String("c"))); + assertThat((String) lookupClause.get("as"), is(new String("d"))); + + assertThat(lookupOperation.getFields().exposesNoFields(), is(false)); + assertThat(lookupOperation.getFields().exposesSingleFieldOnly(), is(true)); + } + + private DBObject extractDbObjectFromLookupOperation(LookupOperation lookupOperation) { + DBObject dbObject = lookupOperation.toDBObject(Aggregation.DEFAULT_CONTEXT); + DBObject lookupClause = DBObjectTestUtils.getAsDBObject(dbObject, "$lookup"); + return lookupClause; + } +}