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_" + fieldName
  • int, Integer, long, Long: 0 / 1
  • boolean, Boolean: false / true
  • LocalDateTime: LocalDateTime.now() / LocalDateTime.now().plusDays(1)
  • LocalDate: LocalDate.now() / LocalDate.now().plusDays(1)
  • BigDecimal: 0 / 1