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.34.6: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.34.6: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.5:add
or directly in pom.xml:
<dependency>
<groupId>it.n-ess.queryable</groupId>
<artifactId>queryable-maven-plugin</artifactId>
<version>3.0.5</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.5</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 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).
JPA @Entity classes location
The plugin searches for classes extending PanacheEntityBase in {groupId}.{artifactId}.model.
JAX-RS classes location
The plugin searches for @Path @Singleton classes in {groupId}.{artifactId}.service.rs following the pattern {EntityName}ServiceRs.
Q Annotations
Annotations can be applied to classes or fields; by themselves they have no effect at runtime — they are used by the Maven plugin for code generation.
Field-level
Q, QLike, QLikeList, QList, QLogicalDelete, QNil, QNotNil, QT
Class-level
Q, QExclude, QInclude, QOrderBy, QRs
Test
QT — describe test values for generated test stubs.
@Q annotation
Can be used on: String, enums, LocalDateTime, LocalDate, Date, Boolean, boolean, BigDecimal, Integer, Long.
String
@Q
public String code;
Generates in model:
@FilterDef(name = "XXX.obj.code", parameters = @ParamDef(name = "code", type = String.class))
@Filter(name = "XXX.obj.code", condition = "code = :code")
Generates in REST controller:
if (nn("obj.code")) {
search.filter("XXX.obj.code", Map.of("code", get("obj.code")));
}
Enum
@Enumerated(EnumType.STRING)
@Q
public MovementReason movementReason;
if (nn("obj.movementReason")) {
search.filter("XXX.obj.movementReason", Map.of("movementReason", get("obj.movementReason")));
}
With QOption.EXECUTE_ALWAYS:
@Enumerated(EnumType.STRING)
@Q(condition = "BLANK_DELIVERY", options = {QOption.EXECUTE_ALWAYS})
public OperationType operationType;
search.filter("XXX.obj.operationType", Map.of("operationType", "BLANK_DELIVERY"));
LocalDateTime / LocalDate / Date
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Europe/Rome")
@Q
public LocalDateTime execution_date;
Generates two FilterDefs (from. and to.):
if (nn("from.execution_date")) {
LocalDateTime date = LocalDateTime.parse(get("from.execution_date"));
search.filter("XXX.from.execution_date", Map.of("execution_date", date));
}
if (nn("to.execution_date")) {
LocalDateTime date = LocalDateTime.parse(get("to.execution_date"));
search.filter("XXX.to.execution_date", Map.of("execution_date", date));
}
BigDecimal
@Q
public BigDecimal weight;
if (nn("obj.weight")) {
BigDecimal numberof = new BigDecimal(get("obj.weight"));
search.filter("XXX.obj.weight", Map.of("weight", numberof));
}
Integer
@Q
public Integer quantity;
if (nn("obj.quantity")) {
Integer numberof = _integer("obj.quantity");
search.filter("XXX.obj.quantity", Map.of("quantity", numberof));
}
Long
@Q
public Long quantity;
if (nn("obj.quantity")) {
Long numberof = _long("obj.quantity");
search.filter("XXX.obj.quantity", Map.of("quantity", numberof));
}
Boolean
@Q(prefix = "not")
public boolean default_template;
if (nn("not.default_template")) {
Boolean valueof = _boolean("not.default_template");
search.filter("XXX.not.default_template", Map.of("default_template", valueof));
}
With a fixed condition:
@Q(prefix = "not", condition = "false")
public boolean default_template;
if (nn("not.default_template")) {
search.filter("XXX.not.default_template", Map.of("not.default_template", false));
}
@QNil / @QNotNil
@QNil
@QNotNil
public String executor;
@FilterDef(name = "XXX.nil.executor")
@Filter(name = "XXX.nil.executor", condition = "executor IS NULL")
@FilterDef(name = "XXX.notNil.executor")
@Filter(name = "XXX.notNil.executor", condition = "executor IS NOT NULL")
if (nn("nil.executor")) {
search.filter("XXX.nil.executor");
}
if (nn("notNil.executor")) {
search.filter("XXX.notNil.executor");
}
@QList
String field
@QList
public String uuid;
if (nn("obj.uuids")) {
search.filter("XXX.obj.uuids", Map.of("uuids", asList("obj.uuids")));
}
Integer field
@QList
public Integer id;
if (nn("obj.uuids")) {
search.filter("XXX.obj.uuids", Map.of("uuids", asIntegerList("obj.uuids")));
}
Long field
@QList
public Long id;
if (nn("obj.uuids")) {
search.filter("XXX.obj.uuids", Map.of("uuids", asLongList("obj.uuids")));
}
@QLikeList
@QLikeList
public String tags;
String query = null;
Map<String, Object> params = null;
if (nn("like.tags")) {
String[] tags = get("like.tags").split(",");
StringBuilder sb = new StringBuilder();
if (null == params) { params = new HashMap<>(); }
for (int i = 0; i < tags.length; i++) {
final String paramName = String.format("tags%d", i);
sb.append(String.format("tags LIKE :%s", paramName));
params.put(paramName, tags[i]);
if (i < tags.length - 1) { sb.append(" OR "); }
}
query = (null == query) ? sb.toString() : query + " OR " + sb.toString();
}
PanacheQuery<CostCenter> search;
Sort sort = sort(orderBy);
if (sort != null) {
search = CostCenter.find(query, sort, params);
} else {
search = CostCenter.find(query, params);
}
@QLogicalDelete
@QLogicalDelete
public boolean active = true;
search.filter("XXX.obj.active", Map.of("active", true));
@QInclude / @QExclude (class level)
@QInclude — used on class level to select a class for FilterDef generation. If used, all other classes are ignored.
@QExclude — used on class level to deselect a class from FilterDef generation.
@QT — Test Builder
Use @QT to describe test values and override defaults for generated test stub classes.
@QT(defaultValue = "default_fiscal_code", updatedValue = "updated_fiscal_code")
public String fiscal_code;
Generate test classes with:
./mvnw queryable:testsources
Default values used when @QT is not applied:
String:"defaultValue_" + fieldName/"updatedValue_" + fieldNameint,Integer,long,Long:0/1boolean,Boolean:false/trueLocalDateTime:LocalDateTime.now()/LocalDateTime.now().plusDays(1)LocalDate:LocalDate.now()/LocalDate.now().plusDays(1)BigDecimal:0/1