Install Queryable
Prerequisites: an existing Quarkus project using JDK 21 and Hibernate ORM with Panache.
For a new project, begin with the complete Getting Started guide.
AI agents and automated tools should start with llms.txt. It is also available in AGENTS.md format on this website.
Add the plugin
./mvnw it.n-ess.queryable:queryable-maven-plugin:3.0.12:add
Or add it directly to the build section of pom.xml:
<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>
Generate sources
Initialize the minimal Queryable API and sample entity once:
./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).
Process all model classes
After creating or changing 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).
Since 3.0.10, REST V3 entity imports use the package declared in the entity source, including model subpackages
and custom model packages. Upgrade the plugin and rerun ./mvnw queryable:source to repair legacy
entity imports in existing services. REST resource packages and application constant imports remain unchanged.
See the 3.0.12 release notes.
Process selected model classes
Pass one or more simple class names separated by commas:
./mvnw queryable:source -Dclasses=Customer,Order
Exclude selected model classes
Process all eligible model classes except the listed simple class names:
./mvnw queryable:source -DexcludeClasses=Customer,Order
- Use simple Java class names, without package names or
.java. - Whitespace and duplicate names are ignored.
- Unknown names are ignored.
- An explicit
classeslist takes precedence over@QInclude. excludeClassestakes precedence overclassesand@QInclude.- Explicitly excluded classes are not parsed or regenerated.
@QExcludeis always respected.
JPA @Entity classes location
The plugin searches for classes extending PanacheEntityBase in {groupId}.{artifactId}.model and its subpackages, or the configured model directory.
JAX-RS classes location
The plugin searches for @Path @Singleton classes in {groupId}.{artifactId}.service.rs following the pattern {EntityName}ServiceRs.
Mirror model subpackages (V3 and V4, since 3.0.12)
Enable this option in the consuming project's plugin configuration:
<plugin>
<groupId>it.n-ess.queryable</groupId>
<artifactId>queryable-maven-plugin</artifactId>
<version>3.0.12</version>
<configuration>
<useModelSubPackage>true</useModelSubPackage>
</configuration>
</plugin>
Run ./mvnw queryable:source for V3 or
./mvnw queryable:sourcev4 for V4.
The CLI equivalent is -DuseModelSubPackage=true.
The default is false.
| Entity | Omitted / false | true |
|---|---|---|
| xxx.model.Report | xxx.service.rs.ReportServiceRs | xxx.service.rs.ReportServiceRs |
| xxx.model.personale.Report | xxx.service.rs.ReportServiceRs | xxx.service.rs.personale.ReportServiceRs |
| xxx.model.finance.monthly.Report | xxx.service.rs.ReportServiceRs | xxx.service.rs.finance.monthly.ReportServiceRs |
The complete relative entity directory under the configured model root is mirrored;
no subpackage name is hard-coded. Sources must follow the usual Java package/directory layout.
Class names remain {EntityName}ServiceRs. Root entities, REST URLs,
entity packages and application constant imports are unchanged.
Custom roots work too: with sourceModelDirectory=domain/entities and
sourceRestDirectory=web/rest, an entity in
xxx.domain.entities.archive.annual produces a service in
xxx.web.rest.archive.annual.
Switching the option automatically relocates an existing service in either direction.
Manual methods, @QExclude controls and V3 @QPreservedFilter
hooks survive. The old source is removed only after the destination is successfully written.
If both locations already contain a service, generation fails without overwriting either copy.
Migration: review the diff. Update imports or fully qualified references in other classes, and dependencies on old package-local helpers, in the consuming project. The generator relocates the service, not arbitrary application code. Discovery still uses simple entity names; duplicate entity simple names are not newly supported.
QEEX: REST Error Catalogs
QEEX is an optional source generator for typed REST exceptions, separate from entity filters. An annotated interface becomes an application-scoped CDI implementation. Inject the interface and throw a returned QeexWebException to expose a structured JSON error.
Follow the complete customer-error example for a catalog, endpoint, curl request and expected HTTP 404 response.
Installation and generation
After initializing the minimal API with queryable:install, install QEEX support:
./mvnw queryable:qeexinstall
Warning: installation deletes and replaces {groupId}.api.service.RsResponseService. Preserve customizations before running or rerunning it.
The goal creates QeexConfig, QeexExceptionBundle, QeexMessage, QeexWebException, LanguageInterceptor and the two exception mappers under {groupId}.api.qeex, plus a sample {groupId}.{artifactId}.service.exception.ExceptionBundle.
After editing the catalog, generate its implementation:
./mvnw queryable:qeexsource
This scans annotated interfaces under src/main/java and generates {InterfaceName}Impl alongside each interface. Edit the interface, not the generated implementation. Rerun after catalog changes; queryable:source does not perform this generation.
Configuration
Keep src/main/resources/application.properties present during generation and explicitly configure the defaults read by the catch-all mapper:
qeex.project=AWESOME
qeex.default.id=100
qeex.default.code=500
qeex.default.message=Unexpected application error
qeex.default.language=en
qeex.projectoverrides the project name declared on the interface.qeex.default.code,qeex.default.messageandqeex.default.languagecontrol the catch-all mapper. Several values are read usingOptional.get(); do not leave them unset.qeex.default.idis parsed but is not assigned by the catch-all mapper, whose current ID is 0.- Although
QeexConfigdefinesqeex.messages[...]overrides and translation helpers, generated catalog methods do not call them. Their messages and statuses come directly from annotations.
Annotation reference
These annotations are generated in the consuming application's {groupId}.api.qeex.annotations package, not in it.ness.queryable.annotations.
@QeexExceptionBundle
Apply to an interface. Required attributes: project and language. Other declared attributes: id (default 100), code (default 400), message (default empty).
Generation uses project and the starting id; it does not apply the bundle's code, message or language as runtime defaults.
@QeexMessage
Apply to methods returning QeexWebException. Use explicit id, HTTP code and message. Parameters are interpolated using Java String.format, in declaration order.
@QeexMessage(id = 101, code = 404, message = "Customer %s not found")
QeexWebException customerNotFound(String customerCode);
The annotation declares defaults of id = -1, code = 400 and an empty message. The source generator reads explicit literals: an omitted ID is allocated starting at the bundle's id + 1, and an omitted method status generates 500. Explicit IDs avoid changes to the client contract when methods are reordered.
HTTP contract and current limitations
QeexWebException is a checked exception. Let it propagate out of a REST endpoint (declare throws QeexWebException) so QuarkusWebExceptionProvider returns its HTTP code and these JSON fields:
{
"projectName": "AWESOME",
"id": 101,
"code": 404,
"message": "Customer C001 not found",
"language": "en"
}
- QEEX-aware
RsResponseService.jsonErrorMessageResponseandjsonMessageResponseinstead return HTTP 500 for QEEX errors, regardless of the payload'scode. - The custom
languageheader is notAccept-Languageand does not automatically translate messages. Generated methods fall back toen. LanguageInterceptorretains mutable language state in an application-scoped bean and does not clear it for requests without a header. It is unsafe for per-request language isolation, including concurrent requests.- The exception mapper concatenates JSON without escaping message arguments. Quotes, backslashes and control characters can produce invalid JSON; address serialization before using arbitrary user input.
QuarkusWebDefaultExceptionProviderhandles non-QEEX exceptions using the configured default status and message, with error ID 0. Review this global policy before enabling QEEX in an existing application.
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
The generated @ParamDef uses the field's effective type: String, Integer, Long, or BigInteger.
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")));
}
BigInteger field
@QList
public BigInteger id;
@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.
@QPreserveFilters / @QPreservedFilter (V3, since 3.0.11)
Import both annotations from it.ness.queryable.annotations.
Protect manual Hibernate definitions by listing complete, exact, case-sensitive literal names on the entity:
@Entity
@QPreserveFilters({"Customer.obj.name"})
@FilterDef(name = "Customer.obj.name",
parameters = @ParamDef(name = "manualName", type = String.class))
@Filter(name = "Customer.obj.name", condition = "lower(name) = :manualName")
public class Customer extends PanacheEntityBase {
@Id public String uuid;
@Q public String name;
@Q public String code;
}
Before regenerating, move the corresponding inline custom block from
CustomerServiceRs.getSearch() into a manual service method:
@QPreservedFilter("Customer.obj.name")
private void applyManualName(PanacheQuery<Customer> search) {
if (nn("obj.name")) {
String name = get("obj.name").toLowerCase(java.util.Locale.ROOT);
search.filter("Customer.obj.name", Map.of("manualName", name));
}
}
Run ./mvnw queryable:source. The generator preserves the named
@FilterDef and @Filter, skips their automatic REST block,
regenerates other filters, and calls applyManualName(search) after
automatic filters and before returning the query. It does not rewrite the manual method.
- Use literal names, not constants or expressions. Duplicate entity entries are deduplicated; blank names are rejected. Source formatting may be normalized.
- Both Hibernate annotations must already exist on the entity. Repeated annotations and
@FilterDefs/@Filterscontainers are supported for protected definitions. - Protection wins over field and class-level Queryable annotations. List filters use their
plural names. Temporal bounds are independent: preserving
Customer.from.createdAtdoes not suppressCustomer.to.createdAtorCustomer.obj.createdAt. - A hook must be a concrete non-static
voidmethod declared in the service, taking exactly onePanacheQuery<Customer>parameter. Checked exceptions are allowed. Hooks run in source declaration order, with at most one per filter. - Unknown hook names, duplicate bindings, invalid signatures and missing definitions fail generation before rewriting that entity or service. V3 generation errors fail the Maven goal.
- Without a hook the definition remains, but the filter is not automatically activated; generation warns. New services do not receive invented manual logic.
- Existing inline blocks are not automatically extracted. Move them into annotated methods
first, or use
@QExcludeongetSearch()to preserve the entire method. - Hooks apply Hibernate filters after query creation, not pre-query HQL transformations. V4 generation is unchanged.
@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