Skip to content

Commit 536dcc1

Browse files
christophstroblodrotbohm
authored andcommitted
DATAMONGO-1565 - Ignore placeholder pattern in replacement values for annotated queries.
We now make sure to quote single and double ticks in the replacement values before actually appending them to the query. We also replace single ticks around parameters in the actual raw annotated query by double quotes to make sure they are treated as a single string parameter.
1 parent dc44c3a commit 536dcc1

File tree

2 files changed

+213
-35
lines changed

2 files changed

+213
-35
lines changed

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

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

18-
import java.util.Collections;
18+
import java.util.ArrayList;
19+
import java.util.LinkedHashMap;
1920
import java.util.List;
21+
import java.util.Map;
22+
import java.util.NoSuchElementException;
23+
import java.util.regex.Matcher;
24+
import java.util.regex.Pattern;
2025

2126
import javax.xml.bind.DatatypeConverter;
2227

@@ -94,47 +99,67 @@ private String replacePlaceholders(String input, MongoParameterAccessor accessor
9499
return input;
95100
}
96101

97-
boolean isCompletlyParameterizedQuery = input.matches("^\\?\\d+$");
98-
StringBuilder result = new StringBuilder(input);
99-
100-
for (ParameterBinding binding : bindingContext.getBindings()) {
102+
if (input.matches("^\\?\\d+$")) {
103+
return getParameterValueForBinding(accessor, bindingContext.getParameters(),
104+
bindingContext.getBindings().iterator().next());
105+
}
101106

102-
String parameter = binding.getParameter();
103-
int idx = result.indexOf(parameter);
107+
Matcher matcher = createReplacementPattern(bindingContext.getBindings()).matcher(input);
108+
StringBuffer buffer = new StringBuffer();
104109

105-
if (idx == -1) {
106-
continue;
107-
}
110+
while (matcher.find()) {
108111

112+
ParameterBinding binding = bindingContext.getBindingFor(extractPlaceholder(matcher.group()));
109113
String valueForBinding = getParameterValueForBinding(accessor, bindingContext.getParameters(), binding);
110114

111-
int start = idx;
112-
int end = idx + parameter.length();
115+
// appendReplacement does not like unescaped $ sign and others, so we need to quote that stuff first
116+
matcher.appendReplacement(buffer, Matcher.quoteReplacement(valueForBinding));
117+
118+
if (binding.isQuoted()) {
119+
postProcessQuotedBinding(buffer, valueForBinding);
120+
}
121+
}
122+
123+
matcher.appendTail(buffer);
124+
return buffer.toString();
125+
}
113126

