Sometimes when querying a MongoDB document what you will actually need is an item of a given document's embedded collections
The issue with MongoDB is that mongo will filter out only first level documents that correspond to your query but it will not filter out embedded documents that do not correspond to your query criteria (i.e. If you are trying to find a specific book in a book saga) you will get the complete 'parent' documents with the entire sub collection you were trying to filter
So what can you do when you wish to filter a sub-collection to extract 1 subdocument ?
Enter the $elementMatch operator. Introduced in the 2.1 version the $elemMatch projection operator limits the contents of an array field that is included in the query results to contain only the array element that matches the predicate expressed by the operator.
Let's say I have a model like the following :
{ "id": "1", "series": "A song of ice and Fire", "author" : "George R.R. Martin", "books" : [ { "title" : "A Game of Thrones", "pubYear" : 1996, "seriesNumber" : 1 }, { "title" : "A Clash of Kings", "pubYear" : 1998, "seriesNumber" : 2 }, { "title" : "A Storm of Swords", "pubYear" : 2000, "seriesNumber" : 3 }, { "title" : "A Feast for Crows", "pubYear" : 2005, "seriesNumber" : 4 }, { "title" : "A Dance with Dragons", "pubYear" : 2011, "seriesNumber" : 5 }, { "title" : "The Winds of Winter", "pubYear" : 2014, "seriesNumber" : 6 }, { "title" : "A Dream of Spring", "pubYear" : 2100, "seriesNumber" : 7 } ] }
Now let's say for example that I'm interested only in the 5th book of a given book series (A Song of ice and fire for example)
This can be accomplished in a number of different ways :
- MapReduce functions : Supported by Spring data but maybe a bit cumbersome for what we are trying to accomplish (I will write in the future a tutorial on how to use MongoDB MapReduce functions with Spring Data and making those functions "templatable"
- Mongo's aggregation framework : Version 2.1 introduced the aggregation framework that lets you do a lot of stuff (more info here ) however the aggregation framework is not currently supported by Spring Data
- the $elemMatch operator
But what is the $elemMatch operator, and how do you use it?
Well You could say $elemMatch is a multi-purpose operator as it can be used either as part of the query but also as part of the fields object, where it acts as a simplified MapReduce kind-of :)
But as always there is a caveat though when using $elemMatch operator, and that is that if more than 1 embedded document matches your criteria, only the first one will be returned as stated MongoDB documentation
Now when using spring data with mongodb you can relatively easily do field projection using the fields() object exposed by the Query class like so :
Query query = Query.query(Criteria.where("series").is("A song of ice and Fire")); query.fields().include("books");
However the Query class, even though is very easy to use and manipulate, doesn't give you access to some of Mongo's more powerful mechanisms like for example the $elemMatch as a projection operator.
In order for you to use the $elemMatch operator as a projection constraint you need to use a subclass of org.springframework.data.mongodb.core.query.Query i.e. org.springframework.data.mongodb.core.query.BasicQuery
So let's get down to business with an example.
Let's say we are interested only in the 4th book out of George R.R. Martin's saga a Song of ice and fire
Now if you were using the traditional Query class you will probably end up writing a 2 step logic that would look something like this (unless you were using a MapReduce function)
1. Retrieve the parent Book document that correspond to your search criteria
/** *Returns the parent book corresponding to the sagaName criteria with the unfiltered child collection books */ public Book findBookNumberInSaga(String sagaName, Integer bookNumber){ Query query = Query.query(Criteria.where("series").is(sagaName).and("books.seriesNumber").is(bookNumber)); MongoTemplate template = getMongoTemplate(); return template.find(query); }
2. From the parent document iterate through the books collection (or use LambdaJ :p ) to recover the book you a really interested in
public class WithoutElememMatch{ public static void main(String[] args){ Book saga = findBookNumberInSaga("A song of ice and Fire", 4); Book numberFor = null; Iterator<Book> books = saga.getBooks(); while (books.hasNext()){ Book currentBook= books.next(); if(book.getSeriesNumber() == 4){ numberFor = currentBook; } } } //... }
Even though the previous example is certantly not the best way to implement this logic, it works fine and it serves it purpose
Now let's implement the same thing but this time we will make the database work for us :
1. Fetch the book corresponding to the requested criteria but make the database do the work for you
Here we ask MongoDB to filter the elements in the sub-collection books to the one matching our criteria (i.e. the number 4 in the series)
/** * Returns the parent book corresponding to the sagaName criteria with a size 1 collection 'children' collection whose seriesNumber * property corresponds to the value of the seriesNumber argument */ public Book findBookNumberInSaga(String sagaName, Integer bookNumber){ // the query object Criteria findSeriesCriteria = Criteria.where("title").is(title); // the field object Criteria findSagaNumberCriteria = Criteria.where("books").elemMatch(Criteria.where("seriesNumber").is(seriesNumber)); BasicQuery query = new BasicQuery(findSeriesCriteria.getCriteriaObject(), findSagaNumberCriteria.getCriteriaObject()); return mongoOperations.find(query, Book.class); } // omitted mongo template initialization
As you can see this time I didn't use the org.springframework.data.mongodb.core.query.Query class to build my query but instead I used the org.springframework.data.mongodb.core.query.BasicQuery class; because as I stated before you can only do projection in the field object by using these class
You will notice that the syntax for this class is a bit different as it takes 2 DbObjects (which are basically HashMaps) 1 for the query object and one for the field object. Much like the the mongo shell client syntax :
db.collection.find(, )
So now our method is implemented we can finally call it and check the results :
public class WithElememMatch{ public static void main(String[] args){ // get the parent book Book parentBook = findBookNumberInSaga("A song of ice and Fire", 4); Book numberFor; // null checks if(book != null && CollectionUtils.isNotEmpty(book.getBooks()){ // get the only book we are interested in numberFor = parentBook.getBooks().get(0); } } //... }
So there you go hope this was useful to you, as usual you can find the code of this tutorial over at my github account here
Note : for this tutorial you will only find the 2 java classes mentioned earlier, there's no Spring / Spring data configuration (that's for another tutorial)
Thanks for this it was very useful. I tried this but get only the _id of the object back with everything else null. Pretty sure i'm in line with your example. think i need to be able to unwind the array as i only want elements matching a value in the list. How can you $unwind with spring mongo? not seen anything indicating that it can be done. Thanks again.
ReplyDeleteTB.
Hi,
ReplyDeleteWhat version of Spring Data are you using?.
In order to do Aggregation operations such as $unwind you need to use Spring Data 1.3+ which added support for Mongo's Aggregation framework ( see here http://docs.spring.io/spring-data/data-mongodb/docs/current/reference/htmlsingle/#mongo.aggregation).
Basically you do a static import of the package org.springframework.data.mongodb.core.aggregation.Aggregation.*; and play with the aggregation operators.
Ulises
Thank you. It worked for me!!!
ReplyDeletefindBookNumberInSaga has a parameter that is unused. The same function uses a title variable that doesn't exist.
ReplyDelete