Programming · Quarkus

Quarkus Guide: Using Spring Data JPA

Did you know you can use Spring Data JPA in Quarkus?

Yes, you can and it’s actually supported by Quarkus, not only Spring Data but also much of the Spring ecosystem. (including its dependency injection system)

This guide will also introduce Spring Data Jpa itself, since Quarkus’ integration is seamless and there is no change needed.

Let’s see how.

  1. Simple Demo
    1. Person – Entity Class
    2. PersonRepository – Interface (implemented in JPA)
    3. PersonController – Class (REST API)
    4. Testing
    5. Adding a get for one person
    6. Adding the update
      1. 1st way
      2. 2nd way
    7. Testing the update
    8. Adding the delete
  2. Derived Queries
  3. Custom queries
  4. Transactions
    1. Testing without a transaction
    2. Testing with @Transactional
    3. Common mistake with @Transactional
  5. Integration testing
  6. Conclusion

Simple Demo

For this demo, we’ll create a controller that uses a repository to operate (CRUD) on the database (Postgres in this case), without writing any SQL.

As usual, we quickstart with https://code.quarkus.io.

Choose the following extensions:

Don’t enable “Starter Code”, that’s cheating!

Note: the code shown here will be in Java, Kotlin is still in preview, but if you want to use it check this before.

Create the following:

Person – Entity Class

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class Person {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
    private int age;

    protected Person() {}
    
    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

We will create an API and repository to:

  1. add a person
  2. list all
  3. get only one, by ID
  4. update one’s age
  5. filter by name
  6. filter adults (age above 18)

Note that the protected constructor is required for JPA.

Also, the ID will be generated automatically in the DB itself.

PersonRepository – Interface (implemented in JPA)

import org.springframework.data.jpa.repository.JpaRepository;

public interface PersonRepository extends JpaRepository<Person, Long> {}

Yes, these two lines already give you CRUD access to the database.

And we can add more complex queries/updates defined only via the method’s name, we’ll see later how :).

PersonController – Class (REST API)

import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

@Path("/people")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class PersonController {

    PersonRepository repository;

    public PersonController(PersonRepository repository) {
        this.repository = repository;
    }

    @POST
    public Response create(Person person) {
        repository.save(person);

        return Response.ok(person).build();
    }

    @GET
    public Response getAll() {
        var people = repository.findAll();

        return Response.ok(people).build();
    }
}

Here we make use of it, by having our /people endpoint:

  • POST: create a new person
  • GET: retrieve all people

Testing

Install or run Docker, to have Quarkus set up a dev Postgres container, otherwise set up one on your own and set its environment variables.

Run mvn quarkus:dev, and you’ll see it downloading the Postgres image, after being started, create a person with:

$ curl -s -i --location --request POST 'http://127.0.0.1:8080/people' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "Jonathan",
    "age": 27
}'

HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
content-length: 35

{"id":1,"name":"Jonathan","age":27}

Note: you can import that curl on Postman as well.

And validate it is created:

$ curl -s --location --request GET 'http://localhost:8080/people'

[{"id":1,"name":"Jonathan","age":27}]

Adding a get for one person

This one is also straightforward since Spring Data offers us the findById() method.

@GET
@Path("/{id}")
public Response getOne(Long id) {
    return repository.findById(id)
            .map(person -> Response.ok(person).build())
            .orElse(Response.status(Response.Status.NOT_FOUND).build());
}

Here we make use of the Optional return of findById, to either return a 200 OK with the entity, if it exists, or respond with a 404 NOT_FOUND.

Re-issue the POST above, since the hot reload of Quarkus will re-create the database, and then:

$ curl -s --location --request GET 'http://localhost:8080/people/1'

{"id":1,"name":"Jonathan","age":27}

Adding the update

Let’s now implement the update part of CRUD.

There doesn’t seem to be a standard way of updating an entity in Spring Data, we can do it by either:

  • Find the entity by id, set the fields, and save it
  • Define a custom UPDATE query
1st way
@PUT
@Path("/{id}")
public Response update(Long id, Person updatedPerson) {
    return repository.findById(id).map(person -> {
        person.setAge(updatedPerson.getAge());
        repository.save(person);

        return Response.ok(person).build();
    }).orElse(Response.status(Response.Status.NOT_FOUND).build());
}

Pretty much the same as the GET but with the additional steps to update the entity.

2nd way

This way is more verbose, we need to add this to our repository:

public interface PersonRepository extends JpaRepository<Person, Long> {

    @Modifying
    @Query("UPDATE Person p SET p.name = ?2, p.age = ?3 WHERE p.id = ?1")
    int updateById(Long id, String name, int age);
}

