Integration between Querydsl and Spring Data REST using Kotlin, Gradle and Spring Boot 3

Integration between Querydsl and Spring Data REST using Kotlin, Gradle and Spring Boot 3

Β·

11 min read

Overview

This article will walk through the process of integrating Querydsl with an existing Spring Data REST API using Kotlin and Gradle.

The goal is to demonstrate how to implement advanced querying and filtering features on a REST API without the need of writing any single line of SQL code but using Querydsl instead.

We will continue working on our Movie Universe API designed in my previous (and first) article "Building and testing a REST HAL API using Kotlin, Spring Data REST and JPA", therefore I would recommend it as the first read to have the full context, as this one is a kind of follow-up.

TLDR; Show me the code!

The full source code of the article is published on GitHub πŸ˜‰

https://github.com/ArnauAregall/kotlin-spring-data-rest-movies

πŸ”Ž What is Querydsl?

Querydsl stands for "Query Language for Domain Specific Languages".

Is a powerful query language that allows retrieving data from a database using a domain-oriented syntax. It was developed specifically for use with domain-specific languages (DSLs) to provide a unified way to query data across them.

The main benefit of using Querydsl is that allows developers to write database queries naturally and concisely with a syntax closer to applications object-oriented domains rather than SQL, making it easier to express complex queries.

The syntax relies on using regular Java metaclasses that Querydsl generates during the compilation stage of our build from our application source files, typically the domain persistence classes.

It also has first-class integration with Spring Data.

Querydsl is a framework that enables the construction of type-safe SQL-like queries for multiple backends including JPA, MongoDB and SQL in Java.

Instead of writing queries as inline strings or externalizing them into files they are constructed via a fluent API.

πŸ”— Querydsl Github.

πŸ”— Querydsl official site

🐘 Configuring Querydsl JPA Jakarta flavor using Gradle and Kotlin

Querydsl requires configuring our build tool to generate the mentioned metaclasses, also known as Q-types. These generated classes will directly relate to the entities of our application and will be the ones used to perform the queries.

There are plenty of tutorials and articles out there explaining how to configure Querydsl using Maven and Gradle, but they are all oriented to Java applications.

However, I could not easily find anything similar to mimic the same configurations on a Kotlin and Gradle setup. It took me quite some time to browse GitHub issues comments and Stackoverflow Q&A. So after spending an afternoon on it, I decided it would be great to document the steps and findings here.

#1: Add the kapt Gradle plugin

To generate the Q-types from our JPA entities, we need to tell Querydsl to somehow scan our source code to find classes annotated with jakarta.persitence.@Entity, and generate the Q-types from those.

Java Annotation processors are supported in Kotlin by the kapt compiler plugin.

Therefore, the first step is adding the kapt plugin to our build configuration, typically under our settings.gradle.kts file. The plugin version should match the Kotlin project version.

pluginManagement {
    // repositories....
    // versions....
    plugins {
        // other plugins: spring boot, graalvm, etc...
        kotlin("jvm") version kotlinVersion
        kotlin("plugin.spring") version kotlinVersion
        kotlin("plugin.jpa") version kotlinVersion
        kotlin("kapt") version kotlinVersion
    }
}

#2: Configure Querydsl dependencies using kapt

Next step is adding Querydsl dependencies to our build.gradle.kts file and configure kapt to use Querydsl JPA annotation processor.

// repositories {} ...

dependencies {
    // rest of dependencies...
    implementation("com.querydsl:querydsl-jpa:${property("queryDslVersion")}:jakarta")
    kapt("com.querydsl:querydsl-apt:${property("queryDslVersion")}:jakarta")
}

kapt {
    javacOptions {
        option("querydsl.entityAccessors", true)
    }
    arguments {
        arg("plugin", "com.querydsl.apt.jpa.JPAAnnotationProcessor")
    }
}

On the dependencies section:

  • We are adding the implementation of querydsl-jpa with the :jakarta classifier.

  • We are defining that querydsl-apt (annotation processing tool) should use kapt, again with the :jakarta classifier.

And for the Kotiln annotation processing tool section:

  • We are defining the Java compiler options to enable entity reference field accessors with querydsl.entityAccessors=true. This is a pretty common option that brings us the major of the framework features:

    • If our domain class Actor has a property firstName: String mapped with @Column, it will generate that field on the Q-type class QActor so we can access it as QActor.actor.firstName.

    • If our domain class Actor has a property alive: Boolean mapped with a calculated @Formula("death_date is not null"), it will also generate that field : QActor.actor.alive.

    • If our domain class Actor has a property characters: Set<Character> mapped with a @ManyToMany and @JoinTable... yes, I bet you guessed it: it will also generate QActor.actor.characters.

  • We are defining the plugin argument to configure the annotation processor, the JPA one from Querydsl.

