Skip to content

Commit 12f5ad8

Browse files
DATAMONGO-1245 - Add support for Query By Example.
!!! ATTENTION !!! This is just an explorative approach to QBE trying find possibilities and limitations. Since o.s.d.domain.Example needs to be in spring-data-commons one has to run the build with bundlor disabled '-Dbundlor.enabled=false'. !!! ATTENTION !!! We now support querying documents by providing a sample of the given object holding compare values. For the sake of partial matching we flatten out nested structures so we can create different queries for matching like: { _id : 1, nested : { value : "conflux" } } { _id : 1, nested.value : { "conflux" } } This is useful when you want so search using a only partially filled nested document. String matching can be configured to wrap strings with $regex which creates { firstname : { $regex : "^foo", $options: "i" } } when using StringMatchMode.STARTING along with the ignoreCaseOption. DBRefs and geo structures such as Point or GeoJsonPoint is converted to their according structure.
1 parent 69effc9 commit 12f5ad8

22 files changed

+1330
-54
lines changed
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* Copyright 2015 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+
package org.springframework.data.domain;
17+
18+
import org.springframework.util.Assert;
19+
import org.springframework.util.ClassUtils;
20+
21+
/**
22+
* @author Christoph Strobl
23+
* @param <T>
24+
*/
25+
public class Example<T> {
26+
27+
private final T probe;
28+
private ObjectMatchMode objectMatchMode = ObjectMatchMode.LENIENT;
29+
private StringMatchMode stringMatchMode = StringMatchMode.DEFAULT;
30+
private boolean ignoreCaseEnabled = false;
31+
32+
public <S extends T> Example(S probe) {
33+
34+
Assert.notNull(probe, "Probe must not be null!");
35+
this.probe = probe;
36+
}
37+
38+
public T getProbe() {
39+
return probe;
40+
}
41+
42+
public ObjectMatchMode getObjectMatchMode() {
43+
return objectMatchMode;
44+
}
45+
46+
public StringMatchMode getStringMatchMode() {
47+
return stringMatchMode;
48+
}
49+
50+
public boolean isIngnoreCaseEnabled() {
51+
return this.ignoreCaseEnabled;
52+
}
53+
54+
@SuppressWarnings("unchecked")
55+
public Class<? extends T> getProbeType() {
56+
return (Class<? extends T>) ClassUtils.getUserClass(probe.getClass());
57+
}
58+
59+
public static <S extends T, T> Example<T> example(S probe) {
60+
return new Example<T>(probe);
61+
}
62+
63+
public static class ExampleBuilder<T> {
64+
65+
private Example<T> example;
66+
67+
public ExampleBuilder(T probe) {
68+
example = new Example<T>(probe);
69+
}
70+
71+
public ExampleBuilder<T> objectMatchMode(ObjectMatchMode matchMode) {
72+
73+
example.objectMatchMode = matchMode == null ? ObjectMatchMode.LENIENT : matchMode;
74+
return this;
75+
}
76+
77+
public ExampleBuilder<T> stringMatchMode(StringMatchMode matchMode) {
78+
79+
example.stringMatchMode = matchMode == null ? StringMatchMode.DEFAULT : matchMode;
80+
return this;
81+
}
82+
83+
public ExampleBuilder<T> stringMatchMode(StringMatchMode matchMode, boolean ignoreCase) {
84+
85+
example.stringMatchMode = matchMode == null ? StringMatchMode.DEFAULT : matchMode;
86+
example.ignoreCaseEnabled = ignoreCase;
87+
return this;
88+
}
89+
90+
public Example<T> get() {
91+
return this.example;
92+
}
93+
}
94+
95+
/**
96+
* Match modes indicates inclusion of complex objects.
97+
*
98+
* @author Christoph Strobl
99+
*/
100+
public static enum ObjectMatchMode {
101+
/**
102+
* Strict matching will use partially filled objects as reference.
103+
*/
104+
STRICT,
105+
/**
106+
* Lenient matching will inspected nested objects and extract path if needed.
107+
*/
108+
LENIENT
109+
}
110+
111+
/**
112+
* Match modes indicates treatment of {@link String} values.
113+
*
114+
* @author Christoph Strobl
115+
*/
116+
public static enum StringMatchMode {
117+
118+
/**
119+
* Store specific default.
120+
*/
121+
DEFAULT,
122+
/**
123+
* Matches the exact string
124+
*/
125+
EXACT,
126+
/**
127+
* Matches string starting with pattern
128+
*/
129+
STARTING,
130+
/**
131+
* Matches string ending with pattern
132+
*/
133+
ENDING,
134+
/**
135+
* Matches string containing pattern
136+
*/
137+
CONTAINING,
138+
/**
139+
* Treats strings as regular expression patterns
140+
*/
141+
REGEX
142+
}
143+
144+
// TODO: add default null handling
145+
// TODO: add default String handling
146+
// TODO: add per field null handling
147+
// TODO: add per field String handling
148+
149+
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import org.springframework.data.annotation.Id;
5252
import org.springframework.data.authentication.UserCredentials;
5353
import org.springframework.data.convert.EntityReader;
54+
import org.springframework.data.domain.Example;
5455
import org.springframework.data.geo.Distance;
5556
import org.springframework.data.geo.GeoResult;
5657
import org.springframework.data.geo.GeoResults;
@@ -638,6 +639,17 @@ public <T> T findById(Object id, Class<T> entityClass, String collectionName) {
638639
return doFindOne(collectionName, new BasicDBObject(idKey, id), null, entityClass);
639640
}
640641

642+
public <S extends T, T> List<T> findByExample(S sample) {
643+
return findByExample(new Example<S>(sample));
644+
}
645+
646+
@SuppressWarnings("unchecked")
647+
public <S extends T, T> List<T> findByExample(Example<S> sample) {
648+
649+
Assert.notNull(sample, "Sample object must not be null!");
650+
return (List<T>) find(new Query(new Criteria().alike(sample)), sample.getProbeType());
651+
}
652+
641653
public <T> GeoResults<T> geoNear(NearQuery near, Class<T> entityClass) {
642654
return geoNear(near, entityClass, determineCollectionName(entityClass));
643655
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ public Point convert(DBObject source) {
117117

118118
Assert.isTrue(source.keySet().size() == 2, "Source must contain 2 elements");
119119

120+
if (source.containsField("type")) {
121+
return DbObjectToGeoJsonPointConverter.INSTANCE.convert(source);
122+
}
123+
120124
return new Point((Double) source.get("x"), (Double) source.get("y"));
121125
}
122126
}

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

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,18 @@
2020
import java.util.Collections;
2121
import java.util.Iterator;
2222
import java.util.List;
23+
import java.util.Map;
2324
import java.util.Map.Entry;
2425
import java.util.Set;
26+
import java.util.regex.Pattern;
2527

2628
import org.bson.types.ObjectId;
2729
import org.springframework.core.convert.ConversionException;
2830
import org.springframework.core.convert.ConversionService;
2931
import org.springframework.core.convert.converter.Converter;
32+
import org.springframework.data.domain.Example;
33+
import org.springframework.data.domain.Example.ObjectMatchMode;
34+
import org.springframework.data.domain.Example.StringMatchMode;
3035
import org.springframework.data.mapping.Association;
3136
import org.springframework.data.mapping.PersistentEntity;
3237
import org.springframework.data.mapping.PropertyPath;
@@ -39,9 +44,11 @@
3944
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
4045
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty.PropertyToFieldNameConverter;
4146
import org.springframework.data.mongodb.core.query.Query;
47+
import org.springframework.data.mongodb.core.query.SerializationUtils;
4248
import org.springframework.data.util.ClassTypeInformation;
4349
import org.springframework.data.util.TypeInformation;
4450
import org.springframework.util.Assert;
51+
import org.springframework.util.ObjectUtils;
4552

4653
import com.mongodb.BasicDBList;
4754
import com.mongodb.BasicDBObject;
@@ -239,9 +246,93 @@ protected DBObject getMappedKeyword(Keyword keyword, MongoPersistentEntity<?> en
239246
return new BasicDBObject(keyword.getKey(), newConditions);
240247
}
241248

249+
if (keyword.isSample()) {
250+
return getMappedExample(keyword.<Example<?>> getValue(), entity);
251+
}
252+
242253
return new BasicDBObject(keyword.getKey(), convertSimpleOrDBObject(keyword.getValue(), entity));
243254
}
244255

