diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationContext.java index 3fdc1b125..60363db91 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationContext.java @@ -23,6 +23,7 @@ import org.bson.Document; import org.bson.codecs.configuration.CodecRegistry; import org.springframework.beans.BeanUtils; import org.springframework.data.mongodb.CodecRegistryProvider; +import org.springframework.data.mongodb.MongoCollectionUtils; import org.springframework.data.mongodb.core.aggregation.ExposedFields.FieldReference; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -78,6 +79,29 @@ public interface AggregationOperationContext extends CodecRegistryProvider { */ FieldReference getReference(String name); + /** + * Obtain the target field name for a given field/type combination. + * + * @param type The type containing the field. + * @param field The property/field name + * @return never {@literal null}. + * @since 4.2 + */ + default String getMappedFieldName(Class type, String field) { + return field; + } + + /** + * Obtain the collection name for a given {@link Class type} combination. + * + * @param type + * @return never {@literal null}. + * @since 4.2 + */ + default String getCollection(Class type) { + return MongoCollectionUtils.getPreferredCollectionName(type); + } + /** * Returns the {@link Fields} exposed by the type. May be a {@literal class} or an {@literal interface}. The default * implementation uses {@link BeanUtils#getPropertyDescriptors(Class) property descriptors} discover fields from a 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 index 44d0f1569..4e4abfb1b 100644 --- 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 @@ -39,7 +39,7 @@ import org.springframework.util.Assert; */ public class LookupOperation implements FieldsExposingAggregationOperation, InheritsFieldsAggregationOperation { - private final String from; + private Object from; @Nullable // private final Field localField; @@ -97,6 +97,22 @@ public class LookupOperation implements FieldsExposingAggregationOperation, Inhe */ public LookupOperation(String from, @Nullable Field localField, @Nullable Field foreignField, @Nullable Let let, @Nullable AggregationPipeline pipeline, Field as) { + this((Object) from, localField, foreignField, let, pipeline, as); + } + + /** + * Creates a new {@link LookupOperation} for the given combination of {@link Field}s and {@link AggregationPipeline + * pipeline}. + * + * @param from must not be {@literal null}. Can be eiter the target collection name or a {@link Class}. + * @param localField can be {@literal null} if {@literal pipeline} is present. + * @param foreignField can be {@literal null} if {@literal pipeline} is present. + * @param let can be {@literal null} if {@literal localField} and {@literal foreignField} are present. + * @param as must not be {@literal null}. + * @since 4.1 + */ + private LookupOperation(Object from, @Nullable Field localField, @Nullable Field foreignField, @Nullable Let let, + @Nullable AggregationPipeline pipeline, Field as) { Assert.notNull(from, "From must not be null"); if (pipeline == null) { @@ -125,12 +141,14 @@ public class LookupOperation implements FieldsExposingAggregationOperation, Inhe Document lookupObject = new Document(); - lookupObject.append("from", from); + lookupObject.append("from", getCollectionName(context)); + if (localField != null) { lookupObject.append("localField", localField.getTarget()); } + if (foreignField != null) { - lookupObject.append("foreignField", foreignField.getTarget()); + lookupObject.append("foreignField", getForeignFieldName(context)); } if (let != null) { lookupObject.append("let", let.toDocument(context).get("$let", Document.class).get("vars")); @@ -144,6 +162,16 @@ public class LookupOperation implements FieldsExposingAggregationOperation, Inhe return new Document(getOperator(), lookupObject); } + String getCollectionName(AggregationOperationContext context) { + return from instanceof Class type ? context.getCollection(type) : from.toString(); + } + + String getForeignFieldName(AggregationOperationContext context) { + + return from instanceof Class type ? context.getMappedFieldName(type, foreignField.getTarget()) + : foreignField.getTarget(); + } + @Override public String getOperator() { return "$lookup"; @@ -158,16 +186,28 @@ public class LookupOperation implements FieldsExposingAggregationOperation, Inhe return new LookupOperationBuilder(); } - public static interface FromBuilder { + public interface FromBuilder { /** * @param name the collection in the same database to perform the join with, must not be {@literal null} or empty. * @return never {@literal null}. */ LocalFieldBuilder from(String name); + + /** + * Use the given type to determine name of the foreign collection and map + * {@link ForeignFieldBuilder#foreignField(String)} against it to consider eventually present + * {@link org.springframework.data.mongodb.core.mapping.Field} annotations. + * + * @param type the type of the target collection in the same database to perform the join with, must not be + * {@literal null}. + * @return never {@literal null}. + * @since 4.2 + */ + LocalFieldBuilder from(Class type); } - public static interface LocalFieldBuilder extends PipelineBuilder { + public interface LocalFieldBuilder extends PipelineBuilder { /** * @param name the field from the documents input to the {@code $lookup} stage, must not be {@literal null} or @@ -177,7 +217,7 @@ public class LookupOperation implements FieldsExposingAggregationOperation, Inhe ForeignFieldBuilder localField(String name); } - public static interface ForeignFieldBuilder { + public interface ForeignFieldBuilder { /** * @param name the field from the documents in the {@code from} collection, must not be {@literal null} or empty. @@ -246,7 +286,7 @@ public class LookupOperation implements FieldsExposingAggregationOperation, Inhe LookupOperation as(String name); } - public static interface AsBuilder extends PipelineBuilder { + public interface AsBuilder extends PipelineBuilder { /** * @param name the name of the new array field to add to the input documents, must not be {@literal null} or empty. @@ -264,7 +304,7 @@ public class LookupOperation implements FieldsExposingAggregationOperation, Inhe public static final class LookupOperationBuilder implements FromBuilder, LocalFieldBuilder, ForeignFieldBuilder, AsBuilder { - private @Nullable String from; + private @Nullable Object from; private @Nullable Field localField; private @Nullable Field foreignField; private @Nullable ExposedField as; @@ -288,6 +328,14 @@ public class LookupOperation implements FieldsExposingAggregationOperation, Inhe return this; } + @Override + public LocalFieldBuilder from(Class type) { + + Assert.notNull(type, "'From' must not be null"); + from = type; + return this; + } + @Override public AsBuilder foreignField(String name) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/TypeBasedAggregationOperationContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/TypeBasedAggregationOperationContext.java index fd54514cb..efd135d32 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/TypeBasedAggregationOperationContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/TypeBasedAggregationOperationContext.java @@ -92,6 +92,20 @@ public class TypeBasedAggregationOperationContext implements AggregationOperatio return getReferenceFor(field(name)); } + @Override + public String getCollection(Class type) { + + MongoPersistentEntity persistentEntity = mappingContext.getPersistentEntity(type); + return persistentEntity != null ? persistentEntity.getCollection() : AggregationOperationContext.super.getCollection(type); + } + + @Override + public String getMappedFieldName(Class type, String field) { + + PersistentPropertyPath persistentPropertyPath = mappingContext.getPersistentPropertyPath(field, type); + return persistentPropertyPath.getLeafProperty().getFieldName(); + } + @Override public Fields getFields(Class type) { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationTestUtils.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationTestUtils.java new file mode 100644 index 000000000..57e831622 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationTestUtils.java @@ -0,0 +1,102 @@ +/* + * Copyright 2023. 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. + */ + +/* + * Copyright 2023 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.mapping.context.MappingContext; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; +import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver; +import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.mongodb.test.util.MongoTestMappingContext; + +/** + * @author Christoph Strobl + * @since 2023/06 + */ +public final class AggregationTestUtils { + + public static AggregationContextBuilder strict(Class type) { + + AggregationContextBuilder builder = new AggregationContextBuilder<>(); + builder.strict = true; + return builder.forType(type); + } + + public static AggregationContextBuilder relaxed(Class type) { + + AggregationContextBuilder builder = new AggregationContextBuilder<>(); + builder.strict = false; + return builder.forType(type); + } + + public static class AggregationContextBuilder { + + Class targetType; + MappingContext, MongoPersistentProperty> mappingContext; + QueryMapper queryMapper; + boolean strict; + + public AggregationContextBuilder forType(Class type) { + + this.targetType = type; + return (AggregationContextBuilder) this; + } + + public AggregationContextBuilder using( + MappingContext, MongoPersistentProperty> mappingContext) { + + this.mappingContext = mappingContext; + return this; + } + + public AggregationContextBuilder using(QueryMapper queryMapper) { + + this.queryMapper = queryMapper; + return this; + } + + public T ctx() { + // + if (targetType == null) { + return (T) Aggregation.DEFAULT_CONTEXT; + } + + MappingContext, MongoPersistentProperty> ctx = mappingContext != null ? mappingContext : MongoTestMappingContext.newTestContext().init(); + QueryMapper qm = queryMapper != null ? queryMapper + : new QueryMapper(new MappingMongoConverter(NoOpDbRefResolver.INSTANCE, ctx)); + return (T) (strict ? new TypeBasedAggregationOperationContext(targetType, ctx, qm) + : new RelaxedTypeBasedAggregationOperationContext(targetType, ctx, qm)); + } + } +} 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 index 45b63763c..1127ad6ea 100644 --- 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 @@ -25,6 +25,7 @@ import java.util.List; import org.bson.Document; import org.junit.jupiter.api.Test; import org.springframework.data.mongodb.core.DocumentTestUtils; +import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.query.Criteria; /** @@ -92,7 +93,7 @@ public class LookupOperationUnitTests { @Test // DATAMONGO-1326 public void builderRejectsNullFromField() { - assertThatIllegalArgumentException().isThrownBy(() -> LookupOperation.newLookup().from(null)); + assertThatIllegalArgumentException().isThrownBy(() -> LookupOperation.newLookup().from((String) null)); } @Test // DATAMONGO-1326 @@ -195,10 +196,10 @@ public class LookupOperationUnitTests { void buildsLookupWithLocalAndForeignFieldAsWellAsLetAndPipeline() { LookupOperation lookupOperation = Aggregation.lookup().from("restaurants") // - .localField("restaurant_name") - .foreignField("name") + .localField("restaurant_name") // + .foreignField("name") // .let(newVariable("orders_drink").forField("drink")) // - .pipeline(match(ctx -> new Document("$expr", new Document("$in", List.of("$$orders_drink", "$beverages"))))) + .pipeline(match(ctx -> new Document("$expr", new Document("$in", List.of("$$orders_drink", "$beverages"))))) // .as("matches"); assertThat(lookupOperation.toDocument(Aggregation.DEFAULT_CONTEXT)).isEqualTo(""" @@ -216,4 +217,54 @@ public class LookupOperationUnitTests { }} """); } + + @Test // GH-4379 + void unmappedLookupWithFromExtractedFromType() { + + LookupOperation lookupOperation = Aggregation.lookup().from(Restaurant.class) // + .localField("restaurant_name") // + .foreignField("name") // + .as("restaurants"); + + assertThat(lookupOperation.toDocument(Aggregation.DEFAULT_CONTEXT)).isEqualTo(""" + { $lookup: + { + from: "restaurant", + localField: "restaurant_name", + foreignField: "name", + as: "restaurants" + } + }} + """); + } + + @Test // GH-4379 + void mappedLookupWithFromExtractedFromType() { + + LookupOperation lookupOperation = Aggregation.lookup().from(Restaurant.class) // + .localField("restaurant_name") // + .foreignField("name") // + .as("restaurants"); + + + assertThat(lookupOperation.toDocument(AggregationTestUtils.strict(Restaurant.class).ctx())).isEqualTo(""" + { $lookup: + { + from: "sites", + localField: "restaurant_name", + foreignField: "rs_name", + as: "restaurants" + } + }} + """); + } + + @org.springframework.data.mongodb.core.mapping.Document("sites") + static class Restaurant { + + String id; + + @Field("rs_name") // + String name; + } }