#3: Verify the generation of the Q-types

The Q-types are generated during the compilation stage, therefore the Gradle goal we should use to verify our Q-types are correctly generated is compileKotlin.

Our domain classes live in the "domain" package of our source code, so our Q-types will be also generated there when running is compileKotlin (the target package can be configured although by default is the same as the source code).

$ ls src/main/kotlin/**/domain
Actor.kt     Character.kt Director.kt  Movie.kt
$ ./gradlew clean compileKotlin

BUILD SUCCESSFUL in 9s
4 actionable tasks: 4 executed
$ ls build/**/generated/**/domain
QActor.java     QCharacter.java QDirector.java  QMovie.java

As you see, the kapt plugin processed our Kotlin source files annotated with JPA annotations and generated the corresponding Java Q-type source files. Each generated class extends EntityPathBase<T>, where T is the entity type defined in the respective Kotlin source file. Those can be explored by opening them in your editor, they are just regular Java classes.

Once we have verified our Q-types are generated, we can start importing them into our source code.

πŸƒ Integration between Spring Data REST and Querydsl

The integration between Spring Data repositories and Querydsl is accomplished by implementing the following interface offered from Spring Data:

  • org.springframework.data.querydsl.QuerydslPredicateExecutor<T>

    • The interface that our application repository interface should extend together with JPARepository<T>.

    • It requires a generic type that should match the one defined by extending JPARepository<T>.

    • It will allow the execution of Querydsl predicate instances to perform queries.

Without Querydsl integration:

@RepositoryRestResource(path = "actors", collectionResourceRel = "actors", itemResourceRel = "actor")
interface ActorRestRepository : JpaRepository<Actor, Long>

With Querydsl:

@RepositoryRestResource(path = "actors", collectionResourceRel = "actors", itemResourceRel = "actor")
interface ActorRestRepository : JpaRepository<Actor, Long>, QuerydslPredicateExecutor<Actor>

By just extending the QuerdydslPredicateExecutor, after re-compiling and running our application, our @RepositoryRestResource will support, for example, filtering Actors by full name using query parameters.

GET http://localhost:8080/api/actors?firstName=Pierce&lastName=Brosnan
Accept: application/hal+json

