1. Introduction

In this tutorial, we’ll explore how to create queries with multiple criteria in MongoDB using Spring Data JPA.

2. Setting up the Project

To start, we need to include the necessary dependencies in our project. We’ll add the Spring Data MongoDB starter dependency to our pom.xml file:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb</artifactId>
    <version>3.3.1</version>
</dependency>

This dependency allows us to use Spring Data MongoDB functionalities in our Spring Boot project.

2.1. Defining the MongoDB Document and Repository

Next, we define a MongoDB document, which is a Java class annotated with @Document. This class maps to a collection in MongoDB. For example, let’s create a Product document:

@Document(collection = "products")
public class Product {
    @Id
    private String id;
    private String name;
    private String category;
    private double price;
    private boolean available;

    // Getters and setters
}

In Spring Data MongoDB, we can create a custom repository to define our own query methods. By injecting MongoTemplate, we can perform advanced operations on the MongoDB database. This class provides a rich set of methods for executing queries, aggregating data, and handling CRUD operations effectively:

@Repository
public class CustomProductRepositoryImpl implements CustomProductRepository {
    @Autowired
    private MongoTemplate mongoTemplate;

    @Override
    public List find(Query query, Class entityClass) {
        return mongoTemplate.find(query, entityClass);
    }
}

2.2. Sample Data in MongoDB

Before we begin writing queries, let’s assume we have the following sample data in our MongoDB products collection:

[
    {
        "name": "MacBook Pro M3",
        "price": 1500,
        "category": "Laptop",
        "available": true
    },
    {
        "name": "MacBook Air M2",
        "price": 1000,
        "category": "Laptop",
        "available": false
    },
    {
        "name": "iPhone 13",
        "price": 800,
        "category": "Phone",
        "available": true
    }
]

This data will help us test our queries effectively.

3. Building MongoDB Queries

When constructing complex queries in Spring Data MongoDB, we leverage methods like andOperator() and orOperator() to combine multiple conditions effectively. These methods are crucial for creating queries that require documents to satisfy multiple conditions simultaneously or alternatively.

3.1. Using addOperator()

The andOperator() method is used to combine multiple criteria with an AND operator. This means that all the criteria must be true for a document to match the query. This is useful when we need to enforce that multiple conditions are met.

Here’s how we can construct this query using the andOperator():

List<Product> findProductsUsingAndOperator(String name, int minPrice, String category, boolean available) {
    Query query = new Query();
    query.addCriteria(new Criteria().andOperator(Criteria.where("name")
      .is(name), Criteria.where("price")
      .gt(minPrice), Criteria.where("category")
      .is(category), Criteria.where("available")
      .is(available)));
   return customProductRepository.find(query, Product.class);
}

Suppose we want to retrieve a laptop named “MacBook Pro M3” with a price greater than $1000 and ensure it’s available in stock:

List<Product> actualProducts = productService.findProductsUsingAndOperator("MacBook Pro M3", 1000, "Laptop", true);

assertThat(actualProducts).hasSize(1);
assertThat(actualProducts.get(0).getName()).isEqualTo("MacBook Pro M3");

3.2. Using orOperator()

Conversely, the orOperator() method combines multiple criteria with an OR operator. This means that any one of the specified criteria must be true for a document to match the query. This is useful when retrieving documents that match at least one of several conditions.

Here’s how we can construct this query using the orOperator():

List<Product> findProductsUsingOrOperator(String category, int minPrice) {
    Query query = new Query();
    query.addCriteria(new Criteria().orOperator(Criteria.where("category")
      .is(category), Criteria.where("price")
      .gt(minPrice)));

    return customProductRepository.find(query, Product.class);
}

If we want to retrieve products that either belong to the “Laptop” category or have a price greater than $1000, we can invoke the method:

actualProducts = productService.findProductsUsingOrOperator("Laptop", 1000);
assertThat(actualProducts).hasSize(2);

3.3. Combining andOperator() and orOperator()

We can create complex queries by combining both andOperator() and orOperator() methods:

List<Product> findProductsUsingAndOperatorAndOrOperator(String category1, int price1, String name1, boolean available1) {
    Query query = new Query();
    query.addCriteria(new Criteria().orOperator(
      new Criteria().andOperator(
        Criteria.where("category").is(category1),
        Criteria.where("price").gt(price1)),
      new Criteria().andOperator(
        Criteria.where("name").is(name1),
        Criteria.where("available").is(available1)
      )
    ));

    return customProductRepository.find(query, Product.class);
}