Note: The @Modifying is necessary because this is not a query itself.

And call it as such:

@PUT
@Path("/{id}")
public Response update(Long id, Person updatedPerson) {
    if (repository.updateById(id, updatedPerson.getName(), updatedPerson.getAge()) == 1) {
        return Response.ok(updatedPerson).build();
    } else {
        return Response.status(Response.Status.NOT_FOUND).build();
    }
}

Take note of the == 1, as it’s the only way we can know that the record was changed.

Although this way is more efficient, since we query the database only once, I prefer the first, since it seems less error-prone.

Testing the update

After implementing one of those ways, call the POST again, since Quarkus will hot reload and re-launch the database, and finally the PUT:

$ curl -s --location --request PUT 'http://localhost:8080/people/1' --header 'Content-Type: application/json' --data-raw '{ "id": 1,"name": "Sam", "age": 1337 }'

{"id":1,"name":"Sam","age":1337}

The GET request should now return the same.

One thing you might have found weird is asking for the ID as both a path param (/people/id) and in the body, that is indeed what the REST spec mandates, where we specify the resource in the path.

But we can simply ignore the one in the body.

Adding the delete

The delete is more straightforward than the update:

@DELETE
@Path("/{id}")
public Response delete(Long id) {
    try {
        repository.deleteById(id);
        return Response.ok().build();
    } catch (IllegalArgumentException ignored) {
        return Response.status(Response.Status.NOT_FOUND).build();
    }
}

Here we don’t need to do a find before deleting, although the exception return is a bit of a bummer.

$ curl -s --location --request DELETE 'http://localhost:8080/people/2'

This concludes the CRUD demo, let’s now see how to declaratively define a query with Spring Data.

Derived Queries

We can have Spring Data generate the query based on the method name.

To add an endpoint for getting people named “David”, we’d simply need this on the repository:

List<Person> getPeopleByNameEqualsIgnoreCase(String name);

And call it from the controller:

@GET
@Path("/searches")
public Response searchBy(@QueryParam("name") String name) {
    return Response.ok(repository.getPeopleByNameEqualsIgnoreCase(name)).build();
}

I recommend using this only for very simple queries, otherwise, the name gets too huge. Even in this example, being case-insensitive is already weird to get that it’s by name.

It allows for conditions such as “greater than”, “in between”, and even sorting. (read more here)

Custom queries

Here is an example where having a custom query is actually more readable, we’ll add an endpoint to get only adult people, since getAdults() is more obvious than getPeopleByAgeGreaterThanEqual18().

It is pretty similar to the 2nd way we used to update a person, except that this time it’s just a SELECT with no @Modifying.

@Query("SELECT p FROM Person p WHERE age >= 18")
List<Person> getAdults();

Do note that this isn’t native SQL, it’s JPQL.

If you want to write native SQL, you’d need to include native = true on @Query.

Unless you really need a database-specific feature, you should stick to JPQL, since this allows you to change the database engine without needing to change the queries. (from Oracle to Postgres for instance)

@GET
@Path("/adults")
public Response getAdults() {
    return Response.ok(repository.getAdults()).build();
}

Transactions

Quarkus’ integration also supports transactions. (otherwise, it wouldn’t be that useful)

To show this, we will introduce an endpoint that will increment all people’s age by 1. (/operations/advanceYear)

The controller already comes with a transaction so to demonstrate this we have to create a service to have a method without @Transactional.

Person

Let’s add a simple function to increment the age in the Person model:

public void incAge() throws IllegalArgumentException {
    if (this.age == Integer.MAX_VALUE)
        throw new IllegalArgumentException("Are you human?!");

    this.age++;
}

That throw is what will allow us to have the transaction fail on purpose.

PersonService

This service will go through all people in the repository, to call that function and save the model.

@ApplicationScoped
public class PersonService {

    private final PersonRepository repository;

    public PersonService(PersonRepository repository) {
        this.repository = repository;
    }

    public void advanceYear() {
        repository.findAll().forEach(person -> {
            person.incAge();
            repository.save(person);
        });
    }
}

PersonController

Before making use of it in the endpoint, we have to inject it:

PersonService service;
PersonRepository repository;
public PersonController(PersonService service, PersonRepository repository) {
    this.service = service;
    this.repository = repository;
}

And now:

@POST
@Path("/operations/advanceYear")
public Response advanceYear() {
    try {
        service.advanceYear();
        return Response.noContent().build();
    } catch (IllegalArgumentException ex) {
        return Response.status(Status.BAD_REQUEST).build();
    }
}