{
  "_embedded": {
    "actors": [
      {
        "first_name": "Pierce",
        "last_name": "Brosnan",
        "birth_date": "1953-05-16",
        "death_date": null,
        "id": 1,
        "is_alive": true,
        "_links": {
          "self": {
            "href": "http://localhost:8080/api/actors/1"
          },
          "actor": {
            "href": "http://localhost:8080/api/actors/1"
          },
          "characters": {
            "href": "http://localhost:8080/api/actors/1/characters"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/api/actors?firstName=Pierce&lastName=Brosnan"
    },
    "profile": {
      "href": "http://localhost:8080/api/profile/actors"
    }
  },
  "page": {
    "total_pages": 1,
    "total_elements": 1,
    "size": 20,
    "number": 0
  }
}

Customizing the Q-type bindings

Spring Data also provides an interface to allow us to customize the bindings on the type accessor of our Q-types.

Binding customizations could be useful for:

  • Providing aliases to specific Q-type accessors.

  • Adding generic type-based query expressions (ie: all comparisons on Strings accessors should be done using "contains ignoring case").

  • Adding concrete Q-type accessor queries custom expressions (ie: the "birthDate" accessor of the Actor entity should support filtering between a date range).

To customize the bindings, we should also extend the following interface:

  • org.springframework.data.querydsl.binding.QuerydslBindingCustomizer<Q extends EntityPath>

    • The interface that our application repository should also extend to customize the bindings of our Q-types.

    • It requires a generic type which should be the equivalent Q-type class.

    • The customizations are done by overriding the default method customize(QuerydslBindings bindings, Q root);

Continuing with our ActorRepository example, this is how it would look like with binding customizations:

@RepositoryRestResource(path = "actors", collectionResourceRel = "actors", itemResourceRel = "actor")
interface ActorRestRepository : JpaRepository<Actor, Long>, QuerydslPredicateExecutor<Actor>, QuerydslBinderCustomizer<QActor> {

    override fun customize(bindings: QuerydslBindings, root: QActor) {        
        // support searching on Q-type string properties using "contains ignore case" expression
        bindings.bind(String::class.java).first(StringExpression::containsIgnoreCase) 
        // support searching on Q-type concrete date properties using "between" expression
        bindDateBetween(bindings, root.birthDate)
        bindDateBetween(bindings, root.deathDate)
    }

    fun bindDateBetween(bindings: QuerydslBindings, datePath: DatePath<LocalDate>) {
        bindings.bind(datePath).all { path, values ->
            if (values.size == 2) Optional.of(path.between(values.first(), values.last()))
            else Optional.of(path.eq(values.first()))
        }
    }

}

Now the same endpoint would offer filtering Actors by all string properties using a "like" expression and date ranges using multiple query params.

Example: "Get all the Actors whose first name contains "dani" and who were born in the '60s".

GET http://localhost:8080/api/actors?firstName=dani&birthDate=1960-01-01&birthDate=1969-12-31
Accept: application/hal+json

{
    "_embedded": {
        "actors": [
            {
                "first_name": "Daniel",
                "last_name": "Craig",
                "birth_date": "1968-03-02",
                "death_date": null,
                "id": 2,
                "is_alive": true,
                "_links": {
                    "self": {
                        "href": "http://localhost:8080/api/actors/2"
                    },
                    "actor": {
                        "href": "http://localhost:8080/api/actors/2"
                    },
                    "characters": {
                        "href": "http://localhost:8080/api/actors/2/characters"
                    }
                }
            }
        ]
    },
    "_links": {
        "self": {
            "href": "http://localhost:8080/api/actors?firstName=dani&birthDate=1960-01-01&birthDate=1969-12-31"
        },
        "profile": {
            "href": "http://localhost:8080/api/profile/actors"
        }
    },
    "page": {
        "total_pages": 1,
        "total_elements": 1,
        "number": 0,
        "size": 20
    }
}

Note: to allow Kotlin to override Java default methods on interfaces, we need to add the argument -Xjvm-default=all-compatibility to our KotlinCompile Gradle task:

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf(
            "-Xjsr305=strict",
            "-Xjvm-default=all-compatibility") // needed to override default methods on interfaces
        jvmTarget = "17"
    }
}

🧐 DRY: expanding custom bindings across all repositories

Following the "Don't repeat yourself" (DRY) principle of software development, we could reduce repeating the mentioned Querydsl configurations on all our application Spring Data repositories by extracting those to an interface, taking advantage of the use of generics.

/**
 * Interface that any repository willing to support QueryDSL predicates should extend.
 * @param T The entity type class.
 * @param ID The entity ID type class.
 * @param Q The QueryDSL entity root class generated from the entity type class.
 */
@NoRepositoryBean
interface QuerydslRepository<T, ID, Q : EntityPath<T>> : JpaRepository<T, ID>, QuerydslPredicateExecutor<T>, QuerydslBinderCustomizer<Q> {

    override fun customize(bindings: QuerydslBindings, root: Q) {
        bindings.bind(String::class.java).first(StringExpression::containsIgnoreCase)
        customizeBindings(bindings, root)
    }

    fun customizeBindings(bindings: QuerydslBindings, root: Q) {
        // Default implementation is empty
    }

    fun <C : Comparable<C>> bindDateBetween(bindings: QuerydslBindings, datePath: DatePath<C>) {
        bindings.bind(datePath).all { path, values ->
            if (values.size == 2) Optional.of(path.between(values.first(), values.last()))
            else Optional.of(path.eq(values.first()))
        }
    }

}

It is important to notice that the interface is annotated with @NoRepositoryBean to avoid Spring creating a bean for that interface.

Then our application repositories could be simplified as follows:

@RepositoryRestResource(path = "actors", collectionResourceRel = "actors", itemResourceRel = "actor")
interface ActorRestRepository : QuerydslRepository<Actor, Long, QActor> {

    override fun customizeBindings(bindings: QuerydslBindings, root: QActor) {
        bindDateBetween(bindings, root.birthDate)
        bindDateBetween(bindings, root.deathDate)
    }

}

@RepositoryRestResource(path = "characters", collectionResourceRel = "characters", itemResourceRel = "character")
interface CharacterRestRepository : QuerydslRepository<Character, Long, QCharacter>

@RepositoryRestResource(path = "directors", collectionResourceRel = "directors", itemResourceRel = "director")
interface DirectorRestRepository : QuerydslRepository<Director, Long, QDirector>

@RepositoryRestResource(path = "movies", collectionResourceRel = "movies", itemResourceRel = "movie")
interface MovieRestRepository : QuerydslRepository<Movie, Long, QMovie> {

    override fun customizeBindings(bindings: QuerydslBindings, root: QMovie) {
        bindDateBetween(bindings, root.releaseDate)
    }

}

With this approach, all our REST endpoints would support filtering using query params of each entity that would bind automatically to the respective Q-type. ✨

πŸ” Performing advanced queries using dot path expressions on query params

With the provided configuration we can even perform advanced queries using the dot path feature of query params.

Without writing any single line of SQL nor providing a custom endpoint mapped with @Query, we could perform an advanced filtering query like the following:

"Get all Actors born in the '60s that at some point in time have played a Character in a Movie directed by a Director named Martin Campbell".

The request would be:

GET http://localhost:8080/api/actors?birthDate=1960-01-01&birthDate=1969-12-31&characters.movies.directors.firstName=Martin&characters.movies.directors.lastName=Campbell
Accept: application/hal+json

If we even know the ID for Martin Campell (let's say it's 123) could be even simplified using characters.movies.directors.id query param.

GET http://localhost:8080/api/actors?birthDate=1960-01-01&birthDate=1969-12-31&characters.movies.directors.id=123
Accept: application/hal+json

πŸ§ͺ Implementing integration tests

Let's take the last use case to figure out how we could implement an automated integration Spring Boot test using JUnit5 and MockMvc:

@Test
    fun `Should filter Actors by birth date between and the name of the Director that directed the Movies where they played a Character` () {
        // Director
        val directorLink = createAndReturnSelfHref("/api/directors",
            """
                {"first_name": "Martin", "last_name": "Campbell"}
            """.trimIndent())

        // Actors
        val pierceBrosnanLink = createAndReturnSelfHref("/api/actors",
            """
                {"first_name": "Pierce", "last_name": "Brosnan", "birth_date": "1953-05-16"}
            """.trimIndent())

        val danielCraigLink = createAndReturnSelfHref("/api/actors",
            """
                {"first_name": "Daniel", "last_name": "Craig", "birth_date": "1968-03-02"}
            """.trimIndent())

        val evaGreenLink = createAndReturnSelfHref("/api/actors",
            """
                {"first_name": "Eva", "last_name": "Green", "birth_date": "1980-07-06"}
            """.trimIndent())

        val judiDenchLink = createAndReturnSelfHref("/api/actors",
            """
                {"first_name": "Judi", "last_name": "Dench", "birth_date": "1934-12-09"}
            """.trimIndent())

        // Characters
        val jamesBondLink = createAndReturnSelfHref("/api/characters",
            """
                {"name": "James Bond", "actors": ["$pierceBrosnanLink", "$danielCraigLink"]}
            """.trimIndent())

        val vesperLyndLink = createAndReturnSelfHref("/api/characters",
            """
                {"name": "Vesper Lynd", "actors": ["$evaGreenLink"]}
            """.trimIndent())

        val mLink = createAndReturnSelfHref("/api/characters",
            """
                {"name": "M", "actors": ["$judiDenchLink"]}
            """.trimIndent())

        // Movies
        performPost("/api/movies",
            """
                {"title": "Goldeneye", 
                "release_date": "1995-12-20", 
                "director": "$directorLink", 
                "characters": ["$jamesBondLink", "$mLink"]}
            """.trimIndent())

        performPost("/api/movies",
            """
                {"title": "Casino Royale", 
                "release_date": "2006-11-14", 
                "director": "$directorLink", 
                "characters": ["$jamesBondLink", "$mLink", "$vesperLyndLink"]}
            """.trimIndent())

        // Get all Actors that appeared in a Martin Campbell movie
        mockMvc.perform(get("/api/actors")
            .queryParam("characters.movies.directors.firstName", "Martin")
            .queryParam("characters.movies.directors.lastName", "Campbell")
            .accept(HAL_JSON))
            .andExpect(status().isOk)
            .andExpectAll(
                jsonPath("$._embedded.actors.length()").value(4),
                jsonPath("$._embedded.actors[*]._links.self.href",
                    containsInAnyOrder(`is`(pierceBrosnanLink), `is`(danielCraigLink), `is`(evaGreenLink), `is`(judiDenchLink))
                ),
                jsonPath("$.page").isNotEmpty,
                jsonPath("$.page.total_elements").value(4),
            )

        // Get Actors born in the '60s that appeared in a Martin Campbell movie
        mockMvc.perform(get("/api/actors")
            .queryParam("characters.movies.directors.firstName", "Martin")
            .queryParam("characters.movies.directors.lastName", "Campbell")
            .queryParam("birthDate", "1960-01-01", "1969-12-31")
            .accept(HAL_JSON))
            .andExpect(status().isOk)
            .andExpectAll(
                jsonPath("$._embedded.actors.length()").value(1),
                jsonPath("$._embedded.actors[0]._links.self.href").value(danielCraigLink),
                jsonPath("$.page").isNotEmpty,
                jsonPath("$.page.total_elements").value(1),
            )
    }

The full "James Bond use case" integration test is on GitHub.

Conclusion

This article demonstrated:

  • How to configure Querydsl on a Kotlin application using Gradle.

  • How to integrate Querydsl and Spring Data REST.

  • How to customize Querydsl bindings in a shared way for all our application repositories.

  • How to perform advanced queries using Querydsl without the need of writing a single line of SQL code.

The examples used in this article are available in the linked GitHub repository.

If you enjoyed the article, let me know by either reaching me on social networks or by giving a ⭐️ on the GitHub repository. ☺️

Β