Expressing Hibernate queries as type-safe Java streams

In this article, you’ll learn how the JPAstreamer Quarkus extension makes it easy to execute type-safe Hibernate queries without being too verbose or complex.

As expressive as the JPA Criteria constructor is, so verbose are often JPA requests that the API itself may not be intuitive to use, especially for beginners. In the Quarkus ecosystem, Panache is a partial solution to these problems when using Hibernate.

However, I find myself often juggling Panache’s pre-configured helper methods. transfers and raw-lines when compiling any queries other than the simplest ones.

You can say that I’m just inexperienced and impatient, or vice versa to admit that the API is ideal and easy to use for everyone.

Thus, the user experience of writing JPA queries can be improved in this direction.


One of the remaining drawbacks is that raw strings are not inherently type-safe, which means that my IDE refuses to help me with code completion and, at best, wishes me luck.

On the other side, Quarkus allows you to restart applications in a fraction of a second to give a quick verdict to my code. And nothing compares to sincere joy and genuine surprise when I make a work request not on the tenth, but on the fifth attempt…

With this in mind, we created the open source JPAstreamer library to make the process of writing Hibernate queries more intuitive and less time consuming, while leaving the existing codebase intact.

It achieves this goal by allowing express queries to be expressed as standard Java streams. Once executed, the JPAstreamer translates the stream pipeline into an HQL query for efficient execution and does not create any objects that are irrelevant to the query results.

To give an example: in some arbitrary database there is a table named Personrepresented in a Hibernate application by the following default entity:

@Table(name = "person")
public class Person {
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "person_id", nullable = false, updatable = false)
    private Integer actorId;

    @Column(name = "first_name", nullable = false, columnDefinition = "varchar(45)")
    private String firstName;

    @Column(name = "last_name", nullable = false, columnDefinition = "varchar(45)")
    private String lastName;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

	// Getters for all fields will follow from here 

To obtain Person with id 1 using JPAstreamer, all you need is to execute the following code:

public class PersonRepository {
    EntityManagerFactory entityManagerFactory;

    private final JPAStreamer jpaStreamer;

    public PersonRepository EntityManagerFactory entityManagerFactory) {
		jpaStreamer = JPAStreamer.of(entityManagerFactory); <1>

    public Optional<Person> getPersonById(int id) {
        return this.jpaStreamer.from(Person.class) <2>
            .filter(Person$.personId.equal(id)) <3>

<1> Initialize the JPAstreamer with one line, the base JPA provider handles the database configuration.

<2> Stream source is table Person.

<3> The filter operation is processed as a sentence SQL WHEREand the condition is expressed in a type-safe manner using JPAstreamer predicates (more on this later).

Even though it looks like JPAstreamer works with all objects Personthe pipeline is optimized for a single request, in this case:

    person0_.person_id as person_id1_0_,
    person0_.first_name as first_na2_0_,
    person0_.last_name as last_nam3_0_,
    person0_.created_at as created_4_0_,
    person person0_

So only object is created Person, matching the search criteria.

Next, we’ll look at a more complex example that searches for objects Personwith the first name ending in “A” and the last name beginning with “B”.

Search results are sorted first by first name and then by last name. Next, I decide to apply an offset of 5, excluding the first five results, and limit the total number of results to 10. Here is the stream pipeline to solve this problem:

List<Person> list =
	.filter(Person$.firstName.endsWith("A").and(Person$.lastName.startsWith("B"))) <1>
	.sorted(Person$.firstName.comparator().thenComparing(Person$.lastName.comparator())) <2>
	.skip(5) <3> 
	.limit(10) <4>

<1> Filters can be combined with and/or operators.

<2> Simple filtering by one or more properties.

<3> Skip the first 5 search results.

<4> Return no more than 10 people.

In the context of queries, the stream operators filter, sort, limit, and skip have a natural mapping that makes the resulting query expressive and intuitive to read while remaining compact.

This query is translated by JPAstreamer into the following HQL statement:

    person0_.person_id as person_id1_0_,
    person0_.first_name as first_na2_0_,
    person0_.last_name as last_nam3_0_,
    person0_.created_at as created_4_0_,
    person person0_
    (person0_.first_name like ?) 
    and (person0_.last_name like ?) 
order by
    person0_.first_name asc,
    person0_.last_name asc limit ?, ?

How JPAstreamer Works

Okay, this looks simple. But how does it work? JPAstreamer uses an annotation processor to generate a meta model at compile time. It checks all classes marked with standard JPA annotation @Entityand for each entity Foo.class corresponding class is created Foo$.class. The generated classes represent entity attributes as fields used to form view predicates User$.firstName.startsWith("A")which can be interpreted by the JPAstreamer query optimizer.

It’s worth reiterating that JPAstreamer does not change or break the existing codebase, but simply extends the API for handling Java streaming requests.

Installing the JPAstreamer Extension

JPAstreamer is installed like any other Quarkus extension using a Maven dependency:


After adding the dependency, rebuild the Quarkus application to run the JPAstreamer annotation processor. The installation will be completed when the generated fields appear in the directory /target/generated-sources. You’ll recognize them by the last $ character in their class names, such as Person$.class.

NoteA: JPAstreamer requires an underlying JPA provider such as Hibernate. For this reason, JPAstreamer does not need any additional configuration since the database integration is provided by the JPA provider.

JPAstreamer and Panache

Any Panache aficionado will notice that JPAstreamer shares some goals with Panache, simplifying many common queries. However, JPAstreamer differs in that it instills more confidence in queries due to its type-safe streaming interface. However, no one has to choose, since Panache and JPAstreamer work great together.

note: here Quarkus sample applicationwhich uses both JPAstreamer and Panache.

At the time of writing, JPAstreamer does not support Panache’s Active Record template as it relies on standard JPA entities to create its meta model. This will likely change in the near future.


JPA in general and Hibernate have made it much easier for applications to access databases, but their API sometimes creates unnecessary complexity. With JPAstreamer, you can use JPA while keeping your codebase clean and maintainable.

Similar Posts

Leave a Reply