diff --git a/pom.xml b/pom.xml index ea80a3cb74..325d02daae 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-1551-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..96198b7c4f 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-1551-SNAPSHOT ../pom.xml @@ -48,7 +48,7 @@ org.springframework.data spring-data-mongodb - 1.10.0.BUILD-SNAPSHOT + 1.10.0.DATAMONGO-1551-SNAPSHOT diff --git a/spring-data-mongodb-distribution/pom.xml b/spring-data-mongodb-distribution/pom.xml index 2d02722262..1f841007b9 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-1551-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb-log4j/pom.xml b/spring-data-mongodb-log4j/pom.xml index ee5e3336db..bfba91dd83 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-1551-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index 8072d3f665..84a0dcdde8 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-1551-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 ff9ec46d14..ede7ff3600 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 @@ -27,6 +27,7 @@ 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.FieldsExposingAggregationOperation.InheritsFieldsAggregationOperation; +import org.springframework.data.mongodb.core.aggregation.GraphLookupOperation.StartWithBuilder; import org.springframework.data.mongodb.core.aggregation.Fields.*; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.CriteriaDefinition; @@ -281,6 +282,17 @@ public static GroupOperation group(Fields fields) { return new GroupOperation(fields); } + /** + * Creates a new {@link GraphLookupOperation.FromBuilder} to construct a {@link GraphLookupOperation} given + * {@literal fromCollection}. + * + * @param fromCollection must not be {@literal null} or empty. + * @return + */ + public static StartWithBuilder graphLookup(String fromCollection) { + return GraphLookupOperation.builder().from(fromCollection); + } + /** * Factory method to create a new {@link SortOperation} for the given {@link Sort}. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/GraphLookupOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/GraphLookupOperation.java new file mode 100644 index 0000000000..1f7d24b487 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/GraphLookupOperation.java @@ -0,0 +1,365 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.core.aggregation; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.data.mongodb.core.aggregation.ExposedFields.ExposedField; +import org.springframework.data.mongodb.core.aggregation.FieldsExposingAggregationOperation.InheritsFieldsAggregationOperation; +import org.springframework.data.mongodb.core.query.CriteriaDefinition; +import org.springframework.util.Assert; + +import com.mongodb.BasicDBList; +import com.mongodb.BasicDBObject; +import com.mongodb.DBObject; + +/** + * Encapsulates the aggregation framework {@code $graphLookup}-operation. + *

+ * Performs a recursive search on a collection, with options for restricting the search by recursion depth and query + * filter. + *

+ * We recommend to use the static factory method {@link Aggregation#graphLookup(String)} instead of creating instances + * of this class directly. + * + * @see http://docs.mongodb.org/manual/reference/aggregation/graphLookup/ + * @author Mark Paluch + * @since 1.10 + */ +public class GraphLookupOperation implements InheritsFieldsAggregationOperation { + + private final String from; + private final List startWith; + private final Field connectFrom; + private final Field connectTo; + private final Field as; + private final Long maxDepth; + private final Field depthField; + private final CriteriaDefinition restrictSearchWithMatch; + + private GraphLookupOperation(String from, List startWith, Field connectFrom, Field connectTo, Field as, + Long maxDepth, Field depthField, CriteriaDefinition restrictSearchWithMatch) { + + this.from = from; + this.startWith = startWith; + this.connectFrom = connectFrom; + this.connectTo = connectTo; + this.as = as; + this.maxDepth = maxDepth; + this.depthField = depthField; + this.restrictSearchWithMatch = restrictSearchWithMatch; + } + + /** + * Creates a new {@link FromBuilder} to build {@link GraphLookupOperation}. + * + * @return a new {@link FromBuilder}. + */ + public static FromBuilder builder() { + return new GraphLookupOperationFromBuilder(); + } + + /* (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.AggregationOperation#toDBObject(org.springframework.data.mongodb.core.aggregation.AggregationOperationContext) + */ + @Override + public DBObject toDBObject(AggregationOperationContext context) { + + DBObject graphLookup = new BasicDBObject(); + + graphLookup.put("from", from); + + BasicDBList list = new BasicDBList(); + + for (Object startWithElement : startWith) { + + if (startWithElement instanceof AggregationExpression) { + list.add(((AggregationExpression) startWithElement).toDbObject(context)); + } + + if (startWithElement instanceof Field) { + list.add(context.getReference((Field) startWithElement).toString()); + } + } + + if (list.size() == 1) { + graphLookup.put("startWith", list.get(0)); + } else { + graphLookup.put("startWith", list); + } + + graphLookup.put("connectFromField", connectFrom.getName()); + graphLookup.put("connectToField", connectTo.getName()); + graphLookup.put("as", as.getName()); + + if (maxDepth != null) { + graphLookup.put("maxDepth", maxDepth); + } + + if (depthField != null) { + graphLookup.put("depthField", depthField.getName()); + } + + if (restrictSearchWithMatch != null) { + graphLookup.put("restrictSearchWithMatch", context.getMappedObject(restrictSearchWithMatch.getCriteriaObject())); + } + + return new BasicDBObject("$graphLookup", graphLookup); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.FieldsExposingAggregationOperation#getFields() + */ + @Override + public ExposedFields getFields() { + return ExposedFields.from(new ExposedField(as, true)); + } + + /** + * @author Mark Paluch + */ + public interface FromBuilder { + + /** + * Set the {@literal collectionName} to apply the {@code $graphLookup} to. + * + * @param collectionName must not be {@literal null} or empty. + * @return + */ + StartWithBuilder from(String collectionName); + } + + /** + * @author Mark Paluch + */ + public interface StartWithBuilder { + + /** + * Set the startWith {@literal fieldReferences} to apply the {@code $graphLookup} to. + * + * @param fieldReferences must not be {@literal null}. + * @return + */ + ConnectFromBuilder startWith(String... fieldReferences); + + /** + * Set the startWith {@literal expressions} to apply the {@code $graphLookup} to. + * + * @param expressions must not be {@literal null}. + * @return + */ + ConnectFromBuilder startWith(AggregationExpression... expressions); + } + + /** + * @author Mark Paluch + */ + public interface ConnectFromBuilder { + + /** + * Set the connectFrom {@literal fieldName} to apply the {@code $graphLookup} to. + * + * @param fieldName must not be {@literal null} or empty. + * @return + */ + ConnectToBuilder connectFrom(String fieldName); + } + + /** + * @author Mark Paluch + */ + public interface ConnectToBuilder { + + /** + * Set the connectTo {@literal fieldName} to apply the {@code $graphLookup} to. + * + * @param fieldName must not be {@literal null} or empty. + * @return + */ + GraphLookupOperationBuilder connectTo(String fieldName); + } + + /** + * Builder to build the initial {@link GraphLookupOperationBuilder} that configures the initial mandatory set of + * {@link GraphLookupOperation} properties. + * + * @author Mark Paluch + */ + static final class GraphLookupOperationFromBuilder + implements FromBuilder, StartWithBuilder, ConnectFromBuilder, ConnectToBuilder { + + private String from; + private List startWith; + private String connectFrom; + + /* (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.GraphLookupOperation.FromBuilder#from(java.lang.String) + */ + @Override + public StartWithBuilder from(String collectionName) { + + Assert.hasText(collectionName, "CollectionName must not be null or empty!"); + + this.from = collectionName; + + return this; + } + + /* (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.GraphLookupOperation.StartWithBuilder#startWith(java.lang.String[]) + */ + @Override + public ConnectFromBuilder startWith(String... fieldReferences) { + + Assert.notNull(fieldReferences, "FieldReferences must not be null!"); + Assert.noNullElements(fieldReferences, "FieldReferences must not contain null elements!"); + + List fields = new ArrayList(fieldReferences.length); + + for (String fieldReference : fieldReferences) { + fields.add(Fields.field(fieldReference)); + } + + this.startWith = fields; + + return this; + } + + /* (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.GraphLookupOperation.StartWithBuilder#startWith(org.springframework.data.mongodb.core.aggregation.AggregationExpression[]) + */ + @Override + public ConnectFromBuilder startWith(AggregationExpression... expressions) { + + Assert.notNull(expressions, "AggregationExpressions must not be null!"); + Assert.noNullElements(expressions, "AggregationExpressions must not contain null elements!"); + + this.startWith = Arrays.asList(expressions); + + return this; + } + + /* (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.GraphLookupOperation.ConnectFromBuilder#connectFrom(java.lang.String) + */ + @Override + public ConnectToBuilder connectFrom(String fieldName) { + + Assert.hasText(fieldName, "ConnectFrom must not be null or empty!"); + + this.connectFrom = fieldName; + + return this; + } + + /* (non-Javadoc) + * @see org.springframework.data.mongodb.core.aggregation.GraphLookupOperation.ConnectToBuilder#connectTo(java.lang.String) + */ + @Override + public GraphLookupOperationBuilder connectTo(String fieldName) { + + Assert.hasText(fieldName, "ConnectTo must not be null or empty!"); + + return new GraphLookupOperationBuilder(from, startWith, connectFrom, fieldName); + } + } + + /** + * @author Mark Paluch + */ + static final class GraphLookupOperationBuilder { + + private final String from; + private final List startWith; + private final Field connectFrom; + private final Field connectTo; + private Long maxDepth; + private Field depthField; + private CriteriaDefinition restrictSearchWithMatch; + + protected GraphLookupOperationBuilder(String from, List startWith, String connectFrom, + String connectTo) { + + this.from = from; + this.startWith = new ArrayList(startWith); + this.connectFrom = Fields.field(connectFrom); + this.connectTo = Fields.field(connectTo); + } + + /** + * Limit the number of recursions. + * + * @param numberOfRecursions must be greater or equal to zero. + * @return + */ + public GraphLookupOperationBuilder maxDepth(long numberOfRecursions) { + + Assert.isTrue(numberOfRecursions >= 0, "Max depth must be >= 0!"); + + this.maxDepth = numberOfRecursions; + + return this; + } + + /** + * Add a depth field {@literal fieldName} to each traversed document in the search path. + * + * @param fieldName must not be {@literal null} or empty. + * @return + */ + public GraphLookupOperationBuilder depthField(String fieldName) { + + Assert.hasText(fieldName, "Depth field name must not be null or empty!"); + + this.depthField = Fields.field(fieldName); + + return this; + } + + /** + * Add a query specifying conditions to the recursive search. + * + * @param criteriaDefinition must not be {@literal null}. + * @return + */ + public GraphLookupOperationBuilder restrict(CriteriaDefinition criteriaDefinition) { + + Assert.notNull(criteriaDefinition, "CriteriaDefinition must not be null!"); + + this.restrictSearchWithMatch = criteriaDefinition; + + return this; + } + + /** + * Set the name of the array field added to each output document and return the final {@link GraphLookupOperation}. + * Contains the documents traversed in the {@literal $graphLookup} stage to reach the document. + * + * @param fieldName must not be {@literal null} or empty. + * @return the final {@link GraphLookupOperation}. + */ + public GraphLookupOperation as(String fieldName) { + + Assert.hasText(fieldName, "As field name must not be null or empty!"); + + return new GraphLookupOperation(from, startWith, connectFrom, connectTo, Fields.field(fieldName), maxDepth, + depthField, restrictSearchWithMatch); + } + } +} 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 d89d1a782a..4945dad82b 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 @@ -72,6 +72,7 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import com.mongodb.BasicDBList; import com.mongodb.BasicDBObject; import com.mongodb.BasicDBObjectBuilder; import com.mongodb.CommandResult; @@ -100,6 +101,7 @@ public class AggregationTests { private static final Version TWO_DOT_FOUR = new Version(2, 4); private static final Version TWO_DOT_SIX = new Version(2, 6); private static final Version THREE_DOT_TWO = new Version(3, 2); + private static final Version THREE_DOT_FOUR = new Version(3, 4); private static boolean initialized = false; @@ -145,6 +147,7 @@ private void cleanDb() { mongoTemplate.dropCollection(InventoryItem.class); mongoTemplate.dropCollection(Sales.class); mongoTemplate.dropCollection(Sales2.class); + mongoTemplate.dropCollection(Employee.class); } /** @@ -1580,6 +1583,40 @@ public void letShouldBeAppliedCorrectly() { new BasicDBObjectBuilder().add("_id", "2").add("finalTotal", 10.25D).get())); } + /** + * @see DATAMONGO-1551 + */ + @Test + public void graphLookupShouldBeAppliedCorrectly() { + + assumeTrue(mongoVersion.isGreaterThanOrEqualTo(THREE_DOT_FOUR)); + + Employee em1 = Employee.builder().id(1).name("Dev").build(); + Employee em2 = Employee.builder().id(2).name("Eliot").reportsTo("Dev").build(); + Employee em4 = Employee.builder().id(4).name("Andrew").reportsTo("Eliot").build(); + + mongoTemplate.insert(Arrays.asList(em1, em2, em4), Employee.class); + + TypedAggregation agg = Aggregation.newAggregation(Employee.class, + match(Criteria.where("name").is("Andrew")), // + Aggregation.graphLookup("employee") // + .startWith("reportsTo") // + .connectFrom("reportsTo") // + .connectTo("name") // + .depthField("depth") // + .maxDepth(5) // + .as("reportingHierarchy")); + + AggregationResults result = mongoTemplate.aggregate(agg, DBObject.class); + + DBObject object = result.getUniqueMappedResult(); + BasicDBList list = (BasicDBList) object.get("reportingHierarchy"); + + assertThat(object, isBsonObject().containing("reportingHierarchy", List.class)); + assertThat((DBObject) list.get(0), isBsonObject().containing("name", "Dev").containing("depth", 1L)); + assertThat((DBObject) list.get(1), isBsonObject().containing("name", "Eliot").containing("depth", 0L)); + } + private void createUsersWithReferencedPersons() { mongoTemplate.dropCollection(User.class); @@ -1822,7 +1859,7 @@ public InventoryItem(int id, String item, String description, int qty) { } /** - * @DATAMONGO-1491 + * @see DATAMONGO-1491 */ @lombok.Data @Builder @@ -1833,7 +1870,7 @@ static class Sales { } /** - * @DATAMONGO-1491 + * @see DATAMONGO-1491 */ @lombok.Data @Builder @@ -1846,7 +1883,7 @@ static class Item { } /** - * @DATAMONGO-1538 + * @see DATAMONGO-1538 */ @lombok.Data @Builder @@ -1857,4 +1894,16 @@ static class Sales2 { Float tax; boolean applyDiscount; } + + /** + * @see DATAMONGO-1551 + */ + @lombok.Data + @Builder + static class Employee { + + int id; + String name; + String reportsTo; + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/GraphLookupOperationUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/GraphLookupOperationUnitTests.java new file mode 100644 index 0000000000..091e20106b --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/GraphLookupOperationUnitTests.java @@ -0,0 +1,122 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.core.aggregation; + +import static org.hamcrest.core.Is.*; +import static org.junit.Assert.*; +import static org.springframework.data.mongodb.test.util.IsBsonObject.*; + +import org.junit.Test; +import org.springframework.data.mongodb.core.aggregation.AggregationExpressions.Literal; +import org.springframework.data.mongodb.core.query.Criteria; + +import com.mongodb.BasicDBObject; +import com.mongodb.DBObject; +import com.mongodb.util.JSON; + +/** + * Unit tests for {@link GraphLookupOperation}. + * + * @author Mark Paluch + */ +public class GraphLookupOperationUnitTests { + + /** + * @see DATAMONGO-1551 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsNullFromCollection() { + GraphLookupOperation.builder().from(null); + } + + /** + * @see DATAMONGO-1551 + */ + @Test + public void shouldRenderCorrectly() { + + GraphLookupOperation graphLookupOperation = GraphLookupOperation.builder() // + .from("employees") // + .startWith("reportsTo") // + .connectFrom("reportsTo") // + .connectTo("name") // + .depthField("depth") // + .maxDepth(42) // + .as("reportingHierarchy"); + + DBObject dbObject = graphLookupOperation.toDBObject(Aggregation.DEFAULT_CONTEXT); + assertThat(dbObject, + isBsonObject().containing("$graphLookup.depthField", "depth").containing("$graphLookup.maxDepth", 42L)); + } + + /** + * @see DATAMONGO-1551 + */ + @Test + public void shouldRenderCriteriaCorrectly() { + + GraphLookupOperation graphLookupOperation = GraphLookupOperation.builder() // + .from("employees") // + .startWith("reportsTo") // + .connectFrom("reportsTo") // + .connectTo("name") // + .restrict(Criteria.where("key").is("value")) // + .as("reportingHierarchy"); + + DBObject dbObject = graphLookupOperation.toDBObject(Aggregation.DEFAULT_CONTEXT); + assertThat(dbObject, + isBsonObject().containing("$graphLookup.restrictSearchWithMatch", new BasicDBObject("key", "value"))); + } + + /** + * @see DATAMONGO-1551 + */ + @Test + public void shouldRenderArrayOfStartsWithCorrectly() { + + GraphLookupOperation graphLookupOperation = GraphLookupOperation.builder() // + .from("employees") // + .startWith("reportsTo", "boss") // + .connectFrom("reportsTo") // + .connectTo("name") // + .as("reportingHierarchy"); + + DBObject dbObject = graphLookupOperation.toDBObject(Aggregation.DEFAULT_CONTEXT); + + assertThat(dbObject, + is(JSON.parse("{ $graphLookup : { from: \"employees\", startWith: [\"$reportsTo\", \"$boss\"], " + + "connectFromField: \"reportsTo\", connectToField: \"name\", as: \"reportingHierarchy\" } }"))); + } + + /** + * @see DATAMONGO-1551 + */ + @Test + public void shouldRenderStartWithAggregationExpressions() { + + GraphLookupOperation graphLookupOperation = GraphLookupOperation.builder() // + .from("employees") // + .startWith(Literal.asLiteral("hello")) // + .connectFrom("reportsTo") // + .connectTo("name") // + .as("reportingHierarchy"); + + DBObject dbObject = graphLookupOperation.toDBObject(Aggregation.DEFAULT_CONTEXT); + + assertThat(dbObject, is(JSON.parse("{ $graphLookup : { from: \"employees\", startWith: { $literal: \"hello\"}, " + + "connectFromField: \"reportsTo\", connectToField: \"name\", as: \"reportingHierarchy\" } }"))); + } +}