diff --git a/pom.xml b/pom.xml index 1d9b760700..a3e8541a78 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-mongodb-parent - 1.9.0.BUILD-SNAPSHOT + 1.9.0.DATAMONGO-1391-SNAPSHOT pom Spring Data MongoDB diff --git a/spring-data-mongodb-cross-store/pom.xml b/spring-data-mongodb-cross-store/pom.xml index fd36debedd..4b5a6c0e7c 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.9.0.BUILD-SNAPSHOT + 1.9.0.DATAMONGO-1391-SNAPSHOT ../pom.xml @@ -48,7 +48,7 @@ org.springframework.data spring-data-mongodb - 1.9.0.BUILD-SNAPSHOT + 1.9.0.DATAMONGO-1391-SNAPSHOT diff --git a/spring-data-mongodb-distribution/pom.xml b/spring-data-mongodb-distribution/pom.xml index 28c91bc332..2e0a228213 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.9.0.BUILD-SNAPSHOT + 1.9.0.DATAMONGO-1391-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb-log4j/pom.xml b/spring-data-mongodb-log4j/pom.xml index dfe146ff96..5d55ebdd76 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.9.0.BUILD-SNAPSHOT + 1.9.0.DATAMONGO-1391-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index 0fcdb2f39f..762974d85c 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.9.0.BUILD-SNAPSHOT + 1.9.0.DATAMONGO-1391-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 1c98ebd315..f1be24bd3c 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 @@ -195,13 +195,56 @@ 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. + * @param field must not be {@literal null} or empty. * @return */ public static UnwindOperation unwind(String field) { return new UnwindOperation(field(field)); } + /** + * Factory method to create a new {@link UnwindOperation} for the field with the given name and + * {@code preserveNullAndEmptyArrays}. Note that extended unwind is supported in MongoDB version 3.2+. + * + * @param field must not be {@literal null} or empty. + * @param preserveNullAndEmptyArrays {@literal true} to output the document if path is {@literal null}, missing or + * array is empty. + * @return + * @since 1.10 + */ + public static UnwindOperation unwind(String field, boolean preserveNullAndEmptyArrays) { + return new UnwindOperation(field(field), preserveNullAndEmptyArrays); + } + + /** + * Factory method to create a new {@link UnwindOperation} for the field with the given name and to include the index + * field as {@code arrayIndex}. Note that extended unwind is supported in MongoDB version 3.2+. + * + * @param field must not be {@literal null} or empty. + * @param arrayIndex must not be {@literal null} or empty. + * @return + * @since 1.10 + */ + public static UnwindOperation unwind(String field, String arrayIndex) { + return new UnwindOperation(field(field), field(arrayIndex), false); + } + + /** + * Factory method to create a new {@link UnwindOperation} for the field with the given name, to include the index + * field as {@code arrayIndex} and {@code preserveNullAndEmptyArrays}. Note that extended unwind is supported in + * MongoDB version 3.2+. + * + * @param field must not be {@literal null} or empty. + * @param arrayIndex must not be {@literal null} or empty. + * @param preserveNullAndEmptyArrays {@literal true} to output the document if path is {@literal null}, missing or + * array is empty. + * @return + * @since 1.10 + */ + public static UnwindOperation unwind(String field, String arrayIndex, boolean preserveNullAndEmptyArrays) { + return new UnwindOperation(field(field), field(arrayIndex), preserveNullAndEmptyArrays); + } + /** * Creates a new {@link GroupOperation} for the given fields. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ExposedFields.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ExposedFields.java index 79bece0f85..02537fcbd7 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ExposedFields.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ExposedFields.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2014 the original author or authors. + * Copyright 2013-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. @@ -30,6 +30,7 @@ * * @author Oliver Gierke * @author Thomas Darimont + * @author Mark Paluch * @since 1.3 */ public final class ExposedFields implements Iterable { @@ -203,8 +204,13 @@ private int exposedFieldsCount() { public Iterator iterator() { CompositeIterator iterator = new CompositeIterator(); - iterator.add(syntheticFields.iterator()); - iterator.add(originalFields.iterator()); + if (!syntheticFields.isEmpty()) { + iterator.add(syntheticFields.iterator()); + } + + if (!originalFields.isEmpty()) { + iterator.add(originalFields.iterator()); + } return iterator; } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/UnwindOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/UnwindOperation.java index 883cb8a8c4..3f62d4c079 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/UnwindOperation.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/UnwindOperation.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2015 the original author or authors. + * Copyright 2013-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. @@ -19,6 +19,7 @@ import org.springframework.util.Assert; import com.mongodb.BasicDBObject; +import com.mongodb.BasicDBObjectBuilder; import com.mongodb.DBObject; /** @@ -30,11 +31,15 @@ * @see http://docs.mongodb.org/manual/reference/aggregation/unwind/#pipe._S_unwind * @author Thomas Darimont * @author Oliver Gierke + * @author Mark Paluch * @since 1.3 */ -public class UnwindOperation implements AggregationOperation { +public class UnwindOperation + implements AggregationOperation, FieldsExposingAggregationOperation.InheritsFieldsAggregationOperation { private final ExposedField field; + private final ExposedField arrayIndex; + private final boolean preserveNullAndEmptyArrays; /** * Creates a new {@link UnwindOperation} for the given {@link Field}. @@ -42,17 +47,191 @@ public class UnwindOperation implements AggregationOperation { * @param field must not be {@literal null}. */ public UnwindOperation(Field field) { + this(new ExposedField(field, true), false); + } + + /** + * Creates a new {@link UnwindOperation} using Mongo 3.2 syntax. + * + * @param field must not be {@literal null}. + * @param preserveNullAndEmptyArrays {@literal true} to output the document if path is {@literal null}, missing or + * array is empty. + * @since 1.10 + */ + public UnwindOperation(Field field, boolean preserveNullAndEmptyArrays) { + Assert.notNull(field, "Field must not be null!"); + + this.field = new ExposedField(field, true); + this.arrayIndex = null; + this.preserveNullAndEmptyArrays = preserveNullAndEmptyArrays; + } + + /** + * Creates a new {@link UnwindOperation} using Mongo 3.2 syntax. + * + * @param field must not be {@literal null}. + * @param arrayIndex optional field name to expose the field array index, must not be {@literal null}. + * @param preserveNullAndEmptyArrays {@literal true} to output the document if path is {@literal null}, missing or + * array is empty. + * @since 1.10 + */ + public UnwindOperation(Field field, Field arrayIndex, boolean preserveNullAndEmptyArrays) { + + Assert.notNull(field, "Field must not be null!"); + Assert.notNull(arrayIndex, "ArrayIndex must not be null!"); - Assert.notNull(field); this.field = new ExposedField(field, true); + this.arrayIndex = new ExposedField(arrayIndex, true); + this.preserveNullAndEmptyArrays = preserveNullAndEmptyArrays; } - /* + /* * (non-Javadoc) * @see org.springframework.data.mongodb.core.aggregation.AggregationOperation#toDBObject(org.springframework.data.mongodb.core.aggregation.AggregationOperationContext) */ @Override public DBObject toDBObject(AggregationOperationContext context) { - return new BasicDBObject("$unwind", context.getReference(field).toString()); + + String unwindField = context.getReference(field).toString(); + Object unwindArg; + + if (preserveNullAndEmptyArrays || arrayIndex != null) { + + BasicDBObjectBuilder builder = BasicDBObjectBuilder.start().add("path", unwindField); + builder.add("preserveNullAndEmptyArrays", preserveNullAndEmptyArrays); + + if (arrayIndex != null) { + builder.add("includeArrayIndex", arrayIndex.getName()); + } + + unwindArg = builder.get(); + } else { + unwindArg = unwindField; + } + + return new BasicDBObject("$unwind", unwindArg); + } + + @Override + public ExposedFields getFields() { + return arrayIndex != null ? ExposedFields.from(arrayIndex) : ExposedFields.from(); } + + /** + * Get a builder that allows creation of {@link LookupOperation}. + * + * @return + */ + public static PathBuilder newUnwind() { + return UnwindOperationBuilder.newBuilder(); + } + + public static interface PathBuilder { + + /** + * @param path the path to unwind, must not be {@literal null} or empty. + * @return + */ + IndexBuilder path(String path); + } + + public static interface IndexBuilder { + + /** + * Exposes the array index as {@code field}. + * + * @param field field name to expose the field array index, must not be {@literal null} or empty. + * @return + */ + EmptyArraysBuilder arrayIndex(String field); + + /** + * Do not expose the array index. + * + * @return + */ + EmptyArraysBuilder noArrayIndex(); + } + + public static interface EmptyArraysBuilder { + + /** + * Output documents if the array is null or empty. + * + * @return + */ + UnwindOperation preserveNullAndEmptyArrays(); + + /** + * Do not output documents if the array is null or empty. + * + * @return + */ + UnwindOperation skipNullAndEmptyArrays(); + } + + /** + * Builder for fluent {@link UnwindOperation} creation. + * + * @author Mark Paluch + * @since 1.10 + */ + public static final class UnwindOperationBuilder implements PathBuilder, IndexBuilder, EmptyArraysBuilder { + + private Field field; + private Field arrayIndex; + + private UnwindOperationBuilder() {} + + /** + * Creates new builder for {@link UnwindOperation}. + * + * @return never {@literal null}. + */ + public static PathBuilder newBuilder() { + return new UnwindOperationBuilder(); + } + + @Override + public UnwindOperation preserveNullAndEmptyArrays() { + + if (arrayIndex != null) { + return new UnwindOperation(field, arrayIndex, true); + } + + return new UnwindOperation(field, true); + } + + @Override + public UnwindOperation skipNullAndEmptyArrays() { + + if (arrayIndex != null) { + return new UnwindOperation(field, arrayIndex, false); + } + + return new UnwindOperation(field, false); + } + + @Override + public EmptyArraysBuilder arrayIndex(String field) { + Assert.hasText(field, "'ArrayIndex' must not be null or empty!"); + arrayIndex = Fields.field(field); + return this; + } + + @Override + public EmptyArraysBuilder noArrayIndex() { + arrayIndex = null; + return this; + } + + @Override + public UnwindOperationBuilder path(String path) { + Assert.hasText(path, "'Path' must not be null or empty!"); + field = Fields.field(path); + return this; + } + + } + } 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 7a23b03782..4dc5306ad9 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 @@ -241,6 +241,64 @@ public void shouldAggregateEmptyCollection() { assertThat(tagCount.size(), is(0)); } + /** + * @see DATAMONGO-1391 + */ + @Test + public void shouldUnwindWithIndex() { + + DBCollection coll = mongoTemplate.getCollection(INPUT_COLLECTION); + + coll.insert(createDocument("Doc1", "spring", "mongodb", "nosql")); + coll.insert(createDocument("Doc2")); + + Aggregation agg = newAggregation( // + project("tags"), // + unwind("tags", "n"), // + project("n") // + .and("tag").previousOperation(), // + sort(DESC, "n") // + ); + + AggregationResults results = mongoTemplate.aggregate(agg, INPUT_COLLECTION, TagCount.class); + + assertThat(results, is(notNullValue())); + + List tagCount = results.getMappedResults(); + + assertThat(tagCount, is(notNullValue())); + assertThat(tagCount.size(), is(3)); + } + + /** + * @see DATAMONGO-1391 + */ + @Test + public void shouldUnwindPreserveEmpty() { + + DBCollection coll = mongoTemplate.getCollection(INPUT_COLLECTION); + + coll.insert(createDocument("Doc1", "spring", "mongodb", "nosql")); + coll.insert(createDocument("Doc2")); + + Aggregation agg = newAggregation( // + project("tags"), // + unwind("tags", "n", true), // + sort(DESC, "n") // + ); + + AggregationResults results = mongoTemplate.aggregate(agg, INPUT_COLLECTION, DBObject.class); + + assertThat(results, is(notNullValue())); + + List tagCount = results.getMappedResults(); + + assertThat(tagCount, is(notNullValue())); + assertThat(tagCount.size(), is(4)); + assertThat(tagCount.get(0), isBsonObject().containing("n", 2L)); + assertThat(tagCount.get(3), isBsonObject().notContaining("n")); + } + @Test public void shouldDetectResultMismatch() { 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 7c8d3d3dbd..03387417c2 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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2015 the original author or authors. + * Copyright 2013-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. @@ -20,6 +20,7 @@ import static org.springframework.data.mongodb.core.DBObjectTestUtils.*; import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; 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.List; @@ -39,6 +40,7 @@ * @author Oliver Gierke * @author Thomas Darimont * @author Christoph Strobl + * @author Mark Paluch */ public class AggregationUnitTests { @@ -94,6 +96,64 @@ public void unwindOperationShouldNotChangeAvailableFields() { ).toDbObject("foo", Aggregation.DEFAULT_CONTEXT); } + /** + * @see DATAMONGO-1391 + */ + @Test + public void unwindOperationWithIndexShouldPreserveFields() { + + newAggregation( // + project("a", "b"), // + unwind("a", "x"), // + project("a", "b") // b should still be available + ).toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + } + + /** + * @see DATAMONGO-1391 + */ + @Test + public void unwindOperationWithIndexShouldAddIndexField() { + + newAggregation( // + project("a", "b"), // + unwind("a", "x"), // + project("a", "x") // b should still be available + ).toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + } + + /** + * @see DATAMONGO-1391 + */ + @Test + public void fullUnwindOperationShouldBuildCorrectClause() { + + DBObject agg = newAggregation( // + unwind("a", "x", true)).toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + + @SuppressWarnings("unchecked") + DBObject unwind = ((List) agg.get("pipeline")).get(0); + assertThat((DBObject) unwind.get("$unwind"), + isBsonObject(). // + containing("includeArrayIndex", "x").// + containing("preserveNullAndEmptyArrays", true)); + } + + /** + * @see DATAMONGO-1391 + */ + @Test + public void unwindOperationWithPreserveNullShouldBuildCorrectClause() { + + DBObject agg = newAggregation( // + unwind("a", true)).toDbObject("foo", Aggregation.DEFAULT_CONTEXT); + + @SuppressWarnings("unchecked") + DBObject unwind = ((List) agg.get("pipeline")).get(0); + assertThat(unwind, + isBsonObject().notContaining("includeArrayIndex").containing("preserveNullAndEmptyArrays", true)); + } + /** * @see DATAMONGO-753 */ diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/UnwindOperationUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/UnwindOperationUnitTests.java new file mode 100644 index 0000000000..4ebb01a3db --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/UnwindOperationUnitTests.java @@ -0,0 +1,136 @@ +/* + * 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.Matchers.*; +import static org.junit.Assert.*; +import static org.springframework.data.mongodb.test.util.IsBsonObject.*; + +import org.junit.Test; +import org.springframework.data.mongodb.core.DBObjectTestUtils; + +import com.mongodb.DBObject; + +/** + * Unit tests for {@link UnwindOperation}. + * + * @author Mark Paluch + */ +public class UnwindOperationUnitTests { + + /** + * @see DATAMONGO-1391 + */ + @Test + public void unwindWithPathOnlyShouldUsePreMongo32Syntax() { + + UnwindOperation unwindOperation = Aggregation.unwind("a"); + + DBObject pipeline = unwindOperation.toDBObject(Aggregation.DEFAULT_CONTEXT); + + assertThat(pipeline, isBsonObject().containing("$unwind", "$a")); + } + + /** + * @see DATAMONGO-1391 + */ + @Test + public void unwindWithArrayIndexShouldUseMongo32Syntax() { + + UnwindOperation unwindOperation = Aggregation.unwind("a", "index"); + + DBObject unwindClause = extractDbObjectFromUnwindOperation(unwindOperation); + + assertThat(unwindClause, + isBsonObject().containing("path", "$a").// + containing("preserveNullAndEmptyArrays", false).// + containing("includeArrayIndex", "index")); + } + + /** + * @see DATAMONGO-1391 + */ + @Test + public void unwindWithArrayIndexShouldExposeArrayIndex() { + + UnwindOperation unwindOperation = Aggregation.unwind("a", "index"); + + assertThat(unwindOperation.getFields().getField("index"), is(not(nullValue()))); + } + + /** + * @see DATAMONGO-1391 + */ + @Test + public void plainUnwindShouldNotExposeIndex() { + + UnwindOperation unwindOperation = Aggregation.unwind("a"); + + assertThat(unwindOperation.getFields().exposesNoFields(), is(true)); + } + + /** + * @see DATAMONGO-1391 + */ + @Test + public void unwindWithPreserveNullShouldUseMongo32Syntax() { + + UnwindOperation unwindOperation = Aggregation.unwind("a", true); + + DBObject unwindClause = extractDbObjectFromUnwindOperation(unwindOperation); + + assertThat(unwindClause, + isBsonObject().containing("path", "$a").// + containing("preserveNullAndEmptyArrays", true).// + notContaining("includeArrayIndex")); + } + + /** + * @see DATAMONGO-1391 + */ + @Test + public void lookupBuilderBuildsCorrectClause() { + + UnwindOperation unwindOperation = UnwindOperation.newUnwind().path("$foo").noArrayIndex().skipNullAndEmptyArrays(); + DBObject pipeline = unwindOperation.toDBObject(Aggregation.DEFAULT_CONTEXT); + + assertThat(pipeline, isBsonObject().containing("$unwind", "$foo")); + } + + /** + * @see DATAMONGO-1391 + */ + @Test + public void lookupBuilderBuildsCorrectClauseForMongo32() { + + UnwindOperation unwindOperation = UnwindOperation.newUnwind().path("$foo").arrayIndex("myindex") + .preserveNullAndEmptyArrays(); + + DBObject unwindClause = extractDbObjectFromUnwindOperation(unwindOperation); + + assertThat(unwindClause, + isBsonObject().containing("path", "$foo").// + containing("preserveNullAndEmptyArrays", true).// + notContaining("myindex")); + } + + private DBObject extractDbObjectFromUnwindOperation(UnwindOperation unwindOperation) { + + DBObject dbObject = unwindOperation.toDBObject(Aggregation.DEFAULT_CONTEXT); + DBObject unwindClause = DBObjectTestUtils.getAsDBObject(dbObject, "$unwind"); + return unwindClause; + } +}