Skip to content

Commit ff137ec

Browse files
christophstroblmp911de
authored andcommitted
Map collection and fields for $lookup aggregation against type.
This commit enables using a type parameter to define the from collection of a lookup aggregation stage. In doing so we can derive the target collection name from the type and use the given information to also map the from field against the domain object to so that the user is able to operate on property names instead of the target db field name.
1 parent a93c870 commit ff137ec

File tree

5 files changed

+251
-12
lines changed

5 files changed

+251
-12
lines changed

Diff for: spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationContext.java

+24
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.bson.codecs.configuration.CodecRegistry;
2424
import org.springframework.beans.BeanUtils;
2525
import org.springframework.data.mongodb.CodecRegistryProvider;
26+
import org.springframework.data.mongodb.MongoCollectionUtils;
2627
import org.springframework.data.mongodb.core.aggregation.ExposedFields.FieldReference;
2728
import org.springframework.lang.Nullable;
2829
import org.springframework.util.Assert;
@@ -78,6 +79,29 @@ default Document getMappedObject(Document document) {
7879
*/
7980
FieldReference getReference(String name);
8081

82+
/**
83+
* Obtain the target field name for a given field/type combination.
84+
*
85+
* @param type The type containing the field.
86+
* @param field The property/field name
87+
* @return never {@literal null}.
88+
* @since 4.2
89+
*/
90+
default String getMappedFieldName(Class<?> type, String field) {
91+
return field;
92+
}
93+
94+
/**
95+
* Obtain the collection name for a given {@link Class type} combination.
96+
*
97+
* @param type
98+
* @return never {@literal null}.
99+
* @since 4.2
100+
*/
101+
default String getCollection(Class<?> type) {
102+
return MongoCollectionUtils.getPreferredCollectionName(type);
103+
}
104+
81105
/**
82106
* Returns the {@link Fields} exposed by the type. May be a {@literal class} or an {@literal interface}. The default
83107
* implementation uses {@link BeanUtils#getPropertyDescriptors(Class) property descriptors} discover fields from a

Diff for: spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/LookupOperation.java

+56-8
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
*/
4040
public class LookupOperation implements FieldsExposingAggregationOperation, InheritsFieldsAggregationOperation {
4141

42-
private final String from;
42+
private Object from;
4343

4444
@Nullable //
4545
private final Field localField;
@@ -97,6 +97,22 @@ public LookupOperation(String from, @Nullable Let let, AggregationPipeline pipel
9797
*/
9898
public LookupOperation(String from, @Nullable Field localField, @Nullable Field foreignField, @Nullable Let let,
9999
@Nullable AggregationPipeline pipeline, Field as) {
100+
this((Object) from, localField, foreignField, let, pipeline, as);
101+
}
102+
103+
/**
104+
* Creates a new {@link LookupOperation} for the given combination of {@link Field}s and {@link AggregationPipeline
105+
* pipeline}.
106+
*
107+
* @param from must not be {@literal null}. Can be eiter the target collection name or a {@link Class}.
108+
* @param localField can be {@literal null} if {@literal pipeline} is present.
109+
* @param foreignField can be {@literal null} if {@literal pipeline} is present.
110+
* @param let can be {@literal null} if {@literal localField} and {@literal foreignField} are present.
111+
* @param as must not be {@literal null}.
112+
* @since 4.1
113+
*/
114+
private LookupOperation(Object from, @Nullable Field localField, @Nullable Field foreignField, @Nullable Let let,
115+
@Nullable AggregationPipeline pipeline, Field as) {
100116

101117
Assert.notNull(from, "From must not be null");
102118
if (pipeline == null) {
@@ -125,12 +141,14 @@ public Document toDocument(AggregationOperationContext context) {
125141

126142
Document lookupObject = new Document();
127143

128-
lookupObject.append("from", from);
144+
lookupObject.append("from", getCollectionName(context));
145+
129146
if (localField != null) {
130147
lookupObject.append("localField", localField.getTarget());
131148
}
149+
132150
if (foreignField != null) {
133-
lookupObject.append("foreignField", foreignField.getTarget());
151+
lookupObject.append("foreignField", getForeignFieldName(context));
134152
}
135153
if (let != null) {
136154
lookupObject.append("let", let.toDocument(context).get("$let", Document.class).get("vars"));
@@ -144,6 +162,16 @@ public Document toDocument(AggregationOperationContext context) {
144162
return new Document(getOperator(), lookupObject);
145163
}
146164

165+
String getCollectionName(AggregationOperationContext context) {
166+
return from instanceof Class<?> type ? context.getCollection(type) : from.toString();
167+
}
168+
169+
String getForeignFieldName(AggregationOperationContext context) {
170+
171+
return from instanceof Class<?> type ? context.getMappedFieldName(type, foreignField.getTarget())
172+
: foreignField.getTarget();
173+
}
174+
147175
@Override
148176
public String getOperator() {
149177
return "$lookup";
@@ -158,16 +186,28 @@ public static FromBuilder newLookup() {
158186
return new LookupOperationBuilder();
159187
}
160188

161-
public static interface FromBuilder {
189+
public interface FromBuilder {
162190

163191
/**
164192
* @param name the collection in the same database to perform the join with, must not be {@literal null} or empty.
165193
* @return never {@literal null}.
166194
*/
167195
LocalFieldBuilder from(String name);
196+
197+
/**
198+
* Use the given type to determine name of the foreign collection and map
199+
* {@link ForeignFieldBuilder#foreignField(String)} against it to consider eventually present
200+
* {@link org.springframework.data.mongodb.core.mapping.Field} annotations.
201+
*
202+
* @param type the type of the target collection in the same database to perform the join with, must not be
203+
* {@literal null}.
204+
* @return never {@literal null}.
205+
* @since 4.2
206+
*/
207+
LocalFieldBuilder from(Class<?> type);
168208
}
169209

170-
public static interface LocalFieldBuilder extends PipelineBuilder {
210+
public interface LocalFieldBuilder extends PipelineBuilder {
171211

172212
/**
173213
* @param name the field from the documents input to the {@code $lookup} stage, must not be {@literal null} or
@@ -177,7 +217,7 @@ public static interface LocalFieldBuilder extends PipelineBuilder {
177217
ForeignFieldBuilder localField(String name);
178218
}
179219

180-
public static interface ForeignFieldBuilder {
220+
public interface ForeignFieldBuilder {
181221

182222
/**
183223
* @param name the field from the documents in the {@code from} collection, must not be {@literal null} or empty.
@@ -246,7 +286,7 @@ default AsBuilder pipeline(AggregationOperation... stages) {
246286
LookupOperation as(String name);
247287
}
248288

249-
public static interface AsBuilder extends PipelineBuilder {
289+
public interface AsBuilder extends PipelineBuilder {
250290

251291
/**
252292
* @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 static interface AsBuilder extends PipelineBuilder {
264304
public static final class LookupOperationBuilder
265305
implements FromBuilder, LocalFieldBuilder, ForeignFieldBuilder, AsBuilder {
266306

267-
private @Nullable String from;
307+
private @Nullable Object from;
268308
private @Nullable Field localField;
269309
private @Nullable Field foreignField;
270310
private @Nullable ExposedField as;
@@ -288,6 +328,14 @@ public LocalFieldBuilder from(String name) {
288328
return this;
289329
}
290330

331+
@Override
332+
public LocalFieldBuilder from(Class<?> type) {
333+
334+
Assert.notNull(type, "'From' must not be null");
335+
from = type;
336+
return this;
337+
}
338+
291339
@Override
292340
public AsBuilder foreignField(String name) {
293341

Diff for: spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/TypeBasedAggregationOperationContext.java

+14
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,20 @@ public FieldReference getReference(String name) {
9292
return getReferenceFor(field(name));
9393
}
9494

95+
@Override
96+
public String getCollection(Class<?> type) {
97+
98+
MongoPersistentEntity<?> persistentEntity = mappingContext.getPersistentEntity(type);
99+
return persistentEntity != null ? persistentEntity.getCollection() : AggregationOperationContext.super.getCollection(type);
100+
}
101+
102+
@Override
103+
public String getMappedFieldName(Class<?> type, String field) {
104+
105+
PersistentPropertyPath<MongoPersistentProperty> persistentPropertyPath = mappingContext.getPersistentPropertyPath(field, type);
106+
return persistentPropertyPath.getLeafProperty().getFieldName();
107+
}
108+
95109
@Override
96110
public Fields getFields(Class<?> type) {
97111

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright 2023. the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/*
18+
* Copyright 2023 the original author or authors.
19+
*
20+
* Licensed under the Apache License, Version 2.0 (the "License");
21+
* you may not use this file except in compliance with the License.
22+
* You may obtain a copy of the License at
23+
*
24+
* http://www.apache.org/licenses/LICENSE-2.0
25+
*
26+
* Unless required by applicable law or agreed to in writing, software
27+
* distributed under the License is distributed on an "AS IS" BASIS,
28+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
29+
* See the License for the specific language governing permissions and
30+
* limitations under the License.
31+
*/
32+
package org.springframework.data.mongodb.core.aggregation;
33+
34+
import org.springframework.data.mapping.context.MappingContext;
35+
import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
36+
import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver;
37+
import org.springframework.data.mongodb.core.convert.QueryMapper;
38+
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
39+
import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity;
40+
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
41+
import org.springframework.data.mongodb.test.util.MongoTestMappingContext;
42+
43+
/**
44+
* @author Christoph Strobl
45+
* @since 2023/06
46+
*/
47+
public final class AggregationTestUtils {
48+
49+
public static AggregationContextBuilder<TypeBasedAggregationOperationContext> strict(Class<?> type) {
50+
51+
AggregationContextBuilder<AggregationOperationContext> builder = new AggregationContextBuilder<>();
52+
builder.strict = true;
53+
return builder.forType(type);
54+
}
55+
56+
public static AggregationContextBuilder<TypeBasedAggregationOperationContext> relaxed(Class<?> type) {
57+
58+
AggregationContextBuilder<AggregationOperationContext> builder = new AggregationContextBuilder<>();
59+
builder.strict = false;
60+
return builder.forType(type);
61+
}
62+
63+
public static class AggregationContextBuilder<T extends AggregationOperationContext> {
64+
65+
Class<?> targetType;
66+
MappingContext<? extends MongoPersistentEntity<?>, MongoPersistentProperty> mappingContext;
67+
QueryMapper queryMapper;
68+
boolean strict;
69+
70+
public AggregationContextBuilder<TypeBasedAggregationOperationContext> forType(Class<?> type) {
71+
72+
this.targetType = type;
73+
return (AggregationContextBuilder<TypeBasedAggregationOperationContext>) this;
74+
}
75+
76+
public AggregationContextBuilder<T> using(
77+
MappingContext<? extends MongoPersistentEntity<?>, MongoPersistentProperty> mappingContext) {
78+
79+
this.mappingContext = mappingContext;
80+
return this;
81+
}
82+
83+
public AggregationContextBuilder<T> using(QueryMapper queryMapper) {
84+
85+
this.queryMapper = queryMapper;
86+
return this;
87+
}
88+
89+
public T ctx() {
90+
//
91+
if (targetType == null) {
92+
return (T) Aggregation.DEFAULT_CONTEXT;
93+
}
94+
95+
MappingContext<? extends MongoPersistentEntity<?>, MongoPersistentProperty> ctx = mappingContext != null ? mappingContext : MongoTestMappingContext.newTestContext().init();
96+
QueryMapper qm = queryMapper != null ? queryMapper
97+
: new QueryMapper(new MappingMongoConverter(NoOpDbRefResolver.INSTANCE, ctx));
98+
return (T) (strict ? new TypeBasedAggregationOperationContext(targetType, ctx, qm)
99+
: new RelaxedTypeBasedAggregationOperationContext(targetType, ctx, qm));
100+
}
101+
}
102+
}

Diff for: spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/LookupOperationUnitTests.java

+55-4
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.bson.Document;
2626
import org.junit.jupiter.api.Test;
2727
import org.springframework.data.mongodb.core.DocumentTestUtils;
28+
import org.springframework.data.mongodb.core.mapping.Field;
2829
import org.springframework.data.mongodb.core.query.Criteria;
2930

3031
/**
@@ -92,7 +93,7 @@ private Document extractDocumentFromLookupOperation(LookupOperation lookupOperat
9293

9394
@Test // DATAMONGO-1326
9495
public void builderRejectsNullFromField() {
95-
assertThatIllegalArgumentException().isThrownBy(() -> LookupOperation.newLookup().from(null));
96+
assertThatIllegalArgumentException().isThrownBy(() -> LookupOperation.newLookup().from((String) null));
9697
}
9798

9899
@Test // DATAMONGO-1326
@@ -195,10 +196,10 @@ void buildsLookupWithJustPipeline() {
195196
void buildsLookupWithLocalAndForeignFieldAsWellAsLetAndPipeline() {
196197

197198
LookupOperation lookupOperation = Aggregation.lookup().from("restaurants") //
198-
.localField("restaurant_name")
199-
.foreignField("name")
199+
.localField("restaurant_name") //
200+
.foreignField("name") //
200201
.let(newVariable("orders_drink").forField("drink")) //
201-
.pipeline(match(ctx -> new Document("$expr", new Document("$in", List.of("$$orders_drink", "$beverages")))))
202+
.pipeline(match(ctx -> new Document("$expr", new Document("$in", List.of("$$orders_drink", "$beverages"))))) //
202203
.as("matches");
203204

204205
assertThat(lookupOperation.toDocument(Aggregation.DEFAULT_CONTEXT)).isEqualTo("""
@@ -216,4 +217,54 @@ void buildsLookupWithLocalAndForeignFieldAsWellAsLetAndPipeline() {
216217
}}
217218
""");
218219
}
220+
221+
@Test // GH-4379
222+
void unmappedLookupWithFromExtractedFromType() {
223+
224+
LookupOperation lookupOperation = Aggregation.lookup().from(Restaurant.class) //
225+
.localField("restaurant_name") //
226+
.foreignField("name") //
227+
.as("restaurants");
228+
229+
assertThat(lookupOperation.toDocument(Aggregation.DEFAULT_CONTEXT)).isEqualTo("""
230+
{ $lookup:
231+
{
232+
from: "restaurant",
233+
localField: "restaurant_name",
234+
foreignField: "name",
235+
as: "restaurants"
236+
}
237+
}}
238+
""");
239+
}
240+
241+
@Test // GH-4379
242+
void mappedLookupWithFromExtractedFromType() {
243+
244+
LookupOperation lookupOperation = Aggregation.lookup().from(Restaurant.class) //
245+
.localField("restaurant_name") //
246+
.foreignField("name") //
247+
.as("restaurants");
248+
249+
250+
assertThat(lookupOperation.toDocument(AggregationTestUtils.strict(Restaurant.class).ctx())).isEqualTo("""
251+
{ $lookup:
252+
{
253+
from: "sites",
254+
localField: "restaurant_name",
255+
foreignField: "rs_name",
256+
as: "restaurants"
257+
}
258+
}}
259+
""");
260+
}
261+
262+
@org.springframework.data.mongodb.core.mapping.Document("sites")
263+
static class Restaurant {
264+
265+
String id;
266+
267+
@Field("rs_name") //
268+
String name;
269+
}
219270
}

0 commit comments

Comments
 (0)