114-
// If the value to bind is an object literal we need to remove the quoting around the expression insertion point.
115-
if (valueForBinding.startsWith("{") && !isCompletlyParameterizedQuery) {
127+
/**
128+
* Sanitize String binding by replacing single quoted values with double quotes which prevents potential single quotes
129+
* contained in replacement to interfere with the Json parsing. Also take care of complex objects by removing the
130+
* quotation entirely.
131+
*
132+
* @param buffer the {@link StringBuffer} to operate upon.
133+
* @param valueForBinding the actual binding value.
134+
*/
135+
private void postProcessQuotedBinding(StringBuffer buffer, String valueForBinding) {
116136

117-
// Is the insertion point actually surrounded by quotes?
118-
char beforeStart = result.charAt(start - 1);
119-
char afterEnd = result.charAt(end);
137+
int quotationMarkIndex = buffer.length() - valueForBinding.length() - 1;
138+
char quotationMark = buffer.charAt(quotationMarkIndex);
120139

121-
if ((beforeStart == '\'' || beforeStart == '"') && (afterEnd == '\'' || afterEnd == '"')) {
140+
while (quotationMark != '\'' && quotationMark != '"') {
122141

123-
// Skip preceding and following quote
124-
start -= 1;
125-
end += 1;
126-
}
142+
quotationMarkIndex--;
143+
if (quotationMarkIndex < 0) {
144+
throw new IllegalArgumentException("Could not find opening quotes for quoted parameter");
127145
}
128-
129-
result.replace(start, end, valueForBinding);
146+
quotationMark = buffer.charAt(quotationMarkIndex);
130147
}
131148

132-
return result.toString();
149+
if (valueForBinding.startsWith("{")) { // remove quotation char before the complex object string
150+
buffer.deleteCharAt(quotationMarkIndex);
151+
} else {
152+
153+
if (quotationMark == '\'') {
154+
buffer.replace(quotationMarkIndex, quotationMarkIndex + 1, "\"");
155+
}
156+
buffer.append("\"");
157+
}
133158
}
134159

135160
/**
136161
* Returns the serialized value to be used for the given {@link ParameterBinding}.
137-
*
162+
*
138163
* @param accessor must not be {@literal null}.
139164
* @param parameters
140165
* @param binding must not be {@literal null}.
@@ -148,7 +173,7 @@ private String getParameterValueForBinding(MongoParameterAccessor accessor, Mong
148173
: accessor.getBindableValue(binding.getParameterIndex());
149174

150175
if (value instanceof String && binding.isQuoted()) {
151-
return (String) value;
176+
return ((String) value).startsWith("{") ? (String) value : ((String) value).replace("\"", "\\\"");
152177
}
153178

154179
if (value instanceof byte[]) {
@@ -167,7 +192,7 @@ private String getParameterValueForBinding(MongoParameterAccessor accessor, Mong
167192

168193
/**
169194
* Evaluates the given {@code expressionString}.
170-
*
195+
*
171196
* @param expressionString must not be {@literal null} or empty.
172197
* @param parameters must not be {@literal null}.
173198
* @param parameterValues must not be {@literal null}.
@@ -181,25 +206,59 @@ private Object evaluateExpression(String expressionString, MongoParameters param
181206
return expression.getValue(evaluationContext, Object.class);
182207
}
183208

209+
/**
210+
* Creates a replacement {@link Pattern} for all {@link ParameterBinding#getParameter() binding parameters} including
211+
* a potentially trailing quotation mark.
212+
*
213+
* @param bindings
214+
* @return
215+
*/
216+
private Pattern createReplacementPattern(List<ParameterBinding> bindings) {
217+
218+
StringBuilder regex = new StringBuilder();
219+
for (ParameterBinding binding : bindings) {
220+
regex.append("|");
221+
regex.append(Pattern.quote(binding.getParameter()));
222+
regex.append("['\"]?"); // potential quotation char (as in { foo : '?0' }).
223+
}
224+
225+
return Pattern.compile(regex.substring(1));
226+
}
227+
228+
/**
229+
* Extract the placeholder stripping any trailing trailing quotation mark that might have resulted from the
230+
* {@link #createReplacementPattern(List) pattern} used.
231+
*
232+
* @param groupName The actual {@link Matcher#group() group}.
233+
* @return
234+
*/
235+
private String extractPlaceholder(String groupName) {
236+
237+
if (!groupName.endsWith("'") && !groupName.endsWith("\"")) {
238+
return groupName;
239+
}
240+
return groupName.substring(0, groupName.length() - 1);
241+
}
242+
184243
/**
185244
* @author Christoph Strobl
186245
* @since 1.9
187246
*/
188247
static class BindingContext {
189248

190249
final MongoParameters parameters;
191-
final List<ParameterBinding> bindings;
250+
final Map<String, ParameterBinding> bindings;
192251

193252
/**
194253
* Creates new {@link BindingContext}.
195-
*
254+
*
196255
* @param parameters
197256
* @param bindings
198257
*/
199258
public BindingContext(MongoParameters parameters, List<ParameterBinding> bindings) {
200259

201260
this.parameters = parameters;
202-
this.bindings = bindings;
261+
this.bindings = mapBindings(bindings);
203262
}
204263

205264
/**
@@ -211,11 +270,28 @@ boolean hasBindings() {
211270

212271
/**
213272
* Get unmodifiable list of {@link ParameterBinding}s.
214-
*
273+
*
215274
* @return never {@literal null}.
216275
*/
217276
public List<ParameterBinding> getBindings() {
218-
return Collections.unmodifiableList(bindings);
277+
return new ArrayList<ParameterBinding>(bindings.values());
278+
}
279+
280+
/**
281+
* Get the concrete {@link ParameterBinding} for a given {@literal placeholder}.
282+
*
283+
* @param placeholder must not be {@literal null}.
284+
* @return
285+
* @throws java.util.NoSuchElementException
286+
* @since 1.10
287+
*/
288+
ParameterBinding getBindingFor(String placeholder) {
289+
290+
if (!bindings.containsKey(placeholder)) {
291+
throw new NoSuchElementException(String.format("Could not to find binding for placeholder '%s'.", placeholder));
292+
}
293+
294+
return bindings.get(placeholder);
219295
}
220296

221297
/**
@@ -227,5 +303,13 @@ public MongoParameters getParameters() {
227303
return parameters;
228304
}
229305

306+
private static Map<String, ParameterBinding> mapBindings(List<ParameterBinding> bindings) {
307+
308+
Map<String, ParameterBinding> map = new LinkedHashMap<String, ParameterBinding>(bindings.size(), 1);
309+
for (ParameterBinding binding : bindings) {
310+
map.put(binding.getParameter(), binding);
311+
}
312+
return map;
313+
}
230314
}
231315
}

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

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
import com.mongodb.BasicDBObjectBuilder;
5555
import com.mongodb.DBObject;
5656
import com.mongodb.DBRef;
57+
import com.mongodb.util.JSON;
5758

5859
/**
5960
* Unit tests for {@link StringBasedMongoQuery}.
@@ -178,8 +179,8 @@ public void preventsDeleteAndCountFlagAtTheSameTime() throws Exception {
178179
@Test
179180
public void shouldSupportFindByParameterizedCriteriaAndFields() throws Exception {
180181

181-
ConvertingParameterAccessor accessor = StubParameterAccessor.getAccessor(converter, new Object[] {
182-
new BasicDBObject("firstname", "first").append("lastname", "last"), Collections.singletonMap("lastname", 1) });
182+
ConvertingParameterAccessor accessor = StubParameterAccessor.getAccessor(converter,
183+
new BasicDBObject("firstname", "first").append("lastname", "last"), Collections.singletonMap("lastname", 1));
183184
StringBasedMongoQuery mongoQuery = createQueryForMethod("findByParameterizedCriteriaAndFields", DBObject.class,
184185
Map.class);
185186

@@ -362,6 +363,96 @@ public void shouldSupportNonQuotedBinaryDataReplacement() throws Exception {
362363
assertThat(query.getQueryObject(), is(reference.getQueryObject()));
363364
}
364365

366+
/**
367+
* @see DATAMONGO-1565
368+
*/
369+
@Test
370+
public void shouldIgnorePlaceholderPatternInReplacementValue() throws Exception {
371+
372+
ConvertingParameterAccessor accesor = StubParameterAccessor.getAccessor(converter, "argWith?1andText",
373+
"nothing-special");
374+
375+
StringBasedMongoQuery mongoQuery = createQueryForMethod("findByStringWithWildcardChar", String.class, String.class);
376+
377+
org.springframework.data.mongodb.core.query.Query query = mongoQuery.createQuery(accesor);
378+
assertThat(query.getQueryObject(),
379+
is(JSON.parse("{ \"arg0\" : \"argWith?1andText\" , \"arg1\" : \"nothing-special\"}")));
380+
}
381+
382+
/**
383+
* @see DATAMONGO-1565
384+
*/
385+
@Test
386+
public void shouldQuoteStringReplacementCorrectly() throws Exception {
387+
388+
StringBasedMongoQuery mongoQuery = createQueryForMethod("findByLastnameQuoted", String.class);
389+
ConvertingParameterAccessor accesor = StubParameterAccessor.getAccessor(converter, "Matthews', password: 'foo");
390+
391+
org.springframework.data.mongodb.core.query.Query query = mongoQuery.createQuery(accesor);
392+
assertThat(query.getQueryObject(),
393+
is(not(new BasicDBObjectBuilder().add("lastname", "Matthews").add("password", "foo").get())));
394+
assertThat(query.getQueryObject(), is((DBObject) new BasicDBObject("lastname", "Matthews', password: 'foo")));
395+
}
396+
397+
/**
398+
* @see DATAMONGO-1565
399+
*/
400+
@Test
401+
public void shouldQuoteStringReplacementContainingQuotesCorrectly() throws Exception {
402+
403+
StringBasedMongoQuery mongoQuery = createQueryForMethod("findByLastnameQuoted", String.class);
404+
ConvertingParameterAccessor accesor = StubParameterAccessor.getAccessor(converter, "Matthews\", password: \"foo");
405+
406+
org.springframework.data.mongodb.core.query.Query query = mongoQuery.createQuery(accesor);
407+
assertThat(query.getQueryObject(),
408+
is(not(new BasicDBObjectBuilder().add("lastname", "Matthews").add("password", "foo").get())));
409+
assertThat(query.getQueryObject(), is((DBObject) new BasicDBObject("lastname", "Matthews\", password: \"foo")));
410+
}
411+
412+
/**
413+
* @see DATAMONGO-1565
414+
*/
415+
@Test
416+
public void shouldQuoteStringReplacementWithQuotationsCorrectly() throws Exception {
417+
418+
StringBasedMongoQuery mongoQuery = createQueryForMethod("findByLastnameQuoted", String.class);
419+
ConvertingParameterAccessor accesor = StubParameterAccessor.getAccessor(converter,
420+
"\"Dave Matthews\", password: 'foo");
421+
422+
org.springframework.data.mongodb.core.query.Query query = mongoQuery.createQuery(accesor);
423+
assertThat(query.getQueryObject(),
424+
is((DBObject) new BasicDBObject("lastname", "\"Dave Matthews\", password: 'foo")));
425+
}
426+
427+
/**
428+
* @see DATAMONGO-1565
429+
*/
430+
@Test
431+
public void shouldQuoteComplexQueryStringCorreclty() throws Exception {
432+
433+
StringBasedMongoQuery mongoQuery = createQueryForMethod("findByLastnameQuoted", String.class);
434+
ConvertingParameterAccessor accesor = StubParameterAccessor.getAccessor(converter, "{ $ne : \"calamity\" }");
435+
436+
org.springframework.data.mongodb.core.query.Query query = mongoQuery.createQuery(accesor);
437+
assertThat(query.getQueryObject(),
438+
is((DBObject) new BasicDBObject("lastname", new BasicDBObject("$ne", "calamity"))));
439+
}
440+
441+
/**
442+
* @see DATAMONGO-1565
443+
*/
444+
@Test
445+
public void shouldQuotationInQuotedComplexQueryString() throws Exception {
446+
447+
StringBasedMongoQuery mongoQuery = createQueryForMethod("findByLastnameQuoted", String.class);
448+
ConvertingParameterAccessor accesor = StubParameterAccessor.getAccessor(converter,
449+
"{ $ne : \"\\\"calamity\\\"\" }");
450+
451+
org.springframework.data.mongodb.core.query.Query query = mongoQuery.createQuery(accesor);
452+
assertThat(query.getQueryObject(),
453+
is((DBObject) new BasicDBObject("lastname", new BasicDBObject("$ne", "\"calamity\""))));
454+
}
455+
365456
private StringBasedMongoQuery createQueryForMethod(String name, Class<?>... parameters) throws Exception {
366457

367458
Method method = SampleRepository.class.getMethod(name, parameters);
@@ -420,5 +511,8 @@ private interface SampleRepository extends Repository<Person, Long> {
420511

421512
@Query("{'id':?#{ [0] ? { $exists :true} : [1] }, 'foo':42, 'bar': ?#{ [0] ? { $exists :false} : [1] }}")
422513
List<Person> findByQueryWithExpressionAndMultipleNestedObjects(boolean param0, String param1, String param2);
514+
515+
@Query("{ 'arg0' : ?0, 'arg1' : ?1 }")
516+
List<Person> findByStringWithWildcardChar(String arg0, String arg1);
423517
}
424518
}

0 commit comments

Comments
 (0)