Queryable
A Maven plugin to generate quickly Java classes for JAX-RS controllers using Quarkus and Hibernate Panache, with Hibernate @filters on @entity classes annotated.
mvn io.quarkus.platform:quarkus-maven-plugin:3.39.5:create \
-DprojectGroupId=it.queryable \
-DprojectArtifactId=awesomeproj \
-Dextensions="jdbc-postgresql,resteasy-jackson,hibernate-orm-panache" \
-Dpath="/awesomeproj" \
-DnoCode
cd awesomeproj
Scenario
Normally we use the following paradigm to develop a Quarkus REST app (see our API rules).
1 – Write entities with Hibernate filters
Official documentation: Hibernate_User_Guide#pc-filter | Hibernate @Filter (Thorben Janssen)
@Entity
@Table(name = "customers")
@FilterDef(name = "Customer.obj.code", parameters = @ParamDef(name = "code", type = String.class))
@Filter(name = "Customer.obj.code", condition = "code = :code")
@FilterDef(name = "Customer.like.name", parameters = @ParamDef(name = "name", type = String.class))
@Filter(name = "Customer.like.name", condition = "lower(name) LIKE :name")
@FilterDef(name = "Customer.obj.active", parameters = @ParamDef(name = "active", type = Boolean.class))
@Filter(name = "Customer.obj.active", condition = "active = :active")
public class Customer extends PanacheEntityBase {
@GeneratedValue(generator = "uuid")
@GenericGenerator(name = "uuid", strategy = "uuid2")
@Column(name = "uuid", unique = true)
@Id
public String uuid;
public String code;
public String name;
public boolean active;
public String ldap_group;
public String mail;
}
2 – Write one REST controller per entity
@Path("/api/v1/customers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Singleton
public class CustomerServiceRs extends RsRepositoryServiceV3<Customer, String> {
public CustomerServiceRs() {
super(Customer.class);
}
@Override
protected String getDefaultOrderBy() {
return "name asc";
}
@Override
public PanacheQuery<Customer> getSearch(String orderBy) throws Exception {
PanacheQuery<Customer> search;
Sort sort = sort(orderBy);
if (sort != null) {
search = Customer.find(null, sort);
} else {
search = Customer.find(null);
}
if (nn("obj.code")) {
search.filter("Customer.obj.code", Map.of("code", get("obj.code")));
}
if (nn("like.name")) {
search.filter("Customer.like.name", Map.of("name", likeParamToLowerCase("like.name")));
}
search.filter("Customer.obj.active", Map.of("active", true));
return search;
}
}
3 – Query the API
https://prj.n-ess.it/api/v1/customers?obj.code=xxxx&like.name=yyyy
You can find more examples in the API rules page.
The boring process that Queryable automates:
- the writing of Hibernate filters
- the writing of search conditions using query parameters — with our annotation set, generated at request using a Maven goal!
Quarkus Project Setup
Prerequisites: JDK 21
Start a Maven project: https://quarkus.io/guides/getting-started
mvn io.quarkus.platform:quarkus-maven-plugin:3.39.5:create \
-DprojectGroupId=it.queryable \
-DprojectArtifactId=awesomeproj \
-Dextensions="jdbc-postgresql,resteasy-jackson,hibernate-orm-panache" \
-Dpath="/awesomeproj" \
-DnoCode
cd awesomeproj
Following the Hibernate + Panache guide, add these dependencies to your pom.xml:
<!-- Jackson Mapper -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-jackson</artifactId>
</dependency>
<!-- Hibernate ORM specific dependencies -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<!-- JDBC driver dependencies -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
Add Queryable to your project
./mvnw it.n-ess.queryable:queryable-maven-plugin:3.0.12:add
or directly in pom.xml:
<dependency>
<groupId>it.n-ess.queryable</groupId>
<artifactId>queryable-maven-plugin</artifactId>
<version>3.0.12</version>
</dependency>
In the build section add the plugin:
<build>
<plugins>
<plugin>
<groupId>it.n-ess.queryable</groupId>
<artifactId>queryable-maven-plugin</artifactId>
<version>3.0.12</version>
</plugin>
</plugins>
</build>
Available configuration options
<configuration>
<!-- default is false -->
<removeAnnotations>false</removeAnnotations>
<!-- default is {groupId}/model -->
<sourceModelDirectory>model</sourceModelDirectory>
<!-- default is {groupId}/service/rs -->
<sourceRestDirectory>service/rs</sourceRestDirectory>
<!-- default is false; V3 and V4 -->
<useModelSubPackage>false</useModelSubPackage>
<!-- default is src/main/java -->
<outputDirectory>src/main/java</outputDirectory>
<!-- default is true -->
<logging>true|false</logging>
<!-- default is true -->
<overrideAnnotations>true|false</overrideAnnotations>
<!-- default is true -->
<overrideSearchMethod>true|false</overrideSearchMethod>
</configuration>
And then?! Start to write your entities!
Before editing your entities, run:
./mvnw queryable:install
This adds our minimal API and creates a sample entity class in {groupId}.{artifactId}.model.Greeting
(e.g. it.queryable.awesomeproj.model.Greeting).
After creating your annotated entities, run:
./mvnw queryable:source
This adds @FilterDef on your model classes and adds/updates the getSearch method
on your REST API controllers (one per entity).
To process only selected model classes, pass their simple names separated by commas:
./mvnw queryable:source -Dclasses=Customer,Order
JPA @Entity classes location
The plugin searches for classes extending PanacheEntityBase in {groupId}.{artifactId}.model and its subpackages, or the configured model directory. Since 3.0.10, REST V3 imports use each entity's declared package; rerun queryable:source after upgrading to repair legacy imports.
Since 3.0.12, enable <useModelSubPackage>true</useModelSubPackage>
to mirror model subpackages in V3 and V4 REST service packages, including multiple levels.
Default false keeps services flat. Existing services are relocated when switching the option.
See configuration and migration notes.
Since 3.0.11, protect manual V3 filters with @QPreserveFilters
and link service methods using @QPreservedFilter.
Move custom inline blocks out of getSearch() before regenerating.
See the manual filter preservation guide.
JAX-RS classes location
The plugin searches for @Path @Singleton classes in {groupId}.{artifactId}.service.rs following the pattern {EntityName}ServiceRs.
For more information, see the Documentation.
Optional QEEX: REST Error Catalog
QEEX is the optional exception-generation part of Queryable. It turns an annotated Java interface into a CDI bean whose methods create QeexWebException instances. REST exception mappers expose a project name, stable error ID, HTTP status, message and language label as JSON. It is independent of entity-filter generation.
1. Install the support classes
From the awesomeproj directory created above, after queryable:install, run:
./mvnw queryable:qeexinstall
This creates annotations, configuration, a request filter and exception mappers under it.queryable.api.qeex, plus a sample it.queryable.awesomeproj.service.exception.ExceptionBundle.
Warning: qeexinstall deletes and replaces it.queryable.api.service.RsResponseService with the QEEX-aware template. Review or back up local customizations before running it, including on subsequent installations.
2. Configure the application
Add these entries to src/main/resources/application.properties, preserving your database configuration:
qeex.project=AWESOME
qeex.default.id=100
qeex.default.code=500
qeex.default.message=Unexpected application error
qeex.default.language=en
Keep this file present during generation. Set defaults explicitly: the catch-all exception mapper reads several of them using Optional.get(). qeex.project overrides the project name declared on the bundle.
3. Define an error catalog
Replace the installed sample at src/main/java/it/queryable/awesomeproj/service/exception/ExceptionBundle.java with:
package it.queryable.awesomeproj.service.exception;
import it.queryable.api.qeex.annotations.QeexExceptionBundle;
import it.queryable.api.qeex.annotations.QeexMessage;
import it.queryable.api.qeex.exceptions.QeexWebException;
@QeexExceptionBundle(project = "AWESOME", language = "en")
public interface ExceptionBundle {
@QeexMessage(id = 101, code = 404, message = "Customer %s not found")
QeexWebException customerNotFound(String customerCode);
@QeexMessage(id = 102, code = 409, message = "Customer %s is inactive")
QeexWebException customerInactive(String customerCode);
}
id is the application error identifier; code is the HTTP status. Arguments use Java String.format placeholders in method-parameter order. Use explicit IDs and statuses for a stable client contract: omitted IDs start at the bundle's id + 1 (101 by default), while an omitted method code currently generates 500, not the annotation's declared default of 400.
./mvnw queryable:qeexsource
The goal scans annotated interfaces under src/main/java and creates ExceptionBundleImpl next to the interface. Edit the interface, not the generated implementation, and rerun qeexsource after catalog changes. This is a separate goal from queryable:source.
4. Throw an error from a REST endpoint
Create src/main/java/it/queryable/awesomeproj/service/rs/CustomerErrorDemoRs.java:
package it.queryable.awesomeproj.service.rs;
import it.queryable.api.qeex.exceptions.QeexWebException;
import it.queryable.awesomeproj.service.exception.ExceptionBundle;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/api/v1/qeex-demo/customers")
@Produces(MediaType.APPLICATION_JSON)
public class CustomerErrorDemoRs {
@Inject
ExceptionBundle errors;
@GET
@Path("/{code}")
public void findCustomer(@PathParam("code") String customerCode)
throws QeexWebException {
throw errors.customerNotFound(customerCode);
}
}
This endpoint deliberately always throws an error without querying the database. In a real service, throw it only when the lookup fails. The separate demo path avoids conflicts with generated customer endpoints.
Start the application with ./mvnw quarkus:dev, then call:
curl -i -H 'language: en' \
http://localhost:8080/api/v1/qeex-demo/customers/C001
Expected HTTP status: 404. Expected JSON body:
{
"projectName": "AWESOME",
"id": 101,
"code": 404,
"message": "Customer C001 not found",
"language": "en"
}
Current limitations
- Throw the exception out of the endpoint to use its HTTP status through
QuarkusWebExceptionProvider. Controller paths that catch it and callRsResponseService.jsonErrorMessageResponseorjsonMessageResponsereturn HTTP 500 for QEEX errors, even when the JSONcodeis 404 or 409. - Generated methods use annotation messages and statuses directly. They do not call the override and translation helpers in
QeexConfig; do not expectqeex.messages[...]overrides or automatic translation to change this example. - The filter reads the custom
languageheader, notAccept-Language. Generated methods fall back toen, not the bundle's language orqeex.default.language. The application-scoped filter stores mutable language state and does not clear it when the header is missing. Sendinglanguage: enexplicitly does not fix concurrent-request isolation. - The exception mapper builds JSON without escaping message values. Quotes, backslashes and control characters in arguments can produce invalid JSON. Use controlled values for the demo and address serialization before using arbitrary user input.
- The catch-all mapper handles non-QEEX exceptions using the default status and message. It does not assign
qeex.default.id, so its current error ID is 0. Review this global error policy before enabling QEEX.
See the QEEX reference for generated classes, annotation attributes and configuration details.