In this method, we create a Query object and use the orOperator() to define the main structure of our criteria. Within this, we specify two conditions using andOperator().* For instance, we can retrieve products that either belong to the “Laptop” category with a price greater than $1000 or are named “*MacBook Pro M3″ and are available in stock:

actualProducts = productService.findProductsUsingAndOperatorAndOrOperator("Laptop", 1000, "MacBook Pro M3", true);

assertThat(actualProducts).hasSize(1);
assertThat(actualProducts.get(0).getName()).isEqualTo("MacBook Pro M3");

3.4. Using Chain Methods

Moreover, we can utilize the Criteria class to construct queries in a fluent style by chaining multiple conditions together using and() method. This approach provides a clear and concise way to define complex queries without losing readability:

List<Product> findProductsUsingChainMethod(String name1, int price1, String category1, boolean available1) {
    Criteria criteria = Criteria.where("name").is(name1)
      .and("price").gt(price1)
      .and("category").is(category1)
      .and("available").is(available1);
    return customProductRepository.find(new Query(criteria), Product.class);
}

When invoking this method, we expect to find one product named “MacBook Pro M3” that costs more than $1000 and is available in stock:

actualProducts = productService.findProductsUsingChainMethod("MacBook Pro M3", 1000, "Laptop", true);

assertThat(actualProducts).hasSize(1);
assertThat(actualProducts.get(0).getName()).isEqualTo("MacBook Pro M3");

4. @Query Annotation for Multiple Criteria

In addition to our custom repository using MongoTemplate, we can create a new repository interface that extends MongoRepository to utilize the @Query annotation for multiple criteria queries. This approach allows us to define complex queries directly in our repository without needing to build them programmatically.

We can define a custom method in our ProductRepository interface:

public interface ProductRepository extends MongoRepository<Product, String> {
    @Query("{ 'name': ?0, 'price': { $gt: ?1 }, 'category': ?2, 'available': ?3 }")
    List<Product> findProductsByNamePriceCategoryAndAvailability(String name, double minPrice, String category, boolean available);
    
    @Query("{ $or: [{ 'category': ?0, 'available': ?1 }, { 'price': { $gt: ?2 } } ] }")
    List<Product> findProductsByCategoryAndAvailabilityOrPrice(String category, boolean available, double minPrice);
}

The first method, findProductsByNamePriceCategoryAndAvailability(), retrieves products that match all specified criteria. This includes the exact name of the product, a price greater than a specified minimum, the category the product belongs to, and whether the product is available in stock:

actualProducts = productRepository.findProductsByNamePriceCategoryAndAvailability("MacBook Pro M3", 1000, "Laptop",  true);

assertThat(actualProducts).hasSize(1);
assertThat(actualProducts.get(0).getName()).isEqualTo("MacBook Pro M3");

On the other hand, the second method, findProductsByCategoryAndAvailabilityOrPrice(), offers a more flexible approach. It finds products that either belong to a specific category and are available or have a price greater than the specified minimum:

actualProducts = productRepository.findProductsByCategoryAndAvailabilityOrPrice("Laptop", false, 600);

assertThat(actualProducts).hasSize(3);

5. Using QueryDSL

QueryDSL is a framework that allows us to construct type-safe queries programmatically. Let’s walk through setting up and using QueryDSL for handling multiple criteria queries in our Spring Data MongoDB project.

5.1. Adding QueryDSL Dependency

First, we need to include QueryDSL in our project. We can do this by adding the following dependency to our pom.xml file:

<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-mongodb</artifactId>
    <version>5.1.0</version>
</dependency>

5.2. Generating Q Classes

QueryDSL requires generating helper classes for our domain objects. These classes, typically named with a “Q” prefix (e.g., QProduct), provide type-safe access to our entity fields. We can automate this generation process using the Maven plugin:

