Skip to content

Commit 970182c

Browse files
author
Thomas Darimont
committed
DATAMONGO-420 - Improve support of quote handling for custom query parameters.
Introduced ParameterBindingParser which exposes parameter references in query strings as ParameterBindings. This allows us to detect whether a parameter reference in a query string is already quoted avoiding wrongly double-quoting the parameter value. Original pull request: #185.
1 parent 2f1857e commit 970182c

File tree

2 files changed

+196
-13
lines changed

2 files changed

+196
-13
lines changed

spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/StringBasedMongoQuery.java

Lines changed: 152 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
*/
1616
package org.springframework.data.mongodb.repository.query;
1717

18+
import static java.util.regex.Pattern.*;
19+
20+
import java.util.ArrayList;
21+
import java.util.Collections;
22+
import java.util.List;
1823
import java.util.regex.Matcher;
1924
import java.util.regex.Pattern;
2025

@@ -23,6 +28,7 @@
2328
import org.springframework.data.mongodb.core.MongoOperations;
2429
import org.springframework.data.mongodb.core.query.BasicQuery;
2530
import org.springframework.data.mongodb.core.query.Query;
31+
import org.springframework.util.StringUtils;
2632

2733
import com.mongodb.util.JSON;
2834

@@ -31,17 +37,19 @@
3137
*
3238
* @author Oliver Gierke
3339
* @author Christoph Strobl
40+
* @author Thomas Darimont
3441
*/
3542
public class StringBasedMongoQuery extends AbstractMongoQuery {
3643

3744
private static final String COUND_AND_DELETE = "Manually defined query for %s cannot be both a count and delete query at the same time!";
38-
private static final Pattern PLACEHOLDER = Pattern.compile("\\?(\\d+)");
3945
private static final Logger LOG = LoggerFactory.getLogger(StringBasedMongoQuery.class);
4046

4147
private final String query;
4248
private final String fieldSpec;
4349
private final boolean isCountQuery;
4450
private final boolean isDeleteQuery;
51+
private final List<ParameterBinding> queryParameterBindings;
52+
private final List<ParameterBinding> fieldSpecParameterBindings;
4553

4654
/**
4755
* Creates a new {@link StringBasedMongoQuery} for the given {@link MongoQueryMethod} and {@link MongoOperations}.
@@ -65,7 +73,12 @@ public StringBasedMongoQuery(String query, MongoQueryMethod method, MongoOperati
6573
super(method, mongoOperations);
6674

6775
this.query = query;
76+
this.queryParameterBindings = ParameterBindingParser.INSTANCE.parseParameterBindingsFrom(query);
77+
6878
this.fieldSpec = method.getFieldSpecification();
79+
this.fieldSpecParameterBindings = ParameterBindingParser.INSTANCE.parseParameterBindingsFrom(method
80+
.getFieldSpecification());
81+
6982
this.isCountQuery = method.hasAnnotatedQuery() ? method.getQueryAnnotation().count() : false;
7083
this.isDeleteQuery = method.hasAnnotatedQuery() ? method.getQueryAnnotation().delete() : false;
7184

@@ -81,12 +94,12 @@ public StringBasedMongoQuery(String query, MongoQueryMethod method, MongoOperati
8194
@Override
8295
protected Query createQuery(ConvertingParameterAccessor accessor) {
8396

84-
String queryString = replacePlaceholders(query, accessor);
97+
String queryString = replacePlaceholders(query, accessor, queryParameterBindings);
8598

8699
Query query = null;
87100

88101
if (fieldSpec != null) {
89-
String fieldString = replacePlaceholders(fieldSpec, accessor);
102+
String fieldString = replacePlaceholders(fieldSpec, accessor, fieldSpecParameterBindings);
90103
query = new BasicQuery(queryString, fieldString);
91104
} else {
92105
query = new BasicQuery(queryString);
@@ -119,21 +132,147 @@ protected boolean isDeleteQuery() {
119132
return this.isDeleteQuery;
120133
}
121134

122-
private String replacePlaceholders(String input, ConvertingParameterAccessor accessor) {
135+
/**
136+
* Replaced the parameter place-holders with the actual parameter values from the given {@link ParameterBinding}s.
137+
*
138+
* @param input
139+
* @param accessor
140+
* @param bindings
141+
* @return
142+
*/
143+
private String replacePlaceholders(String input, ConvertingParameterAccessor accessor, List<ParameterBinding> bindings) {
144+
145+
if (bindings.isEmpty()) {
146+
return input;
147+
}
148+
149+
StringBuilder result = new StringBuilder(input);
150+
151+
for (ParameterBinding binding : bindings) {
152+
153+
String parameter = binding.getParameter();
154+
int idx = result.indexOf(parameter);
155+
if (idx != -1) {
156+
result.replace(idx, idx + parameter.length(), getParameterValueForBinding(accessor, binding));
157+
}
158+
}
159+
160+
return result.toString();
161+
}
162+
163+
/**
164+
* Returns the serialized value to be used for the given {@link ParameterBinding}.
165+
*
166+
* @param accessor
167+
* @param binding
168+
* @return
169+
*/
170+
private String getParameterValueForBinding(ConvertingParameterAccessor accessor, ParameterBinding binding) {
123171

124-
Matcher matcher = PLACEHOLDER.matcher(input);
125-
String result = input;
172+
Object value = accessor.getBindableValue(binding.getParameterIndex());
126173

127-
while (matcher.find()) {
128-
String group = matcher.group();
129-
int index = Integer.parseInt(matcher.group(1));
130-
result = result.replace(group, getParameterWithIndex(accessor, index));
174+
if (value instanceof String && binding.isQuoted()) {
175+
return (String) value;
131176
}
132177

133-
return result;
178+
return JSON.serialize(value);
134179
}
135180

136-
private String getParameterWithIndex(ConvertingParameterAccessor accessor, int index) {
137-
return JSON.serialize(accessor.getBindableValue(index));
181+
/**
182+
* A parser that extracts the parameter bindings from a given query string.
183+
*
184+
* @author Thomas Darimont
185+
*/
186+
static enum ParameterBindingParser {
187+
188+
INSTANCE;
189+
190+
private static final Pattern PARAMETER_BINDING_PATTERN;
191+
192+
private final static int PARAMETER_INDEX_GROUP = 1;
193+
194+
static {
195+
196+
StringBuilder builder = new StringBuilder();
197+
builder.append("\\?(\\d+)"); // position parameter and parameter index
198+
builder.append("[^,'\"]*"); // followed by non quotes, non field separators
199+
builder.append("[,\"'}]?");
200+
201+
PARAMETER_BINDING_PATTERN = Pattern.compile(builder.toString(), CASE_INSENSITIVE);
202+
}
203+
204+
/**
205+
* Returns a list of {@link ParameterBinding}s found in the given {@code input} or an
206+
* {@link Collections#emptyList()}.
207+
*
208+
* @param input
209+
* @return
210+
*/
211+
public List<ParameterBinding> parseParameterBindingsFrom(String input) {
212+
213+
if (!StringUtils.hasText(input)) {
214+
return Collections.emptyList();
215+
}
216+
217+
List<ParameterBinding> bindings = new ArrayList<ParameterBinding>();
218+
219+
Matcher matcher = PARAMETER_BINDING_PATTERN.matcher(input);
220+
221+
while (matcher.find()) {
222+
223+
String group = matcher.group();
224+
225+
boolean parameterIsQuoted = group.endsWith("'") || group.endsWith("\"");
226+
int parameterIndex = Integer.parseInt(matcher.group(PARAMETER_INDEX_GROUP));
227+
228+
bindings.add(new ParameterBinding(parameterIndex, parameterIsQuoted));
229+
}
230+
231+
return bindings;
232+
}
233+
}
234+
235+
/**
236+
* A generic parameter binding with name or position information.
237+
*
238+
* @author Thomas Darimont
239+
*/
240+
static class ParameterBinding {
241+
242+
private final int parameterIndex;
243+
private final boolean quoted;
244+
245+
/**
246+
* Creates a new {@link ParameterBinding} with the given {@code parameterIndex}.
247+
*
248+
* @param parameterIndex
249+
*/
250+
public ParameterBinding(int parameterIndex) {
251+
this(parameterIndex, false);
252+
}
253+
254+
/**
255+
* Creates a new {@link ParameterBinding} with the given {@code parameterIndex} and {@code quoted} information.
256+
*
257+
* @param parameterIndex
258+
* @param quoted whether or not the parameter is already quoted.
259+
*/
260+
public ParameterBinding(int parameterIndex, boolean quoted) {
261+
262+
this.parameterIndex = parameterIndex;
263+
this.quoted = quoted;
264+
}
265+
266+
public boolean isQuoted() {
267+
return quoted;
268+
}
269+
270+
public int getParameterIndex() {
271+
return parameterIndex;
272+
}
273+
274+
public String getParameter() {
275+
return "?" + parameterIndex;
276+
}
138277
}
139278
}

spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/StringBasedMongoQueryUnitTests.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
import static org.mockito.Mockito.*;
2121

2222
import java.lang.reflect.Method;
23+
import java.util.Collections;
24+
import java.util.List;
25+
import java.util.Map;
2326

2427
import org.junit.Before;
2528
import org.junit.Test;
@@ -46,6 +49,7 @@
4649
*
4750
* @author Oliver Gierke
4851
* @author Christoph Strobl
52+
* @author Thomas Darimont
4953
*/
5054
@RunWith(MockitoJUnitRunner.class)
5155
public class StringBasedMongoQueryUnitTests {
@@ -158,6 +162,40 @@ public void preventsDeleteAndCountFlagAtTheSameTime() throws Exception {
158162
createQueryForMethod("invalidMethod", String.class);
159163
}
160164

165+
/**
166+
* @see DATAMONGO-420
167+
*/
168+
@Test
169+
public void shouldSupportFindByParameterizedCriteriaAndFields() throws Exception {
170+
171+
StringBasedMongoQuery mongoQuery = createQueryForMethod("findByParameterizedCriteriaAndFields", DBObject.class,
172+
Map.class);
173+
174+
ConvertingParameterAccessor accessor = StubParameterAccessor.getAccessor(converter, new Object[] {
175+
new BasicDBObject("firstname", "first").append("lastname", "last"), Collections.singletonMap("lastname", 1) });
176+
177+
org.springframework.data.mongodb.core.query.Query query = mongoQuery.createQuery(accessor);
178+
179+
assertThat(query.getQueryObject(),
180+
is(new BasicQuery("{ \"firstname\": \"first\", \"lastname\": \"last\"}").getQueryObject()));
181+
assertThat(query.getFieldsObject(), is(new BasicQuery(null, "{ \"lastname\": 1}").getFieldsObject()));
182+
}
183+
184+
/**
185+
* @see DATAMONGO-420
186+
*/
187+
@Test
188+
public void shouldSupportRespectExistingQuotingInFindByTitleBeginsWithExplicitQuoting() throws Exception {
189+
190+
StringBasedMongoQuery mongoQuery = createQueryForMethod("findByTitleBeginsWithExplicitQuoting", String.class);
191+
192+
ConvertingParameterAccessor accessor = StubParameterAccessor.getAccessor(converter, new Object[] { "fun" });
193+
194+
org.springframework.data.mongodb.core.query.Query query = mongoQuery.createQuery(accessor);
195+
196+
assertThat(query.getQueryObject(), is(new BasicQuery("{title: {$regex: '^fun', $options: 'i'}}").getQueryObject()));
197+
}
198+
161199
private StringBasedMongoQuery createQueryForMethod(String name, Class<?>... parameters) throws Exception {
162200

163201
Method method = SampleRepository.class.getMethod(name, parameters);
@@ -184,5 +222,11 @@ private interface SampleRepository {
184222

185223
@Query(value = "{ 'lastname' : ?0 }", delete = true, count = true)
186224
void invalidMethod(String lastname);
225+
226+
@Query(value = "?0", fields = "?1")
227+
DBObject findByParameterizedCriteriaAndFields(DBObject criteria, Map<String, Integer> fields);
228+
229+
@Query("{'title': { $regex : '^?0', $options : 'i'}}")
230+
List<DBObject> findByTitleBeginsWithExplicitQuoting(String title);
187231
}
188232
}

0 commit comments

Comments
 (0)