256+
/**
257+
* Returns the given {@link Example} as {@link DBObject} holding matching values extracted from
258+
* {@link Example#getProbe()}.
259+
*
260+
* @param example
261+
* @param entity
262+
* @return
263+
* @since 1.8
264+
*/
265+
protected DBObject getMappedExample(Example<?> example, MongoPersistentEntity<?> entity) {
266+
267+
DBObject reference = (DBObject) converter.convertToMongoType(example.getProbe());
268+
269+
if (entity.hasIdProperty() && entity.getIdentifierAccessor(example.getProbe()).getIdentifier() == null) {
270+
reference.removeField(entity.getIdProperty().getFieldName());
271+
}
272+
273+
if (!ObjectUtils.nullSafeEquals(StringMatchMode.DEFAULT, example.getStringMatchMode())
274+
|| example.isIngnoreCaseEnabled()) {
275+
applyStringPattern(reference, example);
276+
}
277+
278+
return ObjectUtils.nullSafeEquals(ObjectMatchMode.STRICT, example.getObjectMatchMode()) ? reference
279+
: new BasicDBObject(SerializationUtils.flatMap(reference));
280+
}
281+
282+
private void applyStringPattern(DBObject source, Example<?> example) {
283+
284+
if (!(source instanceof BasicDBObject)) {
285+
return;
286+
}
287+
Iterator<Map.Entry<String, Object>> iter = ((BasicDBObject) source).entrySet().iterator();
288+
289+
while (iter.hasNext()) {
290+
291+
Map.Entry<String, Object> entry = iter.next();
292+
if (entry.getValue() instanceof String) {
293+
294+
// TODO: extract common stuff from MongoQueryCreator
295+
BasicDBObject dbo = new BasicDBObject();
296+
switch (example.getStringMatchMode()) {
297+
298+
case REGEX:
299+
dbo.put("$regex", entry.getValue());
300+
entry.setValue(dbo);
301+
break;
302+
case DEFAULT:
303+
dbo.put("$regex", Pattern.quote((String) entry.getValue()));
304+
entry.setValue(dbo);
305+
break;
306+
case CONTAINING:
307+
dbo.put("$regex", ".*" + entry.getValue() + ".*");
308+
entry.setValue(dbo);
309+
break;
310+
case STARTING:
311+
dbo.put("$regex", "^" + entry.getValue());
312+
entry.setValue(dbo);
313+
break;
314+
case ENDING:
315+
dbo.put("$regex", entry.getValue() + "$");
316+
entry.setValue(dbo);
317+
break;
318+
case EXACT:
319+
dbo.put("$regex", "^" + entry.getValue() + "$");
320+
entry.setValue(dbo);
321+
break;
322+
default:
323+
}
324+
325+
// sometimes order matters in MongoDB so make sure to add $options after $regex.
326+
if (example.isIngnoreCaseEnabled()) {
327+
dbo.put("$options", "i");
328+
}
329+
330+
} else if (entry.getValue() instanceof BasicDBObject) {
331+
applyStringPattern((BasicDBObject) entry.getValue(), example);
332+
}
333+
}
334+
}
335+
245336
/**
246337
* Returns the mapped keyword considered defining a criteria for the given property.
247338
*
@@ -254,8 +345,8 @@ protected DBObject getMappedKeyword(Field property, Keyword keyword) {
254345
boolean needsAssociationConversion = property.isAssociation() && !keyword.isExists();
255346
Object value = keyword.getValue();
256347

257-
Object convertedValue = needsAssociationConversion ? convertAssociation(value, property)
258-
: getMappedValue(property.with(keyword.getKey()), value);
348+
Object convertedValue = needsAssociationConversion ? convertAssociation(value, property) : getMappedValue(
349+
property.with(keyword.getKey()), value);
259350

260351
return new BasicDBObject(keyword.key, convertedValue);
261352
}
@@ -477,8 +568,8 @@ public Object convertId(Object id) {
477568
}
478569

479570
try {
480-
return conversionService.canConvert(id.getClass(), ObjectId.class) ? conversionService.convert(id, ObjectId.class)
481-
: delegateConvertToMongoType(id, null);
571+
return conversionService.canConvert(id.getClass(), ObjectId.class) ? conversionService
572+
.convert(id, ObjectId.class) : delegateConvertToMongoType(id, null);
482573
} catch (ConversionException o_O) {
483574
return delegateConvertToMongoType(id, null);
484575
}
@@ -566,6 +657,16 @@ public boolean isGeometry() {
566657
return "$geometry".equalsIgnoreCase(key);
567658
}
568659

660+
/**
661+
* Returns wheter the current keyword indicates a sample object.
662+
*
663+
* @return
664+
* @since 1.8
665+
*/
666+
public boolean isSample() {
667+
return "$sample".equalsIgnoreCase(key);
668+
}
669+
569670
public boolean hasIterableValue() {
570671
return value instanceof Iterable;
571672
}

0 commit comments

Comments
 (0)