Models and QModels¶
A model is the shape of a result: a plain class or a record you write. A QModel is the class the annotation
processor generates from it (QOrderView for OrderView), holding typed constants you build queries from.
Writing a model¶
Annotate the type with @QueryModel and name the JPA entity it reads from. A field maps to the entity attribute of
the same name.
@QueryModel(root = OrderEntity.class)
public record OrderView(
@PrimaryKey Long id,
String status,
BigDecimal total,
@Join Optional<CustomerView> customer) {}
| Annotation | Use |
|---|---|
@QueryModel(root = ...) |
Marks a model and names its root entity. prefix and suffix rename the generated class; singleGroup is covered in Grouped queries, generateChanges in Shared accessors across models. |
@PrimaryKey |
The entity id. Paging, export and nested-model presence read it. Required on a model without aggregates. |
@Column(attribute = ..., converter = ...) |
Maps a field to a differently named attribute, or one inside an embedded value, or converts between the model type and the attribute type. |
@Join |
A nested model read through a to-one association. The field must be an Optional of another @QueryModel; it is empty when a LEFT join found nothing. |
@FilterColumn(name, path, ...) |
Adds a filter-only constant for an attribute path (customer.country) that the model does not select. |
@ExcludeFromDefaults |
Leaves a heavy column (a BLOB, a long text) out of the generated default column set. |
@Transient |
Keeps a field out of the mapping. |
@GroupBy, @Aggregate |
Turn a model into a grouped result; see Grouped queries. |
@UpdateModel(root = ...) |
Declares the attributes a bulk update may write; see Bulk writes. |
A field of type Instant or Date over a java.sql.Timestamp attribute needs no converter: the processor gives it
the built-in InstantTimestampConverter or DateTimestampConverter, so you filter it with values of the field's type:
a Date placedAt field takes f.gte(QOrderView.PLACED_AT, Optional.of(since)) with since a Date. A Date read
this way is the Timestamp itself, so comparing it back to the stored value is exact. A plain java.util.Date binds
at whole milliseconds, so write an inclusive upper bound as half-open, lt(nextDayStart): lte(23:59:59.999) excludes
a stored 23:59:59.999500. An Instant beyond the range of Timestamp is refused with MQ1308. A converter you
name takes precedence.
A class model needs a no-argument constructor visible from its package and setters. Record components that are primitive are only allowed on the primary key of a plain model; use the boxed type elsewhere, because a column can be NULL.
What the processor generates¶
For OrderView you get QOrderView with:
- a
ColumnFieldconstant per field (ID,STATUS,TOTAL), and per joined column (CUSTOMER_NAME); - a
SelectSetnamedALLwith the model's own columns,DEFAULT(the same minus@ExcludeFromDefaults), and one set per@Join(CUSTOMER); - a constant per
@FilterColumn, and aTableFieldfor every collection association on the root (ITEMS_TABLE), which you use withFilters.exists; ROOT,KEY(the primary key) andMAPPER;query(), a pre-configuredModelQuerybuilder, anddelete().
var q = QOrderView.query()
.select(QOrderView.ALL.with(QOrderView.CUSTOMER))
.where(f -> f.eq(QOrderView.CUSTOMER_COUNTRY, "DE"))
.orderBy(QOrderView.CUSTOMER_NAME.asc().nullsFirst(), QOrderView.ID.desc())
.build();
A SelectSet is immutable: with(...) and without(...) return copies, so a shared constant cannot be changed by
one caller and affect another.
Joins¶
A @Join nested model is read through a join the library creates for you. Selecting, filtering and ordering on the
same path use exactly one join. Two @Joins on the same attribute become two joins, each aliased by its field name.
A filter-only column that crosses a collection needs an explicit joinType on its @FilterColumn, because joining a
collection multiplies rows; prefer Filters.exists for "has a child matching X".
A generated @Join follows the mapped association, whatever that association joins on. A @ManyToOne with
@JoinColumn(referencedColumnName = "sku") joins through that unique non-key column instead of the target's key, and a
Hibernate @JoinFormula joins through its computed expression; selecting, filtering and sorting on the nested model's
columns use the join the mapping implies. When a join needs an extra ON condition that is not part of the mapping —
the case @Join(on = ...) would cover — write a hand-written TableField and add it with as(...).on(...). The
condition goes through Join#on, so a LEFT join still keeps the rows that miss it. See
Migration recipes for both.
Hand-written columns¶
You rarely need to, but a ColumnField can be written by hand, for example for a column the server sets:
static final OrderedColumnField<OrderPatch, OrderEntity, Instant> UPDATED_AT =
ColumnField.of(OrderPatch.class, QOrderPatch.ROOT, "updatedAt", Instant.class);
A column with no converter, or with an OrderedColumnConverter, is an OrderedColumnField, which Agg.min, Agg.max
and Agg.countDistinct take. A column with any other converter is a plain ColumnField, and those three do not
compile over it.
Computed fields¶
A field whose value the database computes, rather than one it stores, is marked @Computed with a class implementing
ExpressionDefinition<Model, FieldType>:
@QueryModel(root = OrderEntity.class)
public record OrderNet(@PrimaryKey Long id, @Computed(RowNet.class) BigDecimal net) {}
/** {@code total * 2} over {@code orders}, typed to the record model {@link OrderNet} (R-PROC-21). */
public final class RowNet implements ExpressionDefinition<OrderNet, BigDecimal> {
public static final RowNet INSTANCE = new RowNet();
private RowNet() {}
@Override
public ExpressionField<OrderNet, BigDecimal> expression() {
return Expr.times(ColumnField.of(OrderNet.class, TableField.root(OrderEntity.class), "total",
BigDecimal.class), BigDecimal.valueOf(2));
}
}
The generated constant is an ExpressionField named after the field, emitted after the root, joined and filter-only
column constants and after earlier @Computed constants — the @Aggregate constants come last — so a definition
reading Q<Model>'s own constants finds them set (R-GEN-27), and selected by ALL and DEFAULT unless
@ExcludeFromDefaults. The definition's type arguments must be exactly the model and the field's boxed type, and the
class must have a public INSTANCE field or a visible no-arg constructor, or the processor reports MQ3018;
@Computed can't share a field with another mapping annotation, or sit on a primitive field, and then reports
MQ3019. Build the expression inside expression(), or from Q<Model> constants, never from a static constant the
generated class initialises. The expression vocabulary, @Aggregate(expression = ...) and where an expression plugs
in are on Sub-queries and expressions.
Tested by GeneratedModelTest.
Selected fields¶
A record gets null for a column the query did not select, and so for a selected column that is NULL; a class keeps
the field's initialiser in both cases. To tell them apart, declare one @Selected SelectSet<Model> field or record
component, Model being the model itself:
@QueryModel(root = OrderEntity.class)
public record OrderView(
@PrimaryKey Long id,
String status,
@Column(attribute = "referrerId") Long referrerKey,
@Selected @JsonIgnore SelectSet<OrderView> selected) {}
The generated mapper fills it on every row, never with null, with the constants of QOrderView it reads that the
row selected: columns, @Computed and @Aggregate constants and joined columns of any depth. A selected column that is
NULL is in the set, and a column the query left out is not:
OrderView order = executor.list(QOrderView.query().select(SelectSet.of(QOrderView.STATUS)).build(), Limit.of(1)).get(0);
order.selected().contains(QOrderView.STATUS); // true, whatever the status is
order.selected().contains(QOrderView.REFERRER_KEY); // false: never selected, so `null` means nothing
What the set holds is what the row selected, so it can hold more than the caller's own SelectSet: the primary key and
the ordering, group, fetch-plan and join-presence columns the engine adds are in it, because the field holds them. A
column used only in a filter, or an orderBy on a filter-only column, is not, since no field of the model holds it. A
nested @Join or @Child model with its own @Selected field fills it from its own row, and a LEFT-join miss builds no
nested model, so there is nothing to fill. Every row of one query shares one set instance.
The field is no column and no constant, and cannot be combined with another field annotation (MQ3021); its type must be
exactly SelectSet<Model>, and a model has one (MQ3020). An update model and an insert model refuse it (MQ3302,
MQ3502). Put @JsonIgnore (or your serialiser's equivalent) on it: it is for server-side code that reads a model,
not for a response body. @Selected, SelectSet.contains and SelectSet.selectedIn are @Incubating. contains is
exact, as Row.isSelected is, and never looks through a converter.
A record finisher builds a copy through the canonical constructor, so it passes selected() along like any component
it does not change: finisher(o -> new OrderView(o.id(), label(o), o.referrerKey(), o.selected())). The library does
not fill the set a second time, so a copy built with null holds null (R-GEN-31).
Tested by SelectedFieldsTest and SelectedPersistTest.
Shared accessors across models¶
A type carries one of @QueryModel, @UpdateModel and @InsertModel (MQ3503 otherwise), and the processor reads
only the fields a model declares itself, never a superclass's: a field inherited from a base class is not a column.
When a read model and a write model over one entity share fields, declare them in each record and share the accessors
through an interface instead:
public interface CustomerContact {
String name();
String country();
default String label() {
return name() + " (" + country() + ")";
}
}
@QueryModel(root = CustomerEntity.class)
public record CustomerCard(@PrimaryKey Long id, String name, String country) implements CustomerContact {}
@UpdateModel(root = CustomerEntity.class)
public record CustomerContactPatch(@PrimaryKey Long id, String name, String country) implements CustomerContact {}
Code written against CustomerContact (a label, a validator, a mapper to a DTO) then takes either model, and each
model keeps its own generated class: QCustomerCard for queries, QCustomerContactPatch and its change set for
updates. Each record still lists its own components, which is what keeps an update model to the attributes its
endpoint may write. Tested by SharedAccessorModelTest.
For internal code that only needs to write back what it read, @QueryModel(generateChanges = true) gives one model a
change set over its root, non-key columns as well; don't bind that change set from a request, since it can write every
root column the model reads.
Keep the prefix consistent¶
The default class name is Q plus the model name. If you change prefix or suffix through the
-Amodelquery.prefix= / -Amodelquery.suffix= processor options, use the same value in every module that reads a
nested model across module boundaries, or set it on the nested model's own annotation.
Compile-time checks¶
The processor checks your models when you compile: an unknown attribute, a type that does not match the entity, a
missing primary key, a @Join that is not an Optional of a model, and so on. Failures are reported as MQ3xxx
errors; see Diagnostics.