Introduction
Spring Data JPA MongoDB Expressions is a library that allows you to query Spring Data JPA Repositories using
MongoDB Query Language.
It parses (a subset of) MongoDB expressions (which itself a initiative and easy to learn Language) and convert them to
Spring Data JPA Specifications to be used with Spring Data JPA Repositories.
It is specially useful for so many JavaScript clients that like to build queries using convenient MongoDB expressions and send the query to the Spring-based API to be processed by the Repository.
The idea is that you can build the MongoDB query-like JSON from the frontend app and pass it to the Spring Controller, and then optionally enrich it with additional conditions and pass it to the JPA Repository, in which the MongoDB query will be translated automatically to JPA specification and executed.
Getting Started
It’s really easy to get started with Spring Data JPA MongoDB Expressions. This section shows you how.
Installation
Add the following dependency in your Spring/Spring Boot project, where you already have spring-data-jpa dependency added.
Maven
<dependency>
<groupId>com.github.mhewedy</groupId>
<artifactId>spring-data-jpa-mongodb-expressions</artifactId>
<version>0.1.3</version>
</dependency>
Gradle
implementation 'com.github.mhewedy:spring-data-jpa-mongodb-expressions:0.1.3'
Important
|
Spring Data JPA MongoDB Expressions will not bring any dependencies with it - this is by design - so to avoid version overlap in the dependencies. So make sure to setup your spring project to include the proper version of Spring Data JPA. |
How to start
Three easy steps you need to do to be able to use Spring Data JPA MongoDB Expressions:
Note
|
All public APIs (classes and interfaces) of Spring Data JPA MongoDB Expressions are in the package com.github.mhewedy.expressions.*
|
-
You need to customize the base repository to be the
ExpressionsRepositoryImpl
:@SpringBootApplication @EnableJpaRepositories(repositoryBaseClass = ExpressionsRepositoryImpl.class) public class MyApplication { }
You can learn more about customizing the base repository in the spring data jpa documentations
-
Change the parent repository of your JPA repositories to
ExpressionsRepository
@Repository public interface EmployeeRepository extends ExpressionsRepository<Employee, Long> { }
-
Modify the search controller to accept
Expressions
in its parameter list:@PostMapping("/search") public ResponseEntity<Page<EmployeeDto>> search(@RequestBody Expressions expressions, Pageable pageable) { return ok().body( employeeRepository.findAll(expressions, pageable).map(employeeMapper::toDto) ); }
And that’s it, you can now send Mongodb-like json queries to the API. (see Query Specifications )
Public API
Spring Data JPA MongoDB Expressions. has 3 main public APIs (one interface and two classes) that you need to be aware of .
ExpressionsRepository
interface
public interface ExpressionsRepository<T, ID> extends JpaRepository<T, ID> {
List<T> findAll(Expressions expressions);
List<T> findAll(Expressions expressions, Sort sort);
Page<T> findAll(Expressions expressions, Pageable pageable);
long count(Expressions expressions);
}
com.github.mhewedy.expressions.ExpressionsRepository
is the interface that your Repositories will need to extend and use
its methods to pass Expressions
objects received from the Rest Controller.
As seen, you can pass Pageable
object for sorting and paging as well.
Expressions
class
com.github.mhewedy.expressions.Expressions
class is the class used in the controller method to deserialize the query
JSON object into it. it will hold all the conditions passed from the frontend app.
The user can add more conditions at the backend (e.g. to enforce more restrictions). see javadoc.
Here’s the public API for the Expressions
class:
public class Expressions extends HashMap<String, Object> {
public Expressions or(Expression expression) {}
public Expressions and(Expression expression) {}
}
As shown above, it is mainly used to add ORed or ANDed expressions for an existing instance (usually deserialized from the JSON representation sent by the frontend app).
Note
|
The two public methods in Expressions object accepts objects of type Expression
|
Expression
class
One more class is com.github.mhewedy.expressions.Expression
which represents a single expression that can be
added to the Expressions
object.
Here’s the public API for the Expression
class:
public abstract class Expression {
public static Expression of(String field, Operator operator, String value) {}
public static Expression of(String field, Operator operator, Number value) {}
// .....
// .....
public static Expression and(Expression... expressions) {}
public static Expression or(Expression... expressions) {}
public Expression and(Expression expression) {}
public Expression or(Expression expression) {}
public Expressions build() {}
}
As shown, it is used mainly as a static factory to create Expression
instance then convert it to Expressions
using the
build
method or pass it to one of the two methods of the Expressions
class that accept the Expression
object.
Examples:
-
Using
Expression
static builder methods to create a single Expression and ANDing it to theExpressions
object:
Expressions expressions = ... // accepted as a parameter for the controller method
expressions.and(Expression.or(
Expression.of("lastName", Operator.$eq, "ibrahim"),
Expression.of("age", Operator.$in, 10, 30)
));
-
Using
Expression
static builder methods withbuild
method to createExpressions
object:
Expressions expressions = Expression.of("lastName", Operator.$eq, "ibrahim")
.and(Expression.or(
Expression.of("age", Operator.$in, 10, 20),
Expression.of("birthDate", Operator.$lt, LocalDate.of(1980, 1, 1)))
).build();
Query Specifications
Overview
Before we go into details of the query language, we will go through the steps it takes to convert JSON
-encoded
MongoDB
-like Query to SQL query.
- The first step (yellow)
-
The query is created at the frontend app (Angular, React, Vue, etc…) in JSON format according to MongoDB Query language rules and as will be shown in this section.
- The second step (green)
-
The JSON is being deserialized into
com.github.mhewedy.expressions.Expressions
object and thus can be passed to your Repository (the one the extendscom.github.mhewedy.expressions.ExpressionsRepository
).This intermediate step allows you to add additional conditions easily on the deserialized
Expressions
object as it is not uncommon you need to restrict the query passed from the frontend.
Note
|
You can see the Public API for details on how to use the Expressions and Expression objects.
|
- The third step (red)
-
Where the
Expressions
object is being converted into Spring Data JPASpecification
object and then Spring Data JPA along with the underlying Persistence provider will take care of the rest (including paging and sorting, etc…).
Queries
To get an idea about the queries in this section, here’s the domain that the queries will apply on:
Note
|
You might need to have a look on the supported operators list before proceed to the next section. |
Basic Queries
-
Basic query:
{ "lastName": "ibrahim", "birthDate": {"$lte": "1985-10-10"} }
Generated SQL:
... where last_name=? and birth_date<=?
-
Basic queries with comparison operators:
{ "lastName": "ibrahim", "$and": [ { "birthDate": {"$gt": "1981-01-01"} }, { "birthDate": {"$lte": "1985-10-10"} } ] }
Generated SQL:
... where last_name=? and birth_date>? and birth_date<=?
TipIn case of passing a string value into a field of type java.time
API (e.g.LocalDate
,Instant
, etc…), the methodparse
on the corresponding type is responsible of convert the string into an object of that type.NoteHowever the default logical operator is $and
, in the above example we had to add it explicitly, this is because inJSON
you cannot have duplicated object keys (in this case would bebirthDate
). -
Send search query with no conditions:
{}
Generated SQL:
... where ?=1
Note?
in the query parameter above is bound to the valuetrue
-
Search with
null
value (is null
andis not null
):{ "firstName": null }
// in the controller: expressions.or(Expression.of("lastName", Operator.$ne, (String) null));
Generated SQL:
... where first_name is null or last_name is not null
Logical operators
According to MongoDB query language, the default logical operator if omitted is the $and
operator.
The following are examples of complex logical operators that follows operator precedence.
-
Complex case with multiple OR and AND Expressions:
{ "$or": [ { "lastName": "ibrahim" }, { "$and": [ { "firstName": "mostafa" }, { "birthDate": { "$gt": "1990-01-01" } } ] } ] }
Generated SQL:
... where last_name = ? or first_name = ? and birth_date > ?
-
Complex case with multiple OR and AND Expressions:
{ "$and": [ { "lastName": "ibrahim" }, { "$or": [ { "firstName": "ahmed" }, { "birthDate": { "$lt": "1990-01-01" } } ] } ] }
Generated SQL:
... where last_name = ? and (first_name = ? or birth_date < ?)
Text Operators
-
Using Contains for search in fields:
{ "lastName": { "$contains": "rah" } }
Generated SQL:
... where last_name like ?
-
Using Contains ignore case for search in fields:
{ "lastName": { "$icontains": "Rah" } }
Generated SQL:
... where lower(last_name) like ?
-
Using contains for number fields:
{ "age": { "$contains": "0" } }
Generated SQL:
... from employee e where cast(e.age as varchar(255)) like ?
Note
|
Other text operators are supported as well, such as $start , $end , $istart , $iend
|
Tip
|
the i character at the start of the text operator donates case-insensitive nature of the operator.
|
Paging and sorting
Due to being built on top of Spring Data JPA, Spring Data JPA MongoDB Expressions library is fully compatible with the Paging and Sorting features of Spring Data JPA.
-
Paging and sorting in the url:
/api/search?page=0&size=20&sort=id,desc
-
Paging and sorting in code:
{ "hireDate": {"$lt": "2021-01-01T00:00:00Z"} }
// in the controller: Pageable pageable = PageRequest.of(0, 3, Sort.by("firstName").descending()); // or from the controller method parameters Page<Employee> employeeList = employeeRepository.findAll(expressions, pageable);
Generated SQL:
... where hire_date<? order by first_name desc limit ?
Joins
Many to one
-
Many to one Join:
{ "lastName": "ibrahim", "department.name": {"$contains": "sw"} }
Generated SQL:
... from employee e inner join department d on e.department_id=d.id where e.last_name=? and (d.name like ?)
-
Many to one Join with
IN
queries:{ "department.name": {"$in": ["hr", "sw arch"]} }
Generated SQL:
... employee e inner join department d on e.department_id=d.id where d.name in (? , ?)
-
Using Many to one Join with deep nested level:
{ "lastName": "ibrahim", "department.city.name": "cairo" }
Generated SQL:
... from employee e inner join department d on e.department_id=d.id inner join city c on d.city_id=c.id where e.last_name=? and c.name=?
-
Many to One with multiple fields of the One association in the where condition :
{ "lastName": "ibrahim", "department.id": 10, "department.name": "hr" }
Generated SQL:
... from employee e inner join department d on e.department_id=d.id where e.last_name=? and d.id=? and d.name=?
-
Many to one where association is
null
:{ "department": null }
Generated SQL:
... from employee e where e.department is null
One to Many
-
Using One to Many join:
{ "tasks.name": {"$contains": "fix"} }
Generated SQL:
... from employee e inner join task t on e.id=t.employee_id where t.name like ?
NoteThe distinct
keyword is being used in the queries to eliminate duplicates in case ofone-to-many
andmany-to-many
joins.
Tip
|
Sometimes the join capabilities is not enough or the not best way for the use case you trying to implement, in such case you can create a database view and map it to an JPA Entity and then build your search on it. |
Tip
|
The returned properties are the properties of the primary Entity, which means the projection is not supported due to limitation in spring-data-jpa addressed in this bug, until it is fixed and if you need to return properties from other entities involved in the join, you need to follow the database _view workaround mentioned in the previous tip.
|
Embedded
-
Using embedded fields:
{ "name.ar": "ahmed ar", "department.name": "hr" }
Generated SQL:
... from employee e inner join department d on e.department_id=d.id where e.employee_name_ar=? and d.name=?
Enums
-
Passing enums as Integer ordinal:
{ "tasks.status": {"$in": [0, 1]} }
Generated SQL:
... from employee e inner join task t on e.id=t.employee_id where t.status in (? , ?)
-
Passing enums as string enum names:
{ "tasks.status": {"$nin": ["ACTIVE"]} }
Generated SQL:
... from employee e inner join task t on e.id=t.employee_id where t.status not in (?)
UUID
-
querying against UUID fields:
{ "serial": "2dfb7bc7-38a6-4826-b6d3-297969d17244" }
Generated SQL:
... where e.serial=?
Operators
The following is the list of supported operators:
Operator | Description |
---|---|
|
col = val (if val is null then ⇒ col is null) |
|
col <> val (if val is null then ⇒ col is not null) |
|
lower(col) = lower(val) |
|
col > val |
|
col >= val |
|
col < val |
|
col ⇐ val |
|
col like 'val%' |
|
col like '%val' |
|
col like '%val%' |
|
lower(col) like 'lower(val)%' |
|
lower(col) like '%lower(val)' |
|
lower(col) like '%lower(val)%' |
|
col in (val1, val2, …) |
|
col not in (val1, val2, …) |
|
expr1 or expr2 |
|
expr1 and expr2 |
How to Build the Expressions
Building the expression on the frontend using Javascript
is easy.
const q = {};
q.status = 'A';
q.qty = { "$lt": 30 };
console.log(JSON.stringify(q, null, 4));
Output:
{
"status": "A",
"qty": {
"$lt": 30
}
}
let q = {};
q.lastName = "ibrahim";
q.$and = [
{ birthDate: { $gt: "1981-01-03" } },
{ birthDate: { $lte: "1981-01-03" } }
];
console.log(JSON.stringify(q, null, 4));
Output:
{
"lastName": "ibrahim",
"$and": [
{
"birthDate": {
"$gt": "1981-01-03"
}
},
{
"birthDate": {
"$lte": "1981-01-03"
}
}
]
}