Skip to content

Commit a2b25fb

Browse files
Support expressions in query field projections.
// explicit via dedicated AggregationExpression query.fields() .project(StringOperators.valueOf("name").toUpper()) .as("name"); // with a user provided expression parsed from a String query.fields().project(MongoExpression.from("'$toUpper' : '$name'")) .as("name") // using SpEL support query.fields().project(AggregationSpELExpression.expressionOf("toUpper(name)")) .as("name"); // with parameter binding query.fields().project( MongoExpression.from("'$toUpper' : '?0'").bind("$name") ).as("name") // via the @query annotation on repositories @query(value = "{ 'id' : ?0 }", fields = "{ 'name': { '$toUpper': '$name' } }")
1 parent 8c44ab8 commit a2b25fb

File tree

10 files changed

+630
-7
lines changed

10 files changed

+630
-7
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* Copyright 2021 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+
* https://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+
package org.springframework.data.mongodb;
17+
18+
import org.bson.Document;
19+
import org.bson.codecs.DocumentCodec;
20+
import org.bson.codecs.configuration.CodecRegistry;
21+
import org.springframework.data.mongodb.util.json.ParameterBindingDocumentCodec;
22+
import org.springframework.data.util.Lazy;
23+
import org.springframework.lang.Nullable;
24+
import org.springframework.util.ObjectUtils;
25+
import org.springframework.util.StringUtils;
26+
27+
/**
28+
* A {@link MongoExpression} using the {@link ParameterBindingDocumentCodec} for parsing the {@literal json} expression.
29+
* The expression will be wrapped within <code>{ }</code> if necessary. Placeholders like {@code ?0} are resolved when
30+
* first obtaining the target {@link Document} via {@link #toDocument()}.
31+
* <p />
32+
*
33+
* <pre class="code">
34+
* $toUpper : $name -> { '$toUpper' : '$name' }
35+
*
36+
* { '$toUpper' : '$name' } -> { '$toUpper' : '$name' }
37+
*
38+
* { '$toUpper' : '?0' }, "$name" -> { '$toUpper' : '$name' }
39+
* </pre>
40+
*
41+
* Some types (like {@link java.util.UUID}) cannot be used directly but require a special {@link org.bson.codecs.Codec}.
42+
* Make sure to provide a {@link CodecRegistry} containing the required {@link org.bson.codecs.Codec codecs} via
43+
* {@link #withCodecRegistry(CodecRegistry)}.
44+
*
45+
* @author Christoph Strobl
46+
* @since 3.2
47+
*/
48+
public class BindableMongoExpression implements MongoExpression {
49+
50+
private final String json;
51+
52+
@Nullable //
53+
private final CodecRegistryProvider codecRegistryProvider;
54+
55+
@Nullable //
56+
private final Object[] args;
57+
58+
private final Lazy<Document> target;
59+
60+
/**
61+
* Create a new instance of {@link BindableMongoExpression}.
62+
*
63+
* @param json must not be {@literal null}.
64+
* @param args can be {@literal null}.
65+
*/
66+
public BindableMongoExpression(String json, @Nullable Object[] args) {
67+
this(json, null, args);
68+
}
69+
70+
/**
71+
* Create a new instance of {@link BindableMongoExpression}.
72+
*
73+
* @param json must not be {@literal null}.
74+
* @param codecRegistryProvider can be {@literal null}.
75+
* @param args can be {@literal null}.
76+
*/
77+
public BindableMongoExpression(String json, @Nullable CodecRegistryProvider codecRegistryProvider,
78+
@Nullable Object[] args) {
79+
80+
this.json = wrapJsonIfNecessary(json);
81+
this.codecRegistryProvider = codecRegistryProvider;
82+
this.args = args;
83+
this.target = Lazy.of(this::parse);
84+
}
85+
86+
/**
87+
* Provide the {@link CodecRegistry} used to convert expressions.
88+
*
89+
* @param codecRegistry must not be {@literal null}.
90+
* @return new instance of {@link BindableMongoExpression}.
91+
*/
92+
public BindableMongoExpression withCodecRegistry(CodecRegistry codecRegistry) {
93+
return new BindableMongoExpression(json, () -> codecRegistry, args);
94+
}
95+
96+
/**
97+
* Provide the arguments to bind to the placeholders via their index.
98+
*
99+
* @param args must not be {@literal null}.
100+
* @return new instance of {@link BindableMongoExpression}.
101+
*/
102+
public BindableMongoExpression bind(Object... args) {
103+
return new BindableMongoExpression(json, codecRegistryProvider, args);
104+
}
105+
106+
/*
107+
* (non-Javadoc)
108+
*
109+
* @see org.springframework.data.mongodb.MongoExpression#toDocument()
110+
*/
111+
@Override
112+
public Document toDocument() {
113+
return target.get();
114+
}
115+
116+
private String wrapJsonIfNecessary(String json) {
117+
118+
if (StringUtils.hasText(json) && (json.startsWith("{") && json.endsWith("}"))) {
119+
return json;
120+
}
121+
122+
return "{" + json + "}";
123+
}
124+
125+
private Document parse() {
126+
127+
if (ObjectUtils.isEmpty(args)) {
128+
129+
if (codecRegistryProvider == null) {
130+
return Document.parse(json);
131+
}
132+
133+
return Document.parse(json, codecRegistryProvider.getCodecFor(Document.class)
134+
.orElseGet(() -> new DocumentCodec(codecRegistryProvider.getCodecRegistry())));
135+
}
136+
137+
ParameterBindingDocumentCodec codec = codecRegistryProvider == null ? new ParameterBindingDocumentCodec()
138+
: new ParameterBindingDocumentCodec(codecRegistryProvider.getCodecRegistry());
139+
return codec.decode(json, args);
140+
}
141+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2021 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+
* https://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+
package org.springframework.data.mongodb;
17+
18+
import java.util.function.Function;
19+
20+
/**
21+
* Wrapper object for MongoDB expressions like {@code $toUpper : $name} that manifest as {@link org.bson.Document} when
22+
* passed on to the driver.
23+
* <p />
24+
* A set of predefined {@link MongoExpression expressions}, including a
25+
* {@link org.springframework.data.mongodb.core.aggregation.AggregationSpELExpression SpEL based variant} for method
26+
* like expressions (eg. {@code toUpper(name)}) are available via the
27+
* {@link org.springframework.data.mongodb.core.aggregation Aggregation API}.
28+
*
29+
* @author Christoph Strobl
30+
* @since 3.2
31+
* @see org.springframework.data.mongodb.core.aggregation.ArithmeticOperators
32+
* @see org.springframework.data.mongodb.core.aggregation.ArrayOperators
33+
* @see org.springframework.data.mongodb.core.aggregation.ComparisonOperators
34+
* @see org.springframework.data.mongodb.core.aggregation.ConditionalOperators
35+
* @see org.springframework.data.mongodb.core.aggregation.ConvertOperators
36+
* @see org.springframework.data.mongodb.core.aggregation.DateOperators
37+
* @see org.springframework.data.mongodb.core.aggregation.ObjectOperators
38+
* @see org.springframework.data.mongodb.core.aggregation.SetOperators
39+
* @see org.springframework.data.mongodb.core.aggregation.StringOperators @
40+
*/
41+
@FunctionalInterface
42+
public interface MongoExpression {
43+
44+
/**
45+
* Obtain the native {@link org.bson.Document} representation.
46+
*
47+
* @return never {@literal null}.
48+
*/
49+
org.bson.Document toDocument();
50+
51+
/**
52+
* Convert this instance to another expression applying the given conversion {@link Function}.
53+
*
54+
* @param function must not be {@literal null}.
55+
* @param <T>
56+
* @return never {@literal null}.
57+
*/
58+
default <T extends MongoExpression> T as(Function<MongoExpression, T> function) {
59+
return function.apply(this);
60+
}
61+
62+
/**
63+
* Create a new {@link MongoExpression} from plain String (eg. {@code $toUpper : $name}). <br />
64+
* The given source value will be wrapped with <code>{ }</code> to match an actual MongoDB {@link org.bson.Document}
65+
* if necessary.
66+
*
67+
* @param json must not be {@literal null}.
68+
* @return new instance of {@link MongoExpression}.
69+
*/
70+
static MongoExpression expressionFromString(String json) {
71+
return new BindableMongoExpression(json, null);
72+
}
73+
74+
/**
75+
* Create a new {@link MongoExpression} from plain String containing placeholders (eg. {@code $toUpper : ?0}) that
76+
* will be resolved on {@link #toDocument()}. <br />
77+
* The given source value will be wrapped with <code>{ }</code> to match an actual MongoDB {@link org.bson.Document}
78+
* if necessary.
79+
*
80+
* @param json must not be {@literal null}.
81+
* @return new instance of {@link MongoExpression}.
82+
*/
83+
static MongoExpression expressionFromString(String json, Object... args) {
84+
return new BindableMongoExpression(json, args);
85+
}
86+
}

spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryOperations.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import java.util.List;
1919
import java.util.Map;
20+
import java.util.Map.Entry;
2021
import java.util.Optional;
2122
import java.util.Set;
2223
import java.util.concurrent.ConcurrentHashMap;
@@ -31,8 +32,10 @@
3132
import org.springframework.data.mapping.PropertyReferenceException;
3233
import org.springframework.data.mapping.context.MappingContext;
3334
import org.springframework.data.mongodb.CodecRegistryProvider;
35+
import org.springframework.data.mongodb.MongoExpression;
3436
import org.springframework.data.mongodb.core.MappedDocument.MappedUpdate;
3537
import org.springframework.data.mongodb.core.aggregation.Aggregation;
38+
import org.springframework.data.mongodb.core.aggregation.AggregationExpression;
3639
import org.springframework.data.mongodb.core.aggregation.AggregationOperationContext;
3740
import org.springframework.data.mongodb.core.aggregation.AggregationOptions;
3841
import org.springframework.data.mongodb.core.aggregation.AggregationPipeline;
@@ -288,7 +291,22 @@ <T> Document getMappedQuery(@Nullable MongoPersistentEntity<T> entity) {
288291
Document getMappedFields(@Nullable MongoPersistentEntity<?> entity, Class<?> targetType,
289292
ProjectionFactory projectionFactory) {
290293

291-
Document fields = query.getFieldsObject();
294+
Document fields = new Document();
295+
296+
for (Entry<String, Object> entry : query.getFieldsObject().entrySet()) {
297+
298+
if (entry.getValue() instanceof MongoExpression) {
299+
300+
AggregationOperationContext ctx = entity == null ? Aggregation.DEFAULT_CONTEXT
301+
: new RelaxedTypeBasedAggregationOperationContext(entity.getType(), mappingContext, queryMapper);
302+
303+
fields.put(entry.getKey(),
304+
((MongoExpression) entry.getValue()).as(AggregationExpression::create).toDocument(ctx));
305+
} else {
306+
fields.put(entry.getKey(), entry.getValue());
307+
}
308+
}
309+
292310
Document mappedFields = fields;
293311

294312
if (entity == null) {

spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationExpression.java

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package org.springframework.data.mongodb.core.aggregation;
1717

1818
import org.bson.Document;
19+
import org.springframework.data.mongodb.MongoExpression;
1920

2021
/**
2122
* An {@link AggregationExpression} can be used with field expressions in aggregation pipeline stages like
@@ -25,7 +26,36 @@
2526
* @author Oliver Gierke
2627
* @author Christoph Strobl
2728
*/
28-
public interface AggregationExpression {
29+
public interface AggregationExpression extends MongoExpression {
30+
31+
/**
32+
* Obtain the as is (unmapped) representation of the {@link AggregationExpression}. Use
33+
* {@link #toDocument(AggregationOperationContext)} with a matching {@link AggregationOperationContext context} to
34+
* engage domain type mapping including field name resolution.
35+
*
36+
* @see org.springframework.data.mongodb.MongoExpression#toDocument()
37+
*/
38+
@Override
39+
default Document toDocument() {
40+
return toDocument(Aggregation.DEFAULT_CONTEXT);
41+
}
42+
43+
/**
44+
* Create an {@link AggregationExpression} out of a given {@link MongoExpression}. <br />
45+
* If the given expression is already an {@link AggregationExpression} return the very same instance.
46+
*
47+
* @param expression must not be {@literal null}.
48+
* @return never {@literal null}.
49+
* @since 3.2
50+
*/
51+
static AggregationExpression create(MongoExpression expression) {
52+
53+
if (expression instanceof AggregationExpression) {
54+
return AggregationExpression.class.cast(expression);
55+
}
56+
57+
return (context) -> context.getMappedObject(expression.toDocument());
58+
}
2959

3060
/**
3161
* Turns the {@link AggregationExpression} into a {@link Document} within the given

spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import org.bson.Document;
2525
import org.bson.conversions.Bson;
2626
import org.bson.types.ObjectId;
27-
2827
import org.springframework.core.convert.ConversionService;
2928
import org.springframework.core.convert.converter.Converter;
3029
import org.springframework.data.domain.Example;
@@ -37,6 +36,7 @@
3736
import org.springframework.data.mapping.PropertyReferenceException;
3837
import org.springframework.data.mapping.context.InvalidPersistentPropertyPath;
3938
import org.springframework.data.mapping.context.MappingContext;
39+
import org.springframework.data.mongodb.MongoExpression;
4040
import org.springframework.data.mongodb.core.convert.MappingMongoConverter.NestedDocument;
4141
import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity;
4242
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
@@ -295,6 +295,10 @@ protected Entry<String, Object> getMappedObjectForField(Field field, Object rawV
295295
String key = field.getMappedKey();
296296
Object value;
297297

298+
if (rawValue instanceof MongoExpression) {
299+
return createMapEntry(key, getMappedObject(((MongoExpression) rawValue).toDocument(), field.getEntity()));
300+
}
301+
298302
if (isNestedKeyword(rawValue) && !field.isIdField()) {
299303
Keyword keyword = new Keyword((Document) rawValue);
300304
value = getMappedKeyword(field, keyword);
@@ -934,6 +938,11 @@ public MongoPersistentEntity<?> getPropertyEntity() {
934938
return null;
935939
}
936940

941+
@Nullable
942+
MongoPersistentEntity<?> getEntity() {
943+
return null;
944+
}
945+
937946
/**
938947
* Returns whether the field represents an association.
939948
*
@@ -1086,6 +1095,12 @@ public MongoPersistentEntity<?> getPropertyEntity() {
10861095
return property == null ? null : mappingContext.getPersistentEntity(property);
10871096
}
10881097

1098+
@Nullable
1099+
@Override
1100+
public MongoPersistentEntity<?> getEntity() {
1101+
return entity;
1102+
}
1103+
10891104
/*
10901105
* (non-Javadoc)
10911106
* @see org.springframework.data.mongodb.core.convert.QueryMapper.Field#isAssociation()

0 commit comments

Comments
 (0)