<plugin>
    <groupId>com.mysema.maven</groupId>
    <artifactId>apt-maven-plugin</artifactId>
    <version>1.1.3</version>
    <executions>
        <execution>
            <goals>
                <goal>process</goal>
            </goals>
            <configuration>
                <outputDirectory>target/generated-sources/java</outputDirectory>
                <processor>org.springframework.data.mongodb.repository.support.MongoAnnotationProcessor</processor>
            </configuration>
        </execution>
    </executions>
</plugin>

When the build process runs this configuration, the annotation processor generates Q classes for each of our MongoDB documents. For instance, if we have a Product class, it generates a QProduct class. This QProduct class provides type-safe access to the fields of the Product entity, allowing us to construct queries in a more structured and error-free way using QueryDSL.

Next, we need to modify our repository to extend QuerydslPredicateExecutor:

public interface ProductRepository extends MongoRepository<Product, String>, QuerydslPredicateExecutor<Product> {
}

5.3. Using AND with QueryDSL

In QueryDSL, we can construct complex queries using the Predicate interface, which represents a boolean expression. The and() method allows us to combine multiple conditions, ensuring that all specified criteria are satisfied for a document to match the query:

List<Product> findProductsUsingQueryDSLWithAndCondition(String category, boolean available, String name, double minPrice) {
    QProduct qProduct = QProduct.product;
    Predicate predicate = qProduct.category.eq(category)
      .and(qProduct.available.eq(available))
      .and(qProduct.name.eq(name))
      .and(qProduct.price.gt(minPrice));

    return StreamSupport.stream(productRepository.findAll(predicate).spliterator(), false)
      .collect(Collectors.toList());
}

In this method, we first create an instance of QProduct. We then construct a Predicate that combines several conditions using the and() method. Finally, we execute the query using productRepository.findAll(predicate), which retrieves all matching products based on the constructed predicate:

actualProducts = productService.findProductsUsingQueryDSLWithAndCondition("Laptop", true, "MacBook Pro M3", 1000);

assertThat(actualProducts).hasSize(1);
assertThat(actualProducts.get(0).getName()).isEqualTo("MacBook Pro M3");

5.4. Using OR With QueryDSL

We can also construct queries using the or() method, which allows us to combine multiple conditions with a logical OR operator. This means that a document matches the query if any of the specified criteria are satisfied.

Let’s create a method that finds products using QueryDSL with an OR condition:

List<Product> findProductsUsingQueryDSLWithOrCondition(String category, String name, double minPrice) {
    QProduct qProduct = QProduct.product;
    Predicate predicate = qProduct.category.eq(category)
      .or(qProduct.name.eq(name))
      .or(qProduct.price.gt(minPrice));

    return StreamSupport.stream(productRepository.findAll(predicate).spliterator(), false)
      .collect(Collectors.toList());
}

The or() method ensures that a product matches the query if any of these conditions are true:

actualProducts = productService.findProductsUsingQueryDSLWithOrCondition("Laptop", "MacBook", 800);

assertThat(actualProducts).hasSize(2);

5.4. Combining AND and OR With QueryDSL

We can also combine both and() and or() methods within our predicates. This flexibility allows us to specify conditions where some criteria must be true while others can be alternative conditions. Here’s an example of how to combine and() and or() in a single query:

List<Product> findProductsUsingQueryDSLWithAndOrCondition(String category, boolean available, String name, double minPrice) {
    QProduct qProduct = QProduct.product;
    Predicate predicate = qProduct.category.eq(category)
      .and(qProduct.available.eq(available))
      .or(qProduct.name.eq(name).and(qProduct.price.gt(minPrice)));

    return StreamSupport.stream(productRepository.findAll(predicate).spliterator(), false)
      .collect(Collectors.toList());
}

In this method, we construct the query by combining conditions with and() and or(). This allows us to build a query that matches products either in a specific category with a price greater than a specified amount or products with a specific name that are available:

actualProducts = productService.findProductsUsingQueryDSLWithAndOrCondition("Laptop", true, "MacBook Pro M3", 1000);
assertThat(actualProducts).hasSize(3);

6. Conclusion

In this article, we’ve explored various approaches for constructing queries with multiple criteria in Spring Data MongoDB. For straightforward queries with a few criteria, Criteria or chain methods might be sufficient due to their simplicity. However, if the queries involve complex logic with multiple conditions and nesting, using @Query annotations or QueryDSL is generally recommended due to their improved readability and maintainability.

As always, the source code for the examples is available over on GitHub.