Skip to content

Commit 14e326d

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 f026ab4 commit 14e326d

File tree

2 files changed

+213
-34
lines changed

2 files changed

+213
-34
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 & 1 deletion
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}.
@@ -179,7 +180,8 @@ public void preventsDeleteAndCountFlagAtTheSameTime() throws Exception {
179180
@Test
180181
public void shouldSupportFindByParameterizedCriteriaAndFields() throws Exception {
181182

182-
ConvertingParameterAccessor accessor = StubParameterAccessor.getAccessor(converter, new BasicDBObject("firstname", "first").append("lastname", "last"), Collections.singletonMap("lastname", 1));
183+
ConvertingParameterAccessor accessor = StubParameterAccessor.getAccessor(converter,
184+
new BasicDBObject("firstname", "first").append("lastname", "last"), Collections.singletonMap("lastname", 1));
183185
StringBasedMongoQuery mongoQuery = createQueryForMethod("findByParameterizedCriteriaAndFields", DBObject.class,
184186
Map.class);
185187

@@ -373,6 +375,96 @@ public void shouldSupportExistsProjection() throws Exception {
373375
assertThat(mongoQuery.isExistsQuery(), is(true));
374376
}
375377

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

378470
Method method = SampleRepository.class.getMethod(name, parameters);
@@ -434,5 +526,8 @@ private interface SampleRepository extends Repository<Person, Long> {
434526

435527
@Query(value = "{ 'lastname' : ?0 }", exists = true)
436528
boolean existsByLastname(String lastname);
529+
530+
@Query("{ 'arg0' : ?0, 'arg1' : ?1 }")
531+
List<Person> findByStringWithWildcardChar(String arg0, String arg1);
437532
}
438533
}

0 commit comments

Comments
 (0)