Tip: Notice that we’re catching the IllegalArgumentException, we could have omitted it, but it’s always a security best practice to not blow a request with the stack trace.

Testing without a transaction

Now we’ll create 2 people, the first will succeed in the advance year, the second won’t.

For the first:

$ curl -s --location --request POST 'http://127.0.0.1:8080/people' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "First",
    "age": 21
}'

{"id":1,"name":"First","age":21}

Let’s first check if our advance one year actually works:

$ curl -I -s --location --request POST 'http://localhost:8080/people/operations/advanceYear'

HTTP/1.1 204 No Content

$ curl -s --location --request GET 'http://localhost:8080/people'

[{"id":1,"name":"First","age":22}]

Now the second:

$ curl -s --location --request POST 'http://127.0.0.1:8080/people' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "Overflow",
    "age": 2147483647
}'

{"id":2,"name":"Overflow","age":2147483647}

Repeat the advance year:

$ curl -I -s --location --request POST 'http://localhost:8080/people/operations/advanceYear'

HTTP/1.1 400 Bad Request
Content-Type: application/json
content-length: 0

It blew up, and the GET for all should yield:

$ curl -s --location --request GET 'http://localhost:8080/people'

[{"id":2,"name":"Overflow","age":2147483647},{"id":1,"name":"First","age":23}]

As you can see, the “First” person was changed while the “Overflow” wasn’t.

Testing with @Transactional

To fix this, simply add @Transactional to PersonService::advanceYear():

@Transactional
public void advanceYear() {
    repository.findAll().forEach(person -> {
        person.incAge();
        repository.save(person);
    });
}

Re-issue the requests above, and the GET now yields:

$ curl -s --location --request GET 'http://localhost:8080/people'

[{"id":1,"name":"First","age":21},{"id":2,"name":"Overflow","age":2147483647}]

You can also test with only the “First” person, it will work as before.

Common mistake with @Transactional

It’s important to understand how @Transactional works under-the-hood.

Quarkus will create a proxy around our service’s advanceYear(), to add the transaction begin-commit/rollback.

This means that “external calls” will be wrapped in a transaction, but not calls inside this service, since those won’t go through the proxy.

You can try changing the service like this:

public void advanceYear() {
    repository.findAll().forEach(this::incrementPerson);
}

@Transactional
public void incrementPerson(Person person) {
    person.incAge();
    repository.save(person);
}

And see that, as happened without a transaction, the “First” person gets changed even when changing the “Overflow” fails.

You can read about more pitfalls here.

Integration testing

The beauty of Quarkus’ Dev Services is that they also spin up containers in tests.

We will write a test to confirm a person is created and deleted afterwards.

The class starts like this:

import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.junit.QuarkusIntegrationTest;
import org.junit.jupiter.api.Test;

@QuarkusIntegrationTest
@TestHTTPEndpoint(PersonController.class)
public class PersonTests {
}

The @QuarkusIntegrationTest annotation is used instead of the usual @QuarkusTest, for Quarkus to start our service in a container itself, being closer to a real-world scenario.

And @TestHTTPEndpoint allows us to avoid including /people to each URL.

@Test
void shouldCreatePersonAndThenDelete() {
    // Create person
    given()
            .body(new Person("John", 40))
            .contentType("application/json")
            .when().post()
            .then()
            .statusCode(200)
            .body("name", equalTo("John"),
                    "age", equalTo(40));

    // Confirm the person is created
    int id = given()
            .when().get()
            .then()
            .statusCode(200)
            .body("size()", equalTo(1))
            .rootPath("[0]")
            .body("name", equalTo("John"),
                    "age", equalTo(40))
            .extract().path("[0].id");

    // Delete created person
    given()
            .when().delete("/" + id)
            .then()
            .statusCode(200);

    // Confirm the person is deleted
    given()
            .when().get()
            .then()
            .statusCode(200)
            .body("size()", equalTo(0));
}

The test itself is just sending requests and validating the response’s status code and JSON.

Notice that, for JSON, it uses the JSON path format.

Always remember to keep your tests self-contained, meaning that, for each create you include its delete, and you’re not expecting your test to succeed only after another one is run.

This makes sure that a test isn’t dependent on another, and neither is it affected.

A test for the /operations/advanceYear endpoint would essentially look the same, except it would include a POST and then assert the age got incremented, so I’m omitting it here.

Conclusion

This guide showed not only how to use Spring Data in Quarkus, but also as an introduction to Spring Data itself, since using it in Quarkus is just like using it in Spring. (this goes for joins, sorting, paging, etc…)

However, although the most common features are supported, not everything is, you can read about the unsupported features in the Quarkus Guide.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s