Compare commits

...
Author SHA1 Message Date
Rob Bygrave 805e309fb0 Merge branch 'master' into feature/dto-extensions 2026-07-16 16:54:46 +12:00
robin.bygrave adb2e6a5d2 MappedQuery - Add findStream() support 2026-07-16 16:51:27 +12:00
robin.bygrave cbd67a6c22 DtoQuery - Support DtoPath renaming a ToOne or ToMany 2026-07-16 16:23:13 +12:00
Rob Bygraveandrobin.bygrave 30314f163d DtoQuery - add usingMaster, usingTransaction, usingConnection options. (#3853)
Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-16 15:50:07 +12:00
robin.bygrave 893dc599cd DtoQuery - add usingMaster, usingTransaction, usingConnection options. 2026-07-16 15:49:15 +12:00
robin.bygrave b065b030bc Bump ebean-agent to 18.3.0 2026-07-16 10:33:29 +12:00
robin.bygrave 837ba91126 Tests: Improve Redis testing - flush redis after test runs 2026-07-16 09:55:26 +12:00
robin.bygrave e18c664c98 Tests: Improve RedissonCacheFactoryTest 2026-07-16 08:54:17 +12:00
Rob Bygraveandrobin.bygrave 636bb28df3 #2540 Add DTO mapping from Entity beans via generated mapper code (#3852)
* #2540 Add DTO mapping from Entity beans via generated mapper code

A large feature, refer to the docs / guides etc. This is a bit like MapStruct in that it generates mapping code via annotation processing but it ALSO creates an optimised FetchGroup for the destination DTO graph and it also implements a generic interface such that it can be fluently used via query.mapTo().

The value is that it automates the mapping code from entity -> dto plus optimises the query used to support building the DTO graph.

Expected to be better to use than MapStruct for ebean orm users.

* Bump ebean-annotation to 8.6 with the dto annotations

* Adjust the build order to via test dependency on ebean-api

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-16 08:40:41 +12:00
Rob Bygraveandrobin.bygrave 84a311e521 Bug: filterMany() expressions when additional nested fetch has predicates in wrong place (#3851)
* Bug: filterMany() expressions when additional nested fetch has predicates in wrong place

When a filterMany() expression only references the many-root's own properties (e.g. .eq("status", ...)),
but the query also has an additional nested fetch beneath that many-path (e.g. .fetch("orders.customer")
or .fetch("contacts.group")), the filter predicate was incorrectly attached to the last/deepest join's
ON clause instead of the many-root's own join — silently misapplying the filter.

Fix: A hybrid approach:

- If the filterMany expression references only root-level properties → attach the predicate directly on the many-root's own join (correct).

 - If it references deeper/nested properties requiring "extra joins" (a separate existing mechanism) → fall back to the original end-of-subtree behavior, since no SqlTreeNode exists to attach to.

 - includeFilterMany() made idempotent so both mechanisms coexist safely.

* Bug: filterMany() with nested-property predicates silently ineffective
filterMany() expressions referencing a nested/dotted property (e.g.
.eq("group.name", "x"), alone or mixed with root properties) were
attached to a LEFT JOIN's ON clause via the "extra join" mechanism.

That only nulls the extra join's own columns on non-match - it does
not exclude the parent/many-side row, so the predicate had no actual
filtering effect (confirmed by data, not just SQL shape).

## Fix:

force any many-property fetch whose filterMany expression
references a nested property onto a query-join (secondary select
restoring filterMany's original always-fetchQuery behaviour for this
with a genuine WHERE clause) instead of leaving it as an inline JOIN, case.

- SpiExpressionValidation: track all visited property names (allProperties()), not just unknown ones.
- OrmQueryProperties: filterManyHasNestedProperty() walks the filterMany expression for any dotted property reference.
- OrmQueryDetail.markQueryJoins(): route nested-property filterMany chunks to markForQueryJoin() instead of the inline fetch-join slot, without consuming that slot for other many-paths.

Also simplifies the earlier root-only-predicate join-placement fix
(CQueryPredicates/DefaultDbSqlContext/SqlTreeNodeManyRoot) now that
a nested-property filterMany can never reach the JOIN-based code
path: removed the now-unreachable "deepest path" fallback branch,
the includeFilterMany() idempotency guard, and the redundant fallback
call in SqlTreeNodeManyRoot - isFilterManyAttachPoint() remains as
the single, always-used attach mechanism.

Updated TestQueryFilterMany/TestQueryFilterManySimple assertions to
expect the corrected query-join (2 statement) behaviour.

* Simplify SpiExpressionValidation with boolean nestedProperty flag

* Tidy up comments

* Fix OrmQueryProperties.filterManyHasNestedProperty() with real nested property check

---------

Co-authored-by: robin.bygrave <robin.bygrave@eroad.com>
2026-07-15 23:16:18 +12:00
102 changed files with 7675 additions and 43 deletions
+3 -1
View File
@@ -54,7 +54,8 @@ Ebean is an ORM library for Java and Kotlin focused on relational data access, t
| `exists()` | Efficient existence checks | `new QCustomer().email.equalTo(email).exists();` |
| `findOne()` | Unique/single-row retrieval | `new QCustomer().id.equalTo(id).findOne();` |
| `findList()` | List retrieval | `new QCustomer().findList();` |
| `asDto(...).findList()` | DTO projection reads | `new QOrder().asDto(OrderSummary.class).findList();` |
| `asDto(...).findList()` | Flat DTO projection reads | `new QOrder().asDto(OrderSummary.class).findList();` |
| `mapTo(...).findList()` | Nested DTO graph projection reads | `new QCustomer().mapTo(CustomerDto.class).findList();` |
### Entity mapping and lifecycle annotations
@@ -188,6 +189,7 @@ database.save(customer);
| Model entity beans correctly | [entity-bean-creation.md](guides/entity-bean-creation.md) |
| Use Lombok safely with entities | [lombok-with-ebean-entity-beans.md](guides/lombok-with-ebean-entity-beans.md) |
| Write type-safe query bean queries | [writing-ebean-query-beans.md](guides/writing-ebean-query-beans.md) |
| Map nested entity graphs to DTO graphs | [mapping-entity-graphs-to-dtos.md](guides/mapping-entity-graphs-to-dtos.md) |
| Persist changes and manage transactions | [persisting-and-transactions-with-ebean.md](guides/persisting-and-transactions-with-ebean.md) |
| Build test entities quickly | [testing-with-testentitybuilder.md](guides/testing-with-testentitybuilder.md) |
+709
View File
@@ -0,0 +1,709 @@
# Nested DTO Mapping — API Design
Design spike for the accepted requirements in [dto-mapping-requirements.md](./dto-mapping-requirements.md),
covering issue #2540. This captures the concrete API shape, annotations, and open-question decisions made
during design review — before implementation begins.
## Two source-vs-target mapping pipelines
Ebean now has (or will have) two distinct DTO pipelines. It's important callers can tell which one they're
using:
1. **`asDto(Dto.class)`** (existing) — a `DtoQuery`, executed directly against a flat SQL `ResultSet`.
One row -> one DTO, via constructor/setter matching. No nested ToOne/ToMany support, no entity graph
involved.
2. **`mapTo(Dto.class)`** (new) — runs the normal ORM entity query (joins/fetches as usual), producing an
*unmodifiable entity graph*, then maps that Java object graph into a DTO graph. Supports nested
ToOne/ToMany, identity-aware de-duplication, and derives its own fetch spec from the DTO shape.
## Proposed API
```java
// Existing flat DtoQuery pipeline — unchanged
new QUser().valid.eq(true)
.select(firstName, lastName)
.asDto(UserInfo.class)
.findList();
```
```java
// NEW: nested DTO graph pipeline
public class CustomerDto {
Long id;
String name;
AddressDto billingAddress; // ToOne -> nested DTO, matched by property name "billingAddress"
List<ContactDto> contacts; // ToMany -> nested DTO list, matched by property name "contacts"
@DtoPath("billingAddress.line1")
String billingLine1; // renamed / flattened path
}
public class ContactDto {
Long id;
String firstName;
String lastName;
@DtoRef
Long customerId; // id-only back-reference, avoids re-embedding CustomerDto (cycle)
}
List<CustomerDto> dtos = new QCustomer()
.status.eq(Status.ACTIVE)
.mapTo(CustomerDto.class)
.findList();
```
Computed values (e.g. `cityOrUnknown` derived from `coalesce(billingAddress.city, 'Unknown')`) are
**not** modeled with a `@Formula2` annotation directly on the DTO - that was explored and rejected
(see "Formula2-on-DTO scope" below). Instead they're modeled as a plain matching field on the DTO,
sourced from an `@Entity @View` entity that itself carries the `@Formula2` - see "Computed/aggregate
properties" below for the worked example.
`mapTo(CustomerDto.class)`:
- Introspects `CustomerDto` (recursively, at codegen time) to derive the `select(...)`/`.fetch(...)` spec
automatically from the DTO's declared shape.
- Forces `setUnmodifiable(true)` under the hood — gives fail-fast + a cheap, non-mutable source graph
(satisfies the fail-fast requirement without a separate flag).
- Runs the query, then runs a mapper over the resulting entity graph, de-duplicating DTO instances by id
for repeated nested references (identity-aware, mirrors the source entity graph's own de-duplication).
## Decisions made
### Fetch spec: auto-derived from DTO shape
The DTO's declared structure (fields, nested DTO types, `@DtoPath` overrides) is the single source of truth
for what gets selected/fetched from the database. Callers do not need to separately maintain a `.fetch(...)`
spec in parallel with the DTO — this directly addresses the original issue's pain point (DTO and query
projection drifting out of sync).
### Entry point naming: `mapTo(Dto.class)`
Chosen over `asGraph(...)` / `into(...)` / overloading `findList(Class)`. Reads clearly as "map the
resulting entity graph to this DTO type" and is unambiguous against the existing `asDto(...)` (flat,
SQL-row-based) mechanism.
### Cycle handling: codegen-time DAG check + `@DtoRef` escape hatch
Because the fetch spec and mapper are both derived from the *static* DTO type graph (not live object
traversal), cycle detection is a compile-time/codegen-time concern, not a runtime one. This is stronger
than the common approach in the ecosystem:
- **MapStruct** does not auto-detect cycles. It offers an opt-in `@Context` "cycle guard" pattern (an
identity map of already-mapped source -> target objects) that the developer must wire up manually to
avoid infinite recursion mapping bidirectional object graphs.
- **Blaze-Persistence / QueryDSL / JOOQ record mapping** avoid the problem architecturally: view/projection
types are required to be a strict tree; a back-reference is modeled as an id or a much shallower type,
never the same full view type again.
Ebean's approach: fail the build at annotation-processing time if a DTO's declared type graph is not a DAG,
with a clear error message. Provide `@DtoRef` as an explicit escape hatch for intentional back-references
(e.g. `Contact.customer`) — it maps only the id, not the full nested DTO, breaking the cycle by design
rather than by runtime guard.
### `@DtoPath` / `@DtoRef`: parallels for readers coming from MapStruct or Blaze-Persistence
Neither annotation is a novel concept - both map onto things MapStruct and Blaze-Persistence users will
already recognise, which is worth spelling out explicitly so it's easy to "grok fast":
- **`@DtoPath("billingAddress.line1")` is Ebean's equivalent of MapStruct's dot-path `source` flattening**
— e.g. `@Mapping(target = "line1", source = "billingAddress.line1")`. MapStruct auto-generates a
null-safe chain of getter calls for a dotted `source`; `@DtoPath` does exactly the same thing, just
declared on the DTO field itself rather than on a mapper method parameter list. It is also close to
Blaze-Persistence's `@Mapping("billingAddress.line1")` on an `@EntityView` attribute, which is a JPQL
path expression evaluated the same way — Blaze's placement (directly on the target view property) is
actually the closer analogue of the two, since Ebean's `@DtoPath` is likewise placed on the DTO field.
The difference from Blaze: `@DtoPath` is restricted to plain getter-chain navigation (no arbitrary JPQL/
SQL expression) - see "Formula2-on-DTO scope" below for the boundary and why full expression support is
deliberately deferred.
- **`@DtoRef` has no dedicated equivalent in either tool** - both MapStruct and Blaze would express the
same "just the id" mapping as a plain dot-path to `.id` (`@Mapping(source = "customer.id")` / Blaze
`@Mapping("customer.id")`), with no special marker for it. What `@DtoRef` adds beyond that shorthand is
*intent*: it tells the codegen this property is a deliberate cycle-breaking reference, so (a) it adds
just the association's own name (not a dotted `.id` path) to the generated fetch spec's root
`select(...)` - reading the FK column directly with no join, and skipped entirely if that same
association is already fully fetched by a `NESTED_ONE`/`NESTED_MANY` property elsewhere on the same DTO
(see `DtoMapperWriter.fetchGroupChainCalls()`'s `case REF` branch) - and (b) it participates in the
codegen-time DAG cycle check above as an explicit "this is fine, don't flag it" signal, rather than
requiring a suppression escape hatch bolted on afterwards.
**Bug found and fixed while building the aggregation worked example below:** the original implementation
excluded `REF` properties from the fetch spec *entirely*, on the assumption the id is "already available
off an unfetched reference without triggering a fetch/lazy load". That assumption is only true when some
*other* property on the same DTO happens to also fetch that association (as was always the case in the
existing hand-built examples). Tested directly against a bare `@ManyToOne` with no other fetch of it:
accessing `.getCustomer().getId()` in that case triggers a full lazy-reload of the owning row (extra SQL,
not free) - and for an aggregation query it's worse, since the property being grouped by must be selected
or the query can't group correctly at all. Fixed so `REF` always contributes its association name to the
root `select(...)` (deduped against any existing `NESTED_ONE`/`NESTED_MANY` fetch of the same path).
### Read-only entity memory overhead: `InterceptReadOnly`
`setUnmodifiable(true)` isn't just a behavioural fail-fast flag - it also swaps the per-bean intercept
implementation to `InterceptReadOnly`, which is deliberately minimal: just a `boolean[] loaded` (one flag
per property) and a `boolean frozen`, plus the inherited owner reference and `fullyLoadedBean` flag. Compare
to `InterceptReadWrite` (the mutable/updatable variant), which additionally carries a `ReentrantLock`, four
transient collaborator references (`NodeUsageCollector`, `PersistenceContext`, `BeanLoader`,
`PreGetterCallback`), a `byte[] flags` array (per-property loaded+changed+dirty+orig-value-set state),
`Object[] origValues`, `Exception[] loadErrors`, `MutableValueInfo[]`/`MutableValueNext[]`, and several more
scalar bookkeeping fields. None of that is needed for a bean that will only ever be read, so
`setUnmodifiable(true)` graphs carry meaningfully less per-instance overhead than normal fetched entities -
relevant here because `mapTo(Dto.class)` forces `setUnmodifiable(true)` on its underlying query, making the
*source* graph for a DTO mapping cheaper than the equivalent normal (writable) entity graph would be.
### Ad-hoc computed/formula properties: model as `@Entity @View`/`@Sql`, not ad-hoc SQL-on-DTO
The "fully ad-hoc SQL-on-DTO" stretch goal above (closer to Blaze's arbitrary `@Mapping` expressions) doesn't
need to be built as a bespoke DTO-annotation-processing feature. Ebean already supports modelling read-only,
computed, or view-backed data as ordinary entities via `@Entity` + `@View` (backed by a SQL view, e.g. one
with aggregates/computed columns) or `@Entity` + `@Sql` (backed by arbitrary `RawSql`, no base table). Given
that, the Blaze-Persistence-style "an entity view attribute backed by an arbitrary SQL expression" need can
usually be satisfied by:
1. Modelling the computed/derived shape as its own `@Entity @View` (or `@Sql`) "read entity" - the SQL
expression/aggregation lives in the view definition, not in a new annotation-processed DTO mechanism.
`@View`'s `name()` doesn't have to point at a genuinely separate database view - it can just point at
an *existing* table (e.g. `@View(name = "contact")` on a second entity class reading the same table as
`Contact`) purely to mark the entity as view-like/read-only, in which case Ebean's DDL generator emits
**no new table or view at all** for it - it's just a second lens onto the same physical data.
2. Mapping *that* entity into a plain DTO using the existing, already-implemented `@DtoMapping` machinery -
no ad-hoc-SQL-on-DTO support required, since there's no computed expression left to resolve at the DTO
layer at all; it's just another entity-to-DTO mapping.
3. This read entity benefits from the same `setUnmodifiable(true)`/`InterceptReadOnly` memory efficiency
above when used purely as `mapTo(...)` input, so there's no meaningful cost to preferring this over a
hypothetical native ad-hoc-SQL-on-DTO feature.
This significantly narrows (and may eliminate) the case for a dedicated ad-hoc-SQL-on-DTO mechanism - it
remains listed as an open stretch goal below primarily for the case where a computed value's SQL is genuinely
one-off/DTO-specific and not worth promoting to a standalone `@View`/`@Sql` entity.
**Worked example** (`tests/test-dto-mapping`): `ContactSummary` is `@Entity @View(name = "contact")` (no new
DDL - reads the same table as `Contact`) with `@Formula2("concat(firstName, ' ', lastName)")` computing
`fullName`; `ContactSummaryDto` is a plain two-field DTO; `@DtoMapping(source = ContactSummary.class, target
= ContactSummaryDto.class)` generates `ContactSummaryDtoMapper` exactly like any other entity→DTO pair - the
formula property is just selected like any other field (`select("id,fullName")` in the generated
`fetchGroup()`). See `TestContactSummaryDtoMapping`.
### Aggregate/group-by computed properties: `@Sum`/`@Aggregation` as `@Entity @View`, same pattern
Ebean's `@Sum` (shorthand for `@Aggregation("sum($1)")`) and `@Aggregation("count(...)"/"sum(...)"/"avg(...)"/
"min(...)"/"max(...)")` are the group-by parallel to the formula pattern above - the same `@Entity @View`
approach applies, just with an implicit `GROUP BY` instead of a per-row computed column. Ebean auto-derives
the `GROUP BY` clause from whichever non-aggregate properties end up in the query's `select()`/`fetch()` - so
a second `@Entity @View(name = <same base table>)` entity with one or more `@Sum`/`@Aggregation` properties
plus a `@ManyToOne` grouping key becomes a per-parent rollup, with **no new table/view and no explicit
`.groupBy()` call required**. This is Ebean's parallel to Blaze-Persistence entity view correlated aggregate
mappings, e.g. `@Mapping("SIZE(contacts)")` / `@Mapping("SUM(contacts.engagementScore)")` on an `@EntityView`.
**Nuance found while building the worked example, and since fixed: `@DtoRef` originally didn't fit the
grouping key.** `@DtoRef` was originally excluded from the generated `select()`/`fetch()` spec entirely, on
the premise that the id is already available off an unfetched reference for an ordinary entity graph. That
premise doesn't hold for an aggregation query: the `@ManyToOne` *is* the property being grouped by, so if
it's never selected, the query has nothing to group by. It turned out the premise didn't fully hold for
ordinary entity graphs either - see the `@DtoRef` bug writeup above. Fixed so `@DtoRef` now adds the
association's own name to the root `select(...)` (reading the FK column directly, no join) - which both
supplies the grouping key here and fixes the general-case gap.
**Worked example** (`tests/test-dto-mapping`): `ContactStats` is `@Entity @View(name = "contact")` (no new
DDL - reads the same table as `Contact`/`ContactSummary`) with `@Aggregation("count(id)") contactCount` and
`@Sum Integer engagementScore` (a new nullable field added to `Contact` purely to have something to sum),
grouped by its `@ManyToOne customer`. `ContactStatsDto` is a flat 3-field DTO (`customerId`, `contactCount`,
`engagementScore`), with `customerId` mapped via plain `@DtoRef`. The generated `ContactStatsDtoMapper`:
```java
this.fetchGroup = FetchGroup.of(ContactStats.class)
.select("customer,contactCount,engagementScore")
.build();
...
// skip DtoMapContext, only ever a top-level mapping
return new ContactStatsDto(
(source.getCustomer() == null ? null : source.getCustomer().getId()),
source.getContactCount(),
source.getEngagementScore());
```
confirmed (via `LoggedSql`) to produce `select t0.customer_id, count(t0.id), sum(t0.engagement_score) from
contact t0 ... group by t0.customer_id` - **no join**, one row per customer, correctly summed and counted.
See `TestContactStatsDtoMapping`.
### Formula2-on-DTO scope (v1): existing entity formulas only
`@Formula2` on a DTO property in v1 only pulls in a formula **already declared on the source entity** (or
a reachable associated entity) — it does not support fully ad-hoc SQL declared directly on the DTO with no
matching entity property. Fully ad-hoc SQL-on-DTO (closer to Blaze's arbitrary `@Mapping` expressions) is a
separate, larger stretch goal to revisit once the core graph-mapping mechanism is proven.
**Attempted and rejected for v1.** A narrower version was implemented (`@Formula2(value)` resolved exactly
like `@DtoPath` - a dot-path getter chain - plus a codegen-time validation that the resolved entity property
is itself `@Formula2`/`@Formula`-annotated) but was rejected: for the common case (a DTO field with the same
name as the entity's formula property) it generated **identical code to a plain unannotated field** - the
only difference was the validation, which wasn't judged enough distinct value to justify a new annotation
surface. Not implemented. The only way `@Formula2`-on-DTO would add real value is the full ad-hoc-SQL
capability described above, which remains an open stretch goal.
### Mapper implementation strategy: codegen, not reflection (native-image constraint)
Native-image support is a core Ebean requirement, so the entity-graph -> DTO-graph mapper must not rely on
runtime reflection or `MethodHandles`. This ruled out an initial reflection-based spike:
- Ebean's existing flat `DtoQuery` (`DtoMetaConstructor`) already uses `MethodHandles` via
`Lookups.getLookup()`, but there is no `reflect-config.json` / native-image reachability metadata shipped
for it anywhere in the repo. That existing approach is not a clean precedent to copy for a bigger,
native-image-first feature.
- Instead, the approach mirrors `querybean-generator`, which already generates real `.java` source for
`Q*` query bean types (not reflection) — consistent with the wider avaje-ecosystem convention
(avaje-inject / avaje-jsonb are explicitly reflection-free via compile-time codegen).
**Implementation sequencing:** hand-write the mapper in the exact shape the annotation processor will
eventually generate (plain Java, direct getter/constructor/setter calls, zero reflection) for one concrete
example first, to validate the mapping algorithm and API shape quickly without ever introducing throwaway
reflective code. That hand-written mapper then becomes the target/acceptance-test shape for the
`querybean-generator` annotation processor that automates producing it.
### Codegen target: Java first
The mapper generation (requirement r2) targets `querybean-generator` (the existing APT module that already
generates `Q*` query beans, reusing its `PropertyMeta` / `ProcessingContext` machinery). Kotlin parity via
`kotlin-querybean-generator` is deferred to a later phase — not blocking initial delivery.
### Mapper composition: one mapper per entity/DTO pair, generic `DtoMapper<SOURCE, TARGET>` interface
Rather than one large mapper inlining every nested DTO type, each entity/DTO pair gets its own small
mapper class - mirroring MapStruct's per-type mapper generation. All mappers implement a shared generic
interface (prototyped as `org.tests.dtomapping.DtoMapper<SOURCE, TARGET>` in the spike, expected to move to
`io.ebean` as a public type once solidified):
```java
public interface DtoMapper<SOURCE, TARGET> {
TARGET map(SOURCE source);
default List<TARGET> mapList(List<SOURCE> source) { ... }
}
```
A parent mapper composes nested mappers via **constructor injection**, not a static singleton:
```java
public final class CustomerDtoMapper implements DtoMapper<Customer, CustomerDto> {
private final DtoMapper<Address, AddressDto> addressMapper;
public CustomerDtoMapper() {
this(new AddressDtoMapper());
}
public CustomerDtoMapper(DtoMapper<Address, AddressDto> addressMapper) {
this.addressMapper = addressMapper;
}
@Override
public CustomerDto map(Customer source) {
if (source == null) return null;
return new CustomerDto(source.getId(), source.getName(), addressMapper.map(source.getBillingAddress()));
}
}
```
Rationale:
- **Composability & reuse** - the same nested DTO type (e.g. `AddressDto`) used from multiple parent DTOs
reuses one generated mapper class rather than duplicating inline mapping logic.
- **Constructor injection over static state** - avoids a global mutable singleton; a no-arg constructor
gives the common case (default nested mapper), while an overload accepting the nested mapper explicitly
allows substitution (tests, customization) without touching global state.
- **Codegen-friendly** - this shape generates naturally: one top-level mapper class per DTO type, each
constructor-injecting the mappers for any nested DTO types it references.
## ToMany collections and identity de-duplication (dto-spike-tomany-identity)
Extending the spike (`ebean-test/src/test/java/org/tests/dtomapping/`) to a `Customer` with a
`List<Contact> contacts` ToMany, where each `Contact` has a `customer` back-reference, surfaced
two things worth recording.
### The `DtoMapper` interface threads a shared context
`DtoMapper<SOURCE, TARGET>` was extended so that mapping is always done against a `DtoMapContext`:
```java
public interface DtoMapper<SOURCE, TARGET> {
TARGET map(SOURCE source, DtoMapContext context);
default TARGET map(SOURCE source) {
return map(source, new DtoMapContext());
}
default List<TARGET> mapList(List<SOURCE> source, DtoMapContext context) { ... }
default List<TARGET> mapList(List<SOURCE> source) {
return mapList(source, new DtoMapContext());
}
}
```
`DtoMapContext` is an identity-keyed cache of already-mapped source -> target instances, created
once per top-level `mapList(...)`/`map(...)` call and threaded through every nested `map(...)`
call. This lets repeated references to the *same* source entity instance - which Ebean's own
persistence context already de-duplicates within one query (`contact.getCustomer() == customer`
for the enclosing `Customer`, confirmed by an existing test) - map to the *same* target DTO
instance, rather than each producing an equal-but-distinct copy. This is what makes the mapped
DTO output "graph shaped" rather than "tree of copies shaped", and is required for r1/r3.
### Bug found and fixed: the cache must be partitioned by target type, not just source identity
The first cut of `DtoMapContext` was a single `IdentityHashMap<Object, Object>` keyed only by the
source instance. This breaks as soon as the *same* source instance legitimately needs to map to
*two different target types* within one graph - which happens immediately with a back-reference:
- The top-level `CustomerDtoMapper` maps a `Customer` -> full `CustomerDto`.
- The nested `ContactDtoMapper`, mapping `contact.getCustomer()` (the *same* `Customer` instance,
by identity), maps it -> shallow `CustomerRefDto` (the `@DtoRef`-style escape hatch that avoids
the `Customer -> Contact -> Customer` cycle).
With a single un-partitioned identity map, whichever mapper runs first "wins" the cache slot for
that `Customer` instance, and the other mapper incorrectly receives the wrong-typed cached result
(a `ClassCastException` at best, silently wrong data at worst). This was caught by a failing test
during the spike and fixed by partitioning the cache per target type:
```java
public final class DtoMapContext {
private final Map<Class<?>, Map<Object, Object>> mappedByType = new HashMap<>();
public <S, T> T computeIfAbsent(Class<T> targetType, S source, Function<S, T> mappingFunction) {
Map<Object, Object> mapped = mappedByType.computeIfAbsent(targetType, t -> new IdentityHashMap<>());
// ... existing/create/put ...
}
}
```
Each generated mapper passes its own target DTO `Class` as the first argument, so `Customer ->
CustomerDto` and `Customer -> CustomerRefDto` are cached independently even though the key
(`Customer` instance) is identical. **This is an implementation detail the codegen must get
right** - worth flagging explicitly when `dto-codegen-mapper` starts, since it's easy to
regress if the generator is written from scratch without this test coverage in front of it.
### Codegen optimization: skip the `DtoMapContext` cache for types that are never nested elsewhere
`DtoMapContext.computeIfAbsent` only ever produces a cache *hit* when the exact same source
instance is presented to `map()` more than once within one top-level call - which can only happen
when the target type is reachable via more than one path in the graph, i.e. it's used as a
`NESTED_ONE`/`NESTED_MANY` property by some *other* `@DtoMapping` pair (e.g. `CustomerRefDto`
reached from many `Contact`s via a shared `Customer`, or `AddressDto` shared as `billingAddress`
across customers). A type that's only ever a top-level `mapTo(...)`/`mapList(...)` entry point can
never receive the same source instance twice within one call - Ebean's own query engine already
de-duplicates root entity instances - so the cache lookup/insert there is pure overhead with a
guaranteed-never-hit `IdentityHashMap`.
Since all `@DtoMapping` pairs are resolved together at codegen time (`DtoMappingReader.
resolveAndValidate()`), it's straightforward to compute this: after cycle exclusion, walk every
surviving `DtoBeanMeta`'s properties and mark any `nested()` target as `nestedElsewhere()`. The
generated `map()` method then branches per mapper:
```java
// CustomerDto - never nested by another mapper, only a mapTo()/mapList() entry point
public CustomerDto map(Customer source, DtoMapContext context) {
if (source == null) return null;
// DtoMapContext for nested mappers only
return new CustomerDto(source.getId(), source.getName(),
billingAddressMapper.map(source.getBillingAddress(), context),
contactsMapper.mapList(source.getContacts(), context));
}
// AddressDto - nested under CustomerDto.billingAddress, so may be shared across customers
public AddressDto map(Address source, DtoMapContext context) {
if (source == null) return null;
// dedup using DtoMapContext, same Address instance can be reached via more than one path in the graph
return context.computeIfAbsent(AddressDto.class, source, s -> new AddressDto(
s.getId(), s.getLine1(), s.getCity()));
}
// ContactSummaryDto - flat, top-level only, no nested children at all
public ContactSummaryDto map(ContactSummary source, DtoMapContext context) {
if (source == null) return null;
// skip DtoMapContext, only ever a top-level mapping
return new ContactSummaryDto(source.getId(), source.getFullName());
}
```
Deliberately terse, single-line comments - just enough for a developer skimming generated code (e.g.
per the earlier `@Formula2`-on-DTO worked example) to know at a glance *why* a given mapper does or
doesn't use the cache, without spelling out the full reachability argument inline every time (that
lives here in the design doc instead). Note `CustomerDto`'s own construction skips the cache even
though it *has* nested children - `context` is still threaded down to `billingAddressMapper`/
`contactsMapper` since those target types (`AddressDto`, `ContactDto`) *are* nested elsewhere and
still need the identity cache for themselves - hence the distinct "for nested mappers only" wording
from the "only ever a top-level mapping" case (`ContactSummaryDto`), which has no children to thread
a context to at all.
### Fetching a ToOne back-reference used only for its FK/id needs the FK property fetched too
Confirmed (via a first-cut test failure) that if a ToMany's element type has a ToOne back to its
parent (e.g. `Contact.customer`), that FK property must itself be included in the fetch
(`.fetch("contacts", "id,firstName,lastName,customer")`) even when the mapper only reads the id
off the reference. Omitting it throws `LazyInitialisationException: Property not loaded:
customer` on the `getCustomer()` call itself (not merely on a property access on the returned
reference) - i.e. the earlier "ToOne reference access alone doesn't lazy load" finding
(dto-validate-fetch-pagination) only holds once the ToOne/FK property is itself part of the
fetch/select spec. This reinforces r6 (auto-deriving the fetch spec from DTO shape): the codegen
must include a ToOne property in the fetch spec whenever a DTO needing it (even just its id) is
reachable through a ToMany, not just at the top level.
### Test coverage added
- `TestCustomerDtoGraphMapping` extended to cover `contacts` ToMany mapping and to assert that
sibling `ContactDto`s under the same customer share the identical `CustomerRefDto` instance.
- `TestContactDtoGraphMapping` (new) - standalone `ContactDtoMapper` test focused specifically on
the identity de-dup guarantee and null-source handling.
## Codegen foundation: avaje-prisms adopted in querybean-generator (dto-codegen-mapper, step 1)
Before writing the DTO-mapper annotation-processing logic itself, adopted `avaje-prisms`
(`io.avaje:avaje-prisms`) in `querybean-generator` as the mechanism for reading the new
`@DtoPath`/`@DtoRef` annotations at APT time, replacing what would otherwise be more hand-rolled
`AnnotationMirror` walking (the existing pattern in `FindDbName.java`/`ReadModuleInfo.java`,
left as-is/unmigrated - only the *new* annotations use prisms).
This mirrors the proven pattern already used in two sibling projects in the same ecosystem -
`avaje-inject`'s `inject-generator` and `avaje-jsonb`'s `jsonb-generator` - both declare
`@GeneratePrism(SomeAnnotation.class)` once and get a generated `SomeAnnotationPrism` with
`isPresent(element)` / `getInstanceOn(element)` / `getOptionalOn(element)` and typed accessors
for every annotation member (correctly handling `Class`-valued members, avoiding the classic
`MirroredTypeException` dance).
Key property preserved: `querybean-generator` has **zero runtime/compile dependencies today**
(confirmed via `mvn dependency:list` returning "none"), matching annotations by FQN string
constants (`Constants.java`) rather than importing the actual annotation classes - deliberately
keeping the processor free of any dependency footprint for consumers. Adding `avaje-prisms` (to
generate the prism wrapper) and `ebean-annotation` (to reference `@DtoPath`/`@DtoRef` as literal
`Class` values in `@GeneratePrism(...)`) as `optional` dependencies preserves this: `mvn
dependency:list -DincludeScope=runtime` confirms every one of these (plus their own transitive
deps: `avaje-prism-core`, `avaje-spi-service`, `avaje-spi-core`) is marked `(optional)`, so none
of it propagates to a project that depends on `querybean-generator` (whether as a normal
dependency or via `annotationProcessorPaths`).
New annotations were added to the separate `ebean-annotation` repo (`io.ebean.annotation`
package, alongside `@Formula2`), not this repo:
```java
@DtoPath("billingAddress.line1")
String billingLine1; // rename/flatten a DTO property from a nested source path
@DtoRef
Integer customerId; // id-only back-reference, breaks what would otherwise be a graph cycle
```
Both use `@Target({FIELD, METHOD})` and `RetentionPolicy.CLASS` - visible to the annotation
processor (including across module boundaries, since `CLASS` retention survives in the compiled
`.class` file) but absent from runtime reflection, consistent with DTOs remaining plain,
framework-free types with no runtime footprint.
Wiring changes in `querybean-generator`:
- `pom.xml`: added `avaje-prisms` (`optional`, plus `annotationProcessorPaths` entry) and
`ebean-annotation` (`optional`) dependencies; removed the previous `-proc:none` compiler arg
(which would have suppressed `avaje-prisms`' own processor from running to generate the prism
source) - annotation processing is now scoped to exactly `avaje-prisms` via the explicit
`annotationProcessorPaths` list, so no other processor is auto-discovered.
- `module-info.java`: added `requires static io.avaje.prism;` and `requires static
io.ebean.annotation;` (`static` = compile-time only, matching the `optional` Maven scope).
- New `package-info.java` declaring `@GeneratePrism(DtoPath.class)` and
`@GeneratePrism(DtoRef.class)`, generating `DtoPathPrism`/`DtoRefPrism` into
`target/generated-sources/annotations`.
Verified: full `querybean-generator` build + existing test suite pass unchanged, and a downstream
full rebuild (`ebean-test` with `-am`) - which exercises the existing Q-bean codegen - also
passes with no regressions.
### Trigger mechanism: `@DtoMapping(source, target)` on a neutral package-info.java
Considered and rejected: putting a `source`/entity-referencing annotation directly on the DTO
class itself (e.g. `@Dto(Customer.class)` on `CustomerDto`). Rejected because DTO types are
often owned/generated elsewhere (e.g. from an OpenAPI spec) and must not be forced to reference
an internal persistence/entity type - that would leak internal domain types into a
public-facing/generated DTO module.
Instead, adopted the same pattern `avaje-jsonb` uses for external/foreign types it doesn't own
(`@Json.Import`): a repeatable annotation declared on a *neutral* holder - a `package-info.java`
- naming the `source` entity and `target` DTO as a pair:
```java
@DtoMapping(source = Customer.class, target = CustomerDto.class)
@DtoMapping(source = Contact.class, target = ContactDto.class)
package org.example.dto;
```
`@DtoMapping` (new, in `ebean-annotation`) is `@Target({PACKAGE, MODULE})`,
`@Retention(SOURCE)` (pure codegen trigger, never needed at runtime - unlike `@DtoPath`/
`@DtoRef` which need `CLASS` retention to remain visible to the DTO field itself),
`@Repeatable(DtoMapping.List.class)` following Java's own repeatable-annotation idiom. Neither
the entity nor the DTO needs any annotation of its own.
**Generated mapper package placement** - also modeled directly on `avaje-jsonb`'s handling of
`@Json.Import` for external types (`AdapterName`/`ProcessingContext.isImported`): defaults to the
target DTO's own package, *unless* the source or target type belongs to a different Java module
than the one being processed, in which case the generated mapper is placed in a package derived
from the processing module's own name instead - avoiding a JPMS "split package" violation that
would occur from generating source into a package owned by another module. An explicit
`mapperPackage` attribute is available to override this for edge cases. Same-module (or
non-modular/unnamed-module) projects are unaffected and just get the mapper alongside the DTO.
### mapTo(Class) dispatch: Class-token API + generated compile-time-safe registry
The original API sketch above (`mapTo(CustomerDto.class)`) predates the native-image/no-reflection
decision. Rather than switching to an instance-based API (`mapTo(new CustomerDtoMapper())`),
decided to keep the `Class`-token shape and generate a compile-time-safe registry to resolve it -
no reflection, no `Class.forName`, just literal `Class` comparisons generated at build time, e.g.:
```java
<S, D> DtoMapper<S, D> mapperFor(Class<S> sourceType, Class<D> targetType) {
if (sourceType == Customer.class && targetType == CustomerDto.class) {
return (DtoMapper<S, D>) new CustomerDtoMapper();
}
if (sourceType == Contact.class && targetType == ContactDto.class) {
return (DtoMapper<S, D>) new ContactDtoMapper();
}
return null;
}
```
Dispatch is keyed on the **(source, target) pair**, not target alone - this matches how
`@DtoMapping(source, target)` pairs are declared, allows the same DTO type to be mapped from more
than one source entity without ambiguity, and lets `query.mapTo(dtoType)` fail fast with a clear
`PersistenceException` (rather than an incorrect match) when `query.getBeanType()` doesn't pair
with the requested DTO.
This mirrors the existing, already-proven `EbeanEntityRegister`/`EntityClassRegister` mechanism
(`SimpleModuleInfoWriter.java`) that `querybean-generator` already generates per module for entity
classes - a `List<Class<?>>` built from literal `SomeEntity.class` references, registered via
`META-INF/services` (`ServiceLoader`, itself native-image-friendly with no extra reflection
config needed for simple no-arg-constructor implementations). The DTO mapper registry follows the
same per-module aggregation + `META-INF/services` registration shape, giving `mapTo(Class)` a
concrete generated implementation to dispatch through at runtime without reflection anywhere in
the chain.
### mapTo(Dto.class) runtime wiring (implemented)
`query.mapTo(dtoType)` returns a `MappedQuery<D>` (`findList()`/`findOne()`/`findOneOrEmpty()`/
`findStream()`/`findPagedList()`/`usingMaster(boolean)`/`usingTransaction(Transaction)`/`usingConnection(Connection)`).
On first use it resolves the generated `DtoMapper<S, D>` for the query's `(getBeanType(), dtoType)`
pair via a `DtoMapperManager` (a `ServiceLoader`-backed aggregator over all generated
`DtoMapperRegister`s, analogous to `DtoBeanManager`), then:
- applies `mapper.fetchGroup()` to the query via `query.select(fetchGroup)` - the fetch/select spec
is entirely derived from the DTO's declared shape, no manual `.select()`/`.fetch()` needed;
- forces `query.setUnmodifiable(true)` - the resulting entity graph is read-only input to the
mapper, and any DTO property whose source wasn't actually fetched fails fast with
`LazyInitialisationException` rather than silently lazy loading or returning `null`;
- executes the query and maps the result(s) via `mapper.map(...)`/`mapper.mapList(...)`.
An unregistered `(source, dtoType)` pair throws a `PersistenceException` with a suggested
`@DtoMapping` fix, at first use (i.e. `findList()`/`findOne()`), not at `mapTo(dtoType)` call time.
`MappedQuery<D>.usingMaster(boolean)`, `.usingTransaction(Transaction)`, and `.usingConnection(Connection)`
all delegate directly to the underlying entity query, mirroring `Query`/`QueryBuilder`. This lets a
caller retry against the master data source after a read-replica failure by calling
`usingMaster(true)` on the *same* `MappedQuery` instance and re-invoking a find method - there's no
need to rebuild the query and call `.mapTo(...)` again.
`MappedQuery<D>.findStream()` mirrors `QueryBuilder#findStream()` - the underlying entity query is
streamed (supporting very large result sets, potentially using multiple persistence contexts
internally) and each entity is mapped to its target DTO lazily as the stream is consumed. One
`DtoMapContext` is shared across the whole stream (not per-element), so identity de-duplication of
nested DTOs (e.g. several `Contact`s sharing the same `Customer`) still holds even when the source
entities are never materialized into one `List` at all. As with the entity-level `findStream()`,
callers must consume it via try-with-resources to ensure the underlying resources are closed.
## Still open / to revisit during implementation
- Whether `.fetch(...)` calls can still be layered on top of a `mapTo(Dto.class)` query for explicit
overrides. Currently the mapper's `fetchGroup()` is the *only* source of the fetch spec - any
`.select()`/`.fetch()` calls made before `.mapTo(...)` are overwritten by it.
- Whether `@DtoPath`/`@DtoRef` need additional attributes beyond a bare path/marker (e.g. an explicit
target type on `@DtoRef` for disambiguation) once real DTOs with more complex shapes are codegen'd.
- Behavior when a DTO property has no matching entity property and no `@DtoPath`/`@Formula2` override
(fail at codegen time, most likely, consistent with the "fail fast" philosophy).
- `@Formula2`-on-DTO mapping to Blaze-Persistence/QueryDSL-style computed properties - not yet
implemented (see requirements doc); a narrower validation-only variant was attempted and rejected
as not distinct enough from `@DtoPath` (see "Formula2-on-DTO scope" above). The broader ad-hoc-SQL
case likely doesn't need a dedicated DTO feature at all - see "Ad-hoc computed/formula properties"
above for the `@Entity @View`/`@Sql` alternative.
- **Fetch-path collision between a `NESTED_ONE`/`NESTED_MANY` property and a `@DtoPath` property -
found and fixed**: `DtoMapperWriter.fetchGroupChainCalls()` builds one `.fetch(path, ...)`
chain-call per distinct fetch path, but the underlying `OrmQueryDetail.fetch(...)` unconditionally
**overwrites** (rather than merges) any existing entry for the same path key. If a DTO declared a
`NESTED_ONE`/`NESTED_MANY` property AND a `@DtoPath` property whose fetch-path prefix is the *exact
same* path (e.g. a nested `AddressDto billingAddress` alongside `@DtoPath("billingAddress.line1")`
on the same DTO - both resolve to fetch path `"billingAddress"`), the generator would emit two
`.fetch("billingAddress", ...)` calls and the second would silently discard the first's selected
properties. Merging wasn't practical - the nested property's `.fetch(path, mapper.fetchGroup())`
call passes another mapper's own pre-built, immutable, shared `FetchGroup`, so there's no clean way
to splice an extra scalar property into it at the call site. Fixed instead with a **fail-fast
compile-time error**: `DtoMapperWriter` now detects the collision and raises a clear
`ctx.logError(...)` (annotation-processor `ERROR` diagnostic, fails the compile) naming the
colliding property and fetch path, and suggesting the two ways out - move the property onto the
nested DTO type instead, or pick a `@DtoPath` that reaches a different, non-colliding path (as
`ContactDto.customerCity` already does deliberately, per its own comment, using a 3-segment path).
Verified empirically by compiling a small reproduction with a colliding `@DtoPath` and confirming
the expected error fires; a permanent regression test
(`DtoMapperFetchPathCollisionTest` in `querybean-generator`) now runs this same repro directly
through `javax.tools.JavaCompiler` with the `Processor` registered, asserting the compile fails with
the expected diagnostic message.
- **Compile-time verification of `select(...).asDto(...)` (r6, aspirational) - explored and closed as
rejected**: raw SQL is an opaque `String` at compile time, and even the typed query-bean
`.select(...)` form only type-checks against the *entity* - the match to the target DTO's constructor
still happens at runtime via reflection (`DtoQueryPlanConstructor`), and the `.asDto(...)` call site
can be arbitrarily distant from the `.select(...)` call, so there's no fixed AST shape an annotation
processor could reliably verify (unlike QueryDSL, whose compile-time safety actually comes from typed
`Projections.constructor(...)`/generated Q-type constructor calls, not from checking a select-list
against a DTO). `mapTo(Dto.class)` already closes the underlying gap in the tractable direction - it
derives the select/fetch spec *from* the DTO's declared shape at APT time, so it is compile-time safe
by construction. Recommend `mapTo()` whenever compile-time-checked DTO projection matters, and treat
`asDto()`/`findDto()` as the flexible, runtime-checked escape hatch for raw/dynamic SQL. See
`dto-mapping-requirements.md` requirement r6.
- **Custom property conversion (`@DtoConvert`/`@DtoMixin`, r13/r14) - implemented**: motivated by a
real hand-written mapper (`DriverMapper`, central-access) needing both a dependency-free scalar
coercion (`short` -> `boolean`) and a dependency-backed conversion (AES decryption via an injected
cipher). Final design (see `dto-mapping-requirements.md` section E), as built:
- `@DtoConvert(value = ConverterType.class, method = "name")` on a DTO property (combinable with
`@DtoPath`); the generator dispatches on whether the referenced method is `static` - static means a
direct inlined static call (no registration, covers common reusable coercions), instance means
dispatch via a new `DtoConverterManager.get(ConverterType.class).method(...)` call, with the
resolved instance wired as a real constructor parameter/field on the generated mapper (same shape
as existing nested-mapper constructor injection). Multiple properties on the same mapper sharing
the same converter type are deduplicated to a single constructor parameter/field
(`DtoBeanMeta.converterDeps()`).
- `DtoConverterManager` (`ebean-api`, `io.ebean` package) is a small, narrowly-scoped static put/get
bridge - the app registers an already-DI-constructed converter singleton (e.g. built by
avaje-inject) *before* building the `Database`. This is a deliberate, narrow exception to the
general no-static-mutable-state convention: `ServiceLoader`-discovered, no-arg-constructed
generated code (`EbeanDtoMapperRegister`) has no other way to reach an already-DI-constructed
singleton. `DtoConverterManager.get(type)` throws immediately if nothing was registered for that
type, so a missing converter fails fast at Database-startup time (an eager field initializer on
`EbeanDtoMapperRegister`, and equally on each mapper's own no-arg constructor, which resolves the
same way via `DtoConverterManager.get(...)` for standalone/test construction), not lazily on first
use - `DtoMapperRegister`'s `mapperFor(...)` signature and `DtoMapperManager` are otherwise
completely unchanged, as originally planned.
- Two alternatives were explored and rejected first: (a) a `DtoMapContext.service(Class)` lookup -
wrong lifetime, `DtoMapContext` is a short-lived per-call identity-cache only; (b) a
`ServiceLoader`-discovered `DtoConverterSource` SPI mirroring `DtoMapperRegister` itself - can't
bridge to an *already* DI-constructed dependency without reconstructing/duplicating it.
- `@DtoMixin(Target.class)` - a companion type overlaying `@DtoPath`/`@DtoConvert`/`@DtoRef`
annotations onto a DTO that can't be annotated directly (e.g. OpenAPI-generated). Discovered via
`roundEnv.getElementsAnnotatedWith(...)` (added to `Processor.getSupportedAnnotationTypes()`,
since - unlike `@DtoPath`/`@DtoRef`/`@DtoConvert` - a mixin doesn't annotate an already-iterated
field of a known `@DtoMapping` target, so it can't be found lazily). `DtoMappingReader` resolves
each target property's annotations from the field itself first, falling back to a same-named
method on the registered mixin (`DtoMappingReader.prismOn(...)`) - directly mirrors avaje-jsonb's
proven `@Json.MixIn` mechanism.
- Implemented in `ebean-annotation` (`DtoConvert`, `DtoMixin`), `ebean-api` (`DtoConverterManager`),
and `querybean-generator` (`DtoConverterMeta`, `DtoBeanMeta.converterDeps()`,
`DtoMappingReader`/`DtoMapperWriter`/`DtoMapperRegisterWriter` changes). Test coverage:
`tests/test-dto-mapping` `TestDtoConvert` (static + instance dispatch, fail-fast unregistered-type
check) and `TestDtoMixin` (mixin overlay, including instance-dispatch conversion resolved purely
from mixin-declared annotations). The instance-dispatch converter is registered via a
`DatabaseConfigProvider` (ServiceLoader hook run before the `Database` is built) rather than a test
`@BeforeAll`, since `EbeanDtoMapperRegister`'s mapper fields (including any needing
`DtoConverterManager`) are all constructed eagerly during `Database` startup, which can be
triggered by whichever test class in the module happens to run first.
## References
- Requirements: [dto-mapping-requirements.md](./dto-mapping-requirements.md)
- Issue: https://github.com/ebean-orm/ebean/issues/2540
- MapStruct cycle mapping: https://mapstruct.org/documentation/stable/reference/html/#mapping-object-cycles
+270
View File
@@ -0,0 +1,270 @@
# Nested DTO Mapping — Requirements
Design requirements distilled from [issue #2540 "Support nested DTO mapping"](https://github.com/ebean-orm/ebean/issues/2540),
reviewed against comparable features in QueryDSL (`@QueryProjection`) and Blaze-Persistence (`@EntityView`).
## Context
Ebean already supports:
- Partial/flat DTO queries via `DB.findDto(...)` and `query.select(...).asDto(Dto.class)`.
- `@Formula` / `@Formula2` — path-based, auto-joined computed SQL expressions, but only on managed entities.
- `query.setUnmodifiable(true)` — builds a read-only, non-lazy-loading entity graph (`InterceptReadOnly`,
see PR #2626). Accessing an unloaded property throws `LazyInitializationException`; mutating throws
`UnmodifiableEntityException`.
Unlike Hibernate, Ebean does dirty-detection on the bean itself (no dynamic proxies), so there is very little
extra cost to an entity-graph query versus a DTO query. This makes an **unmodifiable entity graph** a cheap,
natural intermediate representation to map *from* when producing a DTO graph — we don't need Blaze/Hibernate's
proxy-based `EntityView` mechanism to get the performance benefit they are chasing.
The goal is nested DTO graph support (DTOs containing ToOne/ToMany child DTOs), not just today's flat DTOs,
while keeping DTOs as plain, framework-unattached classes.
## Accepted Requirements
### A. Nested DTO graphs
- **Support nested DTO graphs (ToOne/ToMany)**
Allow mapping a query result into a DTO graph where DTO fields are themselves DTOs (ToOne) or
`List`/`Set<Dto>` (ToMany), not just flat DTOs. Use the existing `setUnmodifiable(true)` entity graph as
the intermediate, de-duplicated, identity-consistent source to map from.
*Inspiration: Blaze `@EntityView` subviews/subview collections; Jimmer fetcher DTOs.*
- **Auto-generated entity → DTO graph mapper**
Given an unmodifiable entity graph plus a target nested DTO type, generate (via annotation processing,
reflection-free) a mapper that walks the graph and populates the DTO graph, matching properties by
name/type with override annotations for renames, computed values, and collection element types.
*Inspiration: Blaze `@EntityView` + subview mapping; conceptually similar to MapStruct but Ebean-generated
and graph/identity aware.*
- **Identity-aware de-duplication in nested collections**
When mapping nested collections referencing the same underlying entity instance multiple times, reuse the
same DTO instance (mirrors Blaze/Jimmer identity semantics) rather than producing independent copies.
*Inspiration: Blaze/Jimmer identity handling.*
### B. Formula-style DTO annotations
- **`@Formula2`-like annotations on DTO fields**
Bring the existing `@Formula` / `@Formula2` concept (auto-joined, path-based computed SQL expressions) to
DTO classes so a DTO field can request a computed/aggregated value with the join auto-derived, instead of
only being available on managed entities.
*Inspiration: User suggestion; Ebean `@Formula2`; Blaze `@Mapping` computed expressions.*
*Status: a narrower version (pulling in an existing entity-level `@Formula2` by path) was implemented and
then rejected - for the common same-name case it generated code identical to a plain unannotated field, so
the annotation added no real value beyond a codegen-time validation. See `docs/dto-mapping-design.md`
("Formula2-on-DTO scope" and "Ad-hoc computed/formula properties" sections). The broader goal - arbitrary
ad-hoc computed SQL on a DTO field - is better served by modelling the computed value as its own
`@Entity @View`/`@Sql` read entity and mapping *that* into a plain DTO, reusing the existing (already
accepted) nested-DTO mapping machinery rather than a new DTO-level annotation.
- **Path-based property mapping annotation on DTO**
Allow a DTO field or constructor param to be annotated with a source path expression (e.g. `parent.name`)
so Ebean can auto-derive the select clause plus joins for nested/renamed properties, reducing manual
constructor wiring for non-trivial mappings.
*Inspiration: Blaze `@Mapping`; QueryDSL constructor expressions.*
### C. Compile-time safety
- **Compile-time verification of `select(...).asDto(...)` mapping** *(explored, rejected as impractical -
`mapTo()` accepted as the alternative)*
Today `select(props).asDto(Dto.class)` is only checked at runtime. Explored an annotation-processor
based mechanism to verify at compile time that selected properties match the DTO constructor or setters,
mirroring QueryDSL's `@QueryProjection` compile-time Q-type generation. Rejected as impractical: raw SQL
is an opaque `String` at compile time, and even the typed query-bean `.select(...)` form only
type-checks against the *entity* - the match to the target DTO still happens at runtime via reflection
(`DtoQueryPlanConstructor`), and the `.asDto(...)` call site can be arbitrarily distant from the
`.select(...)` call, so there's no fixed AST shape an annotation processor could reliably verify.
QueryDSL's actual compile-time safety comes from a different mechanism entirely - typed
`Projections.constructor(...)`/generated Q-type constructor calls, not from checking an
independently-built select-list against a DTO. `mapTo(Dto.class)` (see section A) already closes the
underlying gap in the opposite, tractable direction: it derives the select/fetch spec *from* the DTO's
declared shape at APT time, so it is compile-time safe by construction, with no separate select-list to
drift out of sync. Recommendation: document `mapTo()` as the compile-time-safe answer for DTO
projections, and treat `asDto()`/`findDto()` explicitly as the flexible, runtime-checked escape hatch
for raw/dynamic SQL.
*Inspiration: QueryDSL `@QueryProjection` compile-time Q-type generation.*
- **Fail-fast on unmapped or lazy property access**
Ensure a clear, documented, minimal-ceremony way to fail fast if code touches a property not included in
the query projection, instead of silently lazy loading or returning null. `query.setUnmodifiable(true)`
already satisfies this (throws `LazyInitializationException`) — document/promote it as the answer, and
evaluate whether a lighter-weight flag decoupled from full unmodifiable/read-only semantics is needed.
*Inspiration: Original issue ask; already solved via `setUnmodifiable()` (PR #2626 `InterceptReadOnly`).*
### D. Fetch strategy and performance
- **Fetch strategy control for DTO graph relationships**
Existing entity query fetch hints (join vs. select/subselect secondary query, `+query`/`+lazy`) should
transparently carry over when the target of the query is a DTO graph rather than an entity graph.
`query.mapTo(Dto.class)` applies the DTO-derived `FetchGroup` only when the query has no
`select()`/`fetch()` already set - a manually tuned fetch spec always takes precedence and is never
overridden, allowing manual query optimisation when needed (at the cost of falling back to the
existing fail-fast-on-unmapped-property behaviour if the manual spec doesn't cover what the DTO needs).
*Inspiration: Blaze FETCH/SELECT/SUBSELECT fetch strategies.*
- **Pagination support for DTO graph queries**
Confirm existing pagination works unchanged when projecting into nested DTO graphs.
*Inspiration: Blaze pagination and keyset pagination support.*
### E. Custom property conversion
- **Per-property custom scalar conversion (`@DtoConvert`)**
Motivated by real hand-written mapper code (`DriverMapper`, central-access) doing per-property scalar
coercion (`short` -> `boolean`) and dependency-backed conversion (AES decryption via an injected cipher).
Introduce a `@DtoConvert(value = ConverterType.class, method = "name")` annotation (combinable with
`@DtoPath` for source-getter override) on a DTO property. The generator dispatches based on whether the
referenced method is `static`:
- **Static method** -> a direct static call is inlined (`ConverterType.method(source.getX())`), zero
ceremony, no registration - covers common, reusable, dependency-free scalar coercions (e.g.
`short`/`boolean`, enum <-> `String`) that could apply across many unrelated entity/DTO pairs.
- **Instance method** -> dispatched via `DtoConverterManager.get(ConverterType.class).method(source.getX())`
and wired as a real constructor parameter/field on the generated mapper (same shape as existing
nested-mapper constructor injection) - covers conversions needing a real dependency (e.g. a cipher).
`DtoConverterManager` is a small, deliberately-scoped static put/get bridge: the app registers an
already-DI-constructed converter singleton (e.g. built by avaje-inject) *before* building the `Database`.
This is a narrow, accepted exception to the general no-static-mutable-state convention - it exists solely
to bridge an already-DI-constructed singleton into `ServiceLoader`-discovered, no-arg-constructed generated
code, which cannot otherwise reach a DI container. `DtoConverterManager.get(type)` throws immediately if
nothing was registered, so a missing converter fails fast at Database-startup time (an eager field
initializer on the generated `EbeanDtoMapperRegister`), not lazily on first use.
*Design exploration considered and rejected two alternatives first: (a) a `DtoMapContext.service(Class)`
lookup - rejected because `DtoMapContext` is a short-lived per-call identity-cache only, wrong lifetime for
a real singleton dependency; (b) a `ServiceLoader`-discovered `DtoConverterSource` SPI mirroring
`DtoMapperRegister` itself - rejected because a `ServiceLoader`-instantiated (no-arg) source cannot bridge
to an *already* DI-constructed dependency (e.g. a cipher needing config/secrets) without reconstructing it
itself, duplicating/bypassing the app's own DI-managed instance.*
*Inspiration: `DriverMapper` (central-access) hand-written pattern; MapStruct qualified converter methods.*
*Status: implemented - `@DtoConvert` (ebean-annotation), `io.ebean.DtoConverterManager` (ebean-api), and
querybean-generator codegen support (static/instance dispatch, constructor wiring deduplicated by converter
type). Test coverage: `tests/test-dto-mapping` `TestDtoConvert`.*
- **`@DtoMixin` for DTOs that cannot be annotated directly**
Some DTOs are generated (e.g. from an OpenAPI spec) and not editable/annotatable, so `@DtoPath`/
`@DtoConvert`/`@DtoRef` cannot always be placed directly on the DTO. Introduce a `@DtoMixin(Target.class)`
companion interface/type, discovered by scanning the compilation round and overlaying its per-property
annotations onto the real target's properties by name-match - directly mirrors avaje-jsonb's
`@Json.MixIn` mechanism (`KingfisherMixin`/`CrewMateMixIn` pattern), a proven prior-art solution to the
exact same "can't annotate a generated/unowned type" problem.
*Inspiration: avaje-jsonb `@Json.MixIn`.*
*Status: implemented - `@DtoMixin` (ebean-annotation) and querybean-generator round-scanning/overlay
support (matches mixin methods to target properties by name, applying whichever of `@DtoPath`/`@DtoRef`/
`@DtoConvert` is present as if declared on the target field itself). Test coverage: `tests/test-dto-mapping`
`TestDtoMixin`.*
### F. DI-friendly manual mapper usage
- **Public `DtoMapperManager` with `get(Class<T> mapperType)` for DI**
Motivated by `DriverMapper`/`DriverService` (central-access): `DriverMapper` is a hand-written
`@Component` constructor-injected into `DriverService`. Moved `DtoMapperManager` from internal
(`io.ebeaninternal.server.dto`) to public `io.ebean` - unchanged `mapperFor(source, dto)`, plus a new
`get(Class<T> mapperType)` keyed by the generated mapper's own concrete class (e.g.
`manager.get(CustomerDtoMapper.class)`), for direct/concrete-typed DI injection. `DtoMapperRegister`
gained a default `mapperOfType(Class<T>)` method (non-breaking); the generator emits the real if-chain
body (mirrors `mapperFor`'s if-chain). `DtoMapperManager` has zero `Database` dependency (constructor
only does `ServiceLoader.load(DtoMapperRegister.class)`), so it can be constructed standalone,
independent of/before a `Database` - e.g. as an avaje-inject bean.
*Inspiration: `DriverMapper`/`DriverService` (central-access).*
*Status: implemented.*
- **`DtoMapperManager` sharing via `DatabaseBuilder.putServiceObject`**
So `query.mapTo()` and application-injected mappers share the exact same `DtoMapperManager` instance
(and hence the same underlying generated mapper singletons) rather than each independently constructing
its own, `InternalConfiguration` checks `config.getServiceObject(DtoMapperManager.class)` first (mirrors
the existing `AutoMigrationRunner`/`GeoTypeProvider` `putServiceObject`/`getServiceObject` pattern),
falling back to constructing a default `new DtoMapperManager()` if none was supplied.
*Inspiration: user proposal following `DriverMapper`/`DriverService` review.*
*Status: implemented.*
*Rejected: generator-emitted `builder(source)` method* - `DriverMapper` exposes `builder(cDriver)`
returning a partially-populated `DriverBuilder` so callers can add extra caller-supplied fields (e.g.
fleets) before `build()`. Rejected as a generator feature - `Driver`/`DriverSummary` already use
avaje-recordbuilder's `@RecordBuilder`, which generates `Target.builder(existingInstance)`
(seed-from-instance). The same effect is already achievable with zero ebean changes:
`mapper.map(source)` then `Builder.builder(mapped).extraField(x).build()`. Documented as a recipe
instead (see "Recipe: adding extra caller-supplied fields after mapping" in
`docs/guides/mapping-entity-graphs-to-dtos.md`).
### G. Large-target construction and shape variants
- **Builder-based target construction for large DTOs**
Motivated by `UserService`/`User` (central-access): `User` is a 24-field OpenAPI-generated record with
a `@RecordBuilder`-generated `UserBuilder`, hand-mapped via a long fluent builder chain rather than a
positional constructor to stay readable/refactor-safe. The generator auto-detects a RecordBuilder-style
builder on the target (static `Target.builder()` + fluent per-property setters + `build()`) and uses
`Target.builder().prop(x)....build()` instead of `new Target(a, b, c, ...)` whenever (a) a builder is
detected and (b) the target has more than a threshold number of properties (default 5), falling back to
the positional constructor otherwise. An explicit `@DtoMapping` attribute (`builder = AUTO | ALWAYS |
NEVER`) overrides the heuristic in either direction. Applies regardless of whether the target class is
hand-authored or foreign/generated (e.g. an OpenAPI record) - `@DtoMapping` is already declared
externally via `package-info.java`, not on the target class, so this was already compatible with
foreign target types.
*Inspiration: `UserService`/`User` (central-access).*
*Status: implemented.*
- **Named mapper variants excluding nested paths, sharing one generated class**
Motivated by `UserService`/`User` (central-access): `CUser` -> `User` is mapped in two shapes - with
nested `fleets` (`findUserByGid`) and without (`findAll`, bulk listing) - to avoid an unnecessary
join/fetch on the common bulk-listing path. Keeps the existing "shape always derived from declaration,
fetch spec always wins" philosophy (rejected relaxing that rule / rejected a runtime
is-property-loaded auto-skip check as less deterministic). The same `(source, target)` pair can be
declared more than once in `package-info.java` via a named variant, e.g.
`@DtoMapping(source = CUser.class, target = User.class)` (base/full) plus
`@DtoMapping(source = CUser.class, target = User.class, name = "noFleets", exclude = "fleets")`
(variant). Both variants are generated into the **same** mapper class (one class per target, not one
per variant) and share a single private `build(source, context, boolean includeXxx, ...)` method
containing the common field population written once; each excluded nested path becomes a `boolean
includeXxx` parameter of that shared method rather than a precomputed value, so `build()` still
evaluates every property - included or excluded - inline, at its own declared field position (a
`includeFleets ? fleetsMapper.mapList(...) : List.of()` ternary in place, not hoisted out as a
pre-evaluated call argument). This preserves the DTO's declared property order as the true evaluation
order regardless of which properties a variant happens to exclude. The base `map()` passes `true` for
every flag; each named variant (exposed as a same-named accessor, e.g. `noFleets()`, returning a single
shared/cached instance of its own small `DtoMapper<SOURCE, TARGET>`-implementing inner class - not
reconstructed per call) passes `false` for the paths it excludes and omits that path from its own
`fetchGroup`. Selected via a new `query.mapTo(Class<D> dtoType, DtoMapper<T, D> mapper)` overload
taking an already-resolved mapper instance directly (e.g. `query.mapTo(User.class,
userMapper.noFleets())`) - no string-based variant lookup, and no changes needed to
`DtoMapperRegister`/`DtoMapperManager`.
*Inspiration: `UserService`/`User` (central-access).*
*Status: implemented.*
### H. Record entity sources
- **Record-style (bare/fluent) accessors on the source (entity) side**
Ebean supports entity beans declared as Java `record`s (e.g. `public record CourseRecordEntity(@Id long id,
String name, String notes) {}` - see `test-java16`), whose only accessor shape is the bare component name
(`active()`, `name()`, `id()`) - never `getXxx()`/`isXxx()`. This bare-accessor convention isn't limited to
an actual `record` type though - an ordinary class can just as easily expose bare/fluent-style accessors
with no `get`/`is` prefix at all. The generator resolves the real accessor for each source type (the direct
source, or an intermediate `@DtoPath`/`@DtoRef` association type) by checking which shape actually exists as
a method, in order: (1) `isXxx()` returning `boolean` (JavaBean boolean convention), (2) `getXxx()` (JavaBean
convention), (3) the bare `propertyName()` itself - falling back to a guessed `getXxx()` only if none of the
three are found. Resolution is entirely name/existence-based (no dependency on whether the type is actually
a `record`). The Ebean bean-property name used in generated `FetchGroup.select(...)`/`.fetch(...)` calls is
tracked directly from the original property/segment name (not reverse-parsed from the resolved accessor's
method name), so it's correct regardless of which of the three accessor shapes was used.
*Inspiration: Ebean's own record-entity support (`test-java16`); user-reported gap during review.*
*Status: implemented.*
## Rejected Requirements
These were considered and explicitly rejected as out of scope:
- **DTO as interface / dynamic proxy views** — Blaze `@EntityView` defines views as interfaces backed by
runtime proxies. This conflicts with Ebean's preference for plain, framework-unattached DTO classes.
- **Updatable or creatable entity views (persist through DTO)** — Blaze's `@UpdatableEntityView` /
`@CreatableEntityView` cascade persist/update through the view. This would duplicate Ebean's existing
entity persistence model and introduce a second, ambiguous dirty-checking/cascade model.
- **New predicate/filter DSL for subview collections** — Blaze allows filter expressions directly in
`@Mapping` (e.g. filtering a collection by an attribute value). Ebean already has typed query bean
predicates and `.filterMany()` for filtering child collections in queries; no new embedded filter
expression language is needed on the DTO itself.
## References
- Issue: https://github.com/ebean-orm/ebean/issues/2540
- PR #2626: `InterceptReadOnly` / `InterceptReadWrite` split enabling the unmodifiable entity graph fast path
- Ebean docs: https://ebean.io/docs/query/option#unmodifiable
- QueryDSL: `@QueryProjection` (constructor-based, compile-time-checked projections)
- Blaze-Persistence Entity Views: https://persistence.blazebit.com/documentation/1.6/entity-view/manual/en_US/
+1
View File
@@ -14,6 +14,7 @@ Key guides (fetch and follow when performing the relevant task):
- Migrate to `Database.builder()`: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/migrating-to-database-builder.md
- Migrate JSON APIs from Jackson core to avaje-json-core: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/migrating-json-jackson-core-to-avaje-json-core.md
- Write queries with query beans: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/writing-ebean-query-beans.md
- Mapping entity graphs to DTOs (`mapTo`): https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/mapping-entity-graphs-to-dtos.md
- Derived / formula properties (`@Formula`, `@Formula2`): https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/derived-formula-properties.md
- Persisting and transactions: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/persisting-and-transactions-with-ebean.md
- Query metrics and naming: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/ebean-query-metrics.md
+3
View File
@@ -47,6 +47,7 @@ existing Maven project. Complete the steps in order.
| Guide | Description |
|-------|-------------|
| [Write Ebean queries with query beans](writing-ebean-query-beans.md) | Step-by-step guidance for AI agents to write type-safe Ebean queries; choose the right terminal method; tune `select()` / `fetch()` / `fetchQuery()`; and project to DTOs when entity beans are not the right output |
| [Mapping entity graphs to DTOs (`mapTo`)](mapping-entity-graphs-to-dtos.md) | Map a nested entity graph query result to a nested DTO graph via `query.mapTo(Dto.class)`; `@DtoPath`/`@DtoRef` for renamed/flattened/id-only properties; identity-aware de-dup via `DtoMapContext`; computed/aggregate DTO values via `@Entity @View` + `@Formula2`/`@Sum`/`@Aggregation`; comparison with the flat `asDto()` pipeline |
| [Immutable bean cache for read-only references](immutable-bean-cache.md) | Use `ImmutableBeanCache` and `ImmutableBeanCaches.loading(...)` to resolve assoc-one references in read-only/unmodifiable queries, including secondary `fetchQuery`/`fetchLazy` loads |
| [Using `RawSql` with Ebean](using-rawsql-with-ebean.md) | Choose between `RawSqlBuilder.parse()`, `unparsed()`, and `withPlaceholders()`; the `${where}`/`${andWhere}`/`${having}`/`${andHaving}` placeholder reference for CTEs, window functions, and subqueries; column mapping; and using `RawSql` with query beans |
@@ -153,6 +154,7 @@ Key guides (fetch and follow these when performing the relevant task):
- Database configuration: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-postgres-database-config.md
- Migrate to `Database.builder()`: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/migrating-to-database-builder.md
- Write queries with query beans: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/writing-ebean-query-beans.md
- Mapping entity graphs to DTOs (`mapTo`): https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/mapping-entity-graphs-to-dtos.md
- Immutable bean cache for read-only references: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/immutable-bean-cache.md
- Ebean OpenTelemetry tracing: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-opentelemetry.md
- Query metrics and naming: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/ebean-query-metrics.md
@@ -182,6 +184,7 @@ Key guides (fetch and follow these when performing the relevant task):
- Database configuration: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-postgres-database-config.md
- Migrate to `Database.builder()`: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/migrating-to-database-builder.md
- Write queries with query beans: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/writing-ebean-query-beans.md
- Mapping entity graphs to DTOs (`mapTo`): https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/mapping-entity-graphs-to-dtos.md
- Persisting and transactions: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/persisting-and-transactions-with-ebean.md
- Test container setup: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-postgres-test-container.md
- DB migration generation: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-db-migration-generation.md
@@ -0,0 +1,785 @@
# Guide: Mapping entity graphs to DTOs — `query.mapTo(Dto.class)`
## Purpose
`query.mapTo(SomeDto.class)` maps an entity query result to a **nested DTO graph** —
DTO fields can themselves be DTOs (`ToOne`) or `List<Dto>`/`Set<Dto>` (`ToMany`), not
just flat scalar columns. Ebean generates the mapper (reflection-free), automatically
derives the query's `select()`/`fetch()` spec from the target DTO's declared shape, and
forces `setUnmodifiable(true)` so any property the mapper needs but wasn't fetched fails
fast with `LazyInitialisationException` instead of silently lazy loading.
This is distinct from the existing flat `asDto(Dto.class)` — see
[Quick comparison](#quick-comparison-mapto-vs-asdto-vs-plain-entity-query) below.
```java
Optional<CustomerDto> dto = new QCustomer()
.id.eq(customerId)
.mapTo(CustomerDto.class)
.findOneOrEmpty();
```
```java
List<CustomerDto> dtos = DB.find(Customer.class)
.where().eq("status", Status.ACTIVE)
.mapTo(CustomerDto.class) // no .select()/.fetch() needed - derived from CustomerDto's shape
.findList();
```
---
## Quick comparison: `mapTo()` vs `asDto()` vs plain entity query
| | `mapTo(Dto.class)` | `asDto(Dto.class)` | Plain entity query |
|---|---|---|---|
| Shape | Nested DTO **graph** (ToOne/ToMany) | Flat, single-row DTO | Entity graph |
| Fetch spec | Auto-derived from the DTO's declared shape | Whatever `select()`/SQL you write | Whatever `select()`/`fetch()` you write |
| Mismatch caught | At compile time (unregistered pair fails fast at first use; codegen fails fast on structural problems) | At runtime (reflection-based constructor/setter matching) | N/A (real entity properties) |
| Identity/de-dup | Yes - repeated source instances map to the same DTO instance (`DtoMapContext`) | N/A (one row in, one DTO out) | Yes (entity/persistence-context identity) |
| Backing pipeline | Executes the entity ORM query, `setUnmodifiable(true)`, maps the resulting graph | Executes SQL directly against a flat `ResultSet` | Executes the entity ORM query |
| Best for | API/read-model responses that mirror a **nested** entity shape | Flat summary rows, reports, native/vendor SQL | Data you intend to mutate and save back |
See also [writing-ebean-query-beans.md](writing-ebean-query-beans.md) (Step 8/9) for
`asDto()` and the general query-shape decision guide.
---
## Basic usage
### 1. Declare a plain DTO
DTOs are plain classes with **no framework attachment** — no annotations required for
the common case (properties matched to the source entity by name):
```java
public class CustomerDto {
private final Long id;
private final String name;
private final AddressDto billingAddress; // nested ToOne
private final List<ContactDto> contacts; // nested ToMany
public CustomerDto(Long id, String name, AddressDto billingAddress, List<ContactDto> contacts) {
this.id = id;
this.name = name;
this.billingAddress = billingAddress;
this.contacts = contacts;
}
public Long getId() { return id; }
public String getName() { return name; }
public AddressDto getBillingAddress() { return billingAddress; }
public List<ContactDto> getContacts() { return contacts; }
}
```
A constructor whose parameters match (by name) a source entity/DTO property is used
for mapping — same shape convention as the existing `DtoQuery`. Getters are used to
read the source's properties — a bare/fluent accessor like `active()` is resolved
automatically too, not just `getActive()`/`isActive()` (useful both for Ebean's own
record entity beans and for ordinary classes that just expose bare-name accessors).
### 2. Register the (source, target) pair
Declare each entity → DTO pair with `@DtoMapping` on a `package-info.java` (a neutral
holder — see [Why `package-info.java`?](#why-package-infojava)):
```java
@DtoMapping(source = Customer.class, target = CustomerDto.class)
@DtoMapping(source = Address.class, target = AddressDto.class)
@DtoMapping(source = Contact.class, target = ContactDto.class)
package org.example.dto;
import io.ebean.annotation.DtoMapping;
```
This triggers `querybean-generator` (the existing annotation processor) to generate a
`CustomerDtoMapper implements DtoMapper<Customer, CustomerDto>` for each pair — no new
Maven/Gradle setup beyond what query beans already require.
### 3. Query with `mapTo(...)`
```java
List<CustomerDto> dtos = DB.find(Customer.class)
.where().eq("status", Status.ACTIVE)
.mapTo(CustomerDto.class)
.findList();
CustomerDto one = new QCustomer().id.eq(id).mapTo(CustomerDto.class).findOne();
Optional<CustomerDto> maybe = new QCustomer().id.eq(id).mapTo(CustomerDto.class).findOneOrEmpty();
```
`mapTo(...)` works the same from a query bean (`QCustomer`) or a plain `DB.find(...)`/
`ExpressionList` query.
### Paging - `findPagedList()`
`findPagedList()` mirrors `Query#findPagedList()` — the underlying entity query is paged
as normal and each page's result is mapped to the target DTO list:
```java
PagedList<CustomerDto> paged = DB.find(Customer.class)
.where().eq("status", Status.ACTIVE)
.orderBy().asc("name")
.setFirstRow(0)
.setMaxRows(50)
.mapTo(CustomerDto.class)
.findPagedList();
int totalRowCount = paged.getTotalCount(); // page metadata - unaffected by DTO mapping
List<CustomerDto> page1 = paged.getList(); // mapped DTOs for this page
```
Page metadata (`getTotalCount()`, `getTotalPageCount()`, `hasNext()`, `hasPrev()`,
`loadCount()`, ...) reflects the underlying entity query directly; only `getList()`
is mapped (once, cached) to the DTO type.
### An unregistered pair fails fast
If `(Customer.class, SomeDto.class)` was never declared via `@DtoMapping`, the first
`mapTo(SomeDto.class)` call throws immediately:
```
PersistenceException: No DtoMapper registered mapping Customer -> SomeDto
- check @DtoMapping(source = Customer.class, target = SomeDto.class) is declared
on a package-info.java processed by querybean-generator
```
---
## Auto-derived fetch spec
You never write `.select()`/`.fetch()` for a `mapTo(...)` query — the generated mapper
exposes a `fetchGroup()` built directly from the DTO's declared shape, and `mapTo(...)`
applies it automatically:
```java
public CustomerDtoMapper() {
this(new AddressDtoMapper(), new ContactDtoMapper());
}
public CustomerDtoMapper(DtoMapper<Address, AddressDto> billingAddressMapper,
DtoMapper<Contact, ContactDto> contactsMapper) {
this.fetchGroup = FetchGroup.of(Customer.class)
.select("id,name")
.fetch("billingAddress", billingAddressMapper.fetchGroup())
.fetch("contacts", contactsMapper.fetchGroup())
.build();
}
```
Each nested DTO gets its own generated mapper (mirroring MapStruct's per-type mapper
generation), wired together via constructor injection — mappers are stateless and
substitutable, not static singletons. Mapper instances are constructed once, in
dependency order, and reused — see [DtoMapperManager](#one-mapper-instance-per-pair)
below.
---
## Nested collections and identity-aware de-duplication
When the same source entity instance is reachable via more than one path in the graph
(e.g. two `Contact`s sharing the same `Customer`, or the same `Address` referenced from
two paths), the mapper reuses the **same** target DTO instance rather than creating
duplicate-but-equal copies — mirroring the identity semantics the entity graph already
has:
```java
List<CustomerDto> dtos = DB.find(Customer.class).mapTo(CustomerDto.class).findList();
CustomerDto customer = dtos.get(0);
// both contacts share the exact same customer.billingAddress AddressDto instance
assertThat(customer.getContacts().get(0).getCustomer())
.isSameAs(customer.getContacts().get(1).getCustomer());
```
This is done via a `DtoMapContext` threaded through every nested `map(...)` call within
one top-level `mapList(...)`/`findList()` invocation. The generated code only pays for
this when it can actually matter — a DTO that's never nested under another DTO skips
`DtoMapContext` entirely (there's nothing else in scope to de-duplicate against):
```java
// AddressDto is nested under CustomerDto (reachable via multiple contacts) - dedup needed
// dedup using DtoMapContext, same Address instance can be reached via more than one path in the graph
return context.computeIfAbsent(AddressDto.class, source, s -> new AddressDto(...));
// ContactSummaryDto is only ever mapped as a top-level query result - no dedup possible
// skip DtoMapContext, only ever a top-level mapping
return new ContactSummaryDto(source.getId(), source.getFullName());
// CustomerDto has nested mappers (billingAddress, contacts) but is never itself nested
// DtoMapContext for nested mappers only
return new CustomerDto(source.getId(), source.getName(), ...);
```
The generated comment tells you at a glance which of the three cases applies — useful
when debugging why a `DtoMapContext` is (or isn't) in the generated code for a
particular mapper.
---
## Using generated mappers directly (outside `query.mapTo()`)
Every generated `XxxDtoMapper` is a plain public class — you don't need `ServiceLoader`,
a registry, or a `Database` just to construct or call one directly (though
`DtoMapperManager`, below, is available if you want a shared, DI-friendly lookup). It
always has a public no-arg constructor (delegating to defaults for any nested mappers/
`@DtoConvert` converters) plus an explicit constructor taking those dependencies directly,
and implements `DtoMapper<SOURCE, TARGET>`'s `map(...)`/`mapList(...)`:
```java
CustomerDtoMapper mapper = new CustomerDtoMapper();
CustomerDto dto = mapper.map(customer); // any Customer you already have on hand
List<CustomerDto> dtos = mapper.mapList(customers);
```
This works on **any** entity graph, not just one that just came out of a `mapTo(...)`
query — e.g. entities you loaded with a plain `.fetch(...)` query, entities you just
`.save()`d, or entities built by hand in a test. The only requirement is that whatever the
mapper reads (via plain getters) is actually populated — there's no lazy-loading fallback.
### Testing the mapping in isolation
Because mappers are plain, constructor-injected classes, you can unit test the mapping
logic itself — independent of `query.mapTo()`, the DTO-pair registry, and (for
`@DtoConvert` instance-dispatch converters) `DtoConverterManager` — by passing a test
double straight into the explicit constructor:
```java
SecretCipher upperCasingTestCipher = String::toUpperCase;
ContactConversionDto dto = new ContactConversionDtoMapper(upperCasingTestCipher).map(contact);
assertThat(dto.getSecretCode()).isEqualTo("SHH");
```
No `DtoConverterManager.put(...)` registration needed for this kind of test — the real
production wiring (`DtoConverterManager.get(SecretCipher.class)`) only happens in the
generated no-arg constructor, which the explicit-constructor call above bypasses entirely.
See `TestCustomerDtoGraphMapping` (mapper called directly against a manually queried
graph) and `TestMapperManualUsage` (mapper called directly against hand-built/just-saved
entities, plus the converter test-double case above) in `tests/test-dto-mapping`.
### `DtoMapperManager` — resolving a generated mapper for dependency injection
`new CustomerDtoMapper()` is enough for a single mapper, but if your application wants a
single shared instance of *every* generated mapper (mirroring how `query.mapTo()` resolves
them internally) - e.g. to wire one up for constructor injection into a service, replacing
a hand-written mapper class - use `io.ebean.DtoMapperManager`:
```java
DtoMapperManager manager = new DtoMapperManager(); // ServiceLoader discovery only - no Database needed
CustomerDtoMapper mapper = manager.get(CustomerDtoMapper.class);
```
`DtoMapperManager` has no dependency on `Database` at all - its constructor only does
`ServiceLoader.load(DtoMapperRegister.class)` - so it can be constructed independently,
before (or entirely without) a `Database`, e.g. as a bean in an avaje-inject (or any DI
framework's) dependency graph:
```java
@Factory
class DtoMapperFactory {
@Bean
DtoMapperManager dtoMapperManager() {
return new DtoMapperManager();
}
@Bean
CustomerDtoMapper customerDtoMapper(DtoMapperManager manager) {
return manager.get(CustomerDtoMapper.class);
}
}
```
If you also want `query.mapTo(...)` to use that *exact same* manager instance (so there's
only ever one instance of each generated mapper, whichever path resolves it), register it
via `DatabaseBuilder.putServiceObject` before building the `Database` - this is the same
`putServiceObject`/`getServiceObject` mechanism already used for things like
`AutoMigrationRunner`:
```java
DtoMapperManager sharedManager = new DtoMapperManager();
Database db = Database.builder()
.putServiceObject(DtoMapperManager.class, sharedManager)
.build();
// query.mapTo(...) against `db` now resolves mappers via `sharedManager`
```
If nothing is registered via `putServiceObject`, the `Database` builds its own default
`DtoMapperManager` instance instead - registering one is entirely optional. A standalone
`DtoMapperManager()` construction bypasses the `DatabaseConfigProvider` hook (that hook is
specifically about `Database` startup ordering), so if any of your mappers need a
`@DtoConvert` instance-dispatch converter, register it via `DtoConverterManager.put(...)`
yourself first, exactly as you would before building a `Database`. See
`TestDtoMapperManager` and `TestDtoMapperManagerSharing` in `tests/test-dto-mapping`.
### Recipe: adding extra caller-supplied fields after mapping
Sometimes a target DTO needs a field that isn't sourced from the entity graph at all - e.g.
populated from a separate query or business rule, only when a caller-supplied flag is set.
Rather than the generator supporting partial/builder-based mapping directly, if your DTO is
a record with a "seed from instance" builder (e.g. via `avaje-recordbuilder`'s
`@RecordBuilder`, which generates `Target.builder(existingInstance)`), just map the
graph-sourced fields as usual and layer the extra field on afterwards:
```java
Driver base = mapper.map(cDriver);
Driver full = DriverBuilder.builder(base).fleets(fleets).build();
```
No generator changes needed - the mapped instance is simply the seed for the builder.
---
## Large targets: builder-based construction and named variants
Two features aimed at large, builder-shaped target DTOs (typically OpenAPI-generated records
with a generated builder), where a positional constructor call is unwieldy and a single query
needs to populate the target in more than one shape.
### Builder-based construction (`builder = AUTO | ALWAYS | NEVER`)
If the target has a static no-arg `Target.builder()` factory returning a type with a fluent
(returns-itself) setter per property plus a `build()` method - the shape
`avaje-recordbuilder`'s `@RecordBuilder` generates - the generated mapper can construct the
target via `Target.builder().prop(x)....build()` instead of `new Target(a, b, c, ...)`:
```java
public record User(Long id, String name, String email, /* ... 21 more fields */) {
public static UserBuilder builder() {
return UserBuilder.builder();
}
}
```
```java
@DtoMapping(source = CUser.class, target = User.class)
package org.example.dto;
```
By default (`builder = AUTO`), the generator auto-detects a matching builder and uses it only
once the target has more than 5 properties, falling back to a positional constructor for
smaller DTOs. Override explicitly either direction:
```java
@DtoMapping(source = CUser.class, target = User.class, builder = DtoMapping.Builder.ALWAYS)
```
`builder = ALWAYS` is a codegen-time error if no matching builder shape is found; `builder =
NEVER` always uses a positional constructor even if a builder is detected. This applies
regardless of whether the target is hand-authored or foreign/generated - `@DtoMapping` is
already declared externally via `package-info.java`, so no annotation on the target itself is
needed either way.
### Named variants excluding nested paths (`name=`, `exclude=`)
The same `(source, target)` pair can be registered more than once - one base mapping (leaving
`name()` empty) plus any number of named variants, each excluding one or more nested
ToOne/ToMany properties:
```java
@DtoMapping(source = CUser.class, target = User.class)
@DtoMapping(source = CUser.class, target = User.class, name = "noFleets", exclude = "fleets")
package org.example.dto;
```
Both variants are generated into the **same** mapper class (one class per target, not one per
variant) - the generated `noFleets()` accessor returns a single shared/cached `DtoMapper<CUser,
User>` view (not reconstructed per call), omitting `fleets` from both its mapped output (`null`
for a ToOne, `List.of()` for a ToMany) and its own `fetchGroup()`. Each excluded property is still
evaluated inline at its own declared field position internally (guarded by a boolean flag) - a
variant's exclusions never change the evaluation order of the DTO's other properties. Select it
with the `query.mapTo(Class, DtoMapper)` overload, which takes an already-resolved mapper instance
directly - no string-based lookup:
```java
UserMapper userMapper = new UserMapper();
// full shape, with fleets fetched/mapped
List<User> withFleets = DB.find(CUser.class)
.mapTo(User.class, userMapper) // or plain .mapTo(User.class)
.findList();
// bulk listing shape - fleets excluded from both the fetch spec and the output
List<User> noFleets = DB.find(CUser.class)
.mapTo(User.class, userMapper.noFleets())
.findList();
```
Only nested ToOne/ToMany properties can be excluded - a scalar or `@DtoRef` property can't be,
since there's no type-safe "absent" value for an arbitrary scalar type. Named variants are
scoped to independent, top-level query results only - unlike the base mapping, they don't
participate in `DtoMapContext` identity de-duplication when nested elsewhere in a graph, since a
variant is never intended to be nested inside another DTO's mapping.
---
## `@DtoPath` — renamed or flattened properties
By default a DTO property is matched to the source entity property (or nested DTO
mapper) of the **same name**. `@DtoPath` overrides that, allowing a DTO property to be
renamed and/or flattened from a nested path using dot-notation:
```java
public class ContactDto {
private final long id;
private final String firstName;
private final String lastName;
@DtoPath("customer.billingAddress.city")
private final String customerCity; // flattened, 2 hops through customer
// constructor / getters ...
}
```
The generated mapper reads the path with a null-guard at each hop and adds the
necessary joins to the fetch spec automatically:
```java
(s.getCustomer() == null ? null
: (s.getCustomer().getBillingAddress() == null ? null
: s.getCustomer().getBillingAddress().getCity()))
```
`@DtoPath` is purely a compile-time/codegen-time hint — the DTO class itself carries no
runtime dependency on the annotation.
### Fetch-path collisions are a compile-time error
A `@DtoPath` whose fetch path is identical to a nested `ToOne`/`ToMany` property's own
fetch path on the *same* DTO (e.g. a nested `customer` field alongside
`@DtoPath("customer.name")` — both resolve to fetch path `"customer"`) fails the build
with a clear error, rather than silently discarding one side's fetched properties:
```
error: @DtoPath property 'customerName' on FooDto resolves to fetch path 'customer',
which collides with the nested mapping already using that same fetch path - Ebean's
fetch spec can only carry one set of properties per path, so one silently discards
the other. Move 'customerName' onto the nested DTO type instead, or choose a
@DtoPath that reaches into a different, non-colliding path.
```
Fix it either way it suggests: move the property onto the nested DTO type, or choose a
`@DtoPath` that reaches a different path (as `customerCity` above does deliberately,
using a 3-segment path through `customer.billingAddress` rather than colliding with a
plain `customer` nested field).
---
## `@DtoRef` — id-only back-references (breaking cycles)
The DTO graph derived from a set of DTO types must form a DAG — codegen fails if it
doesn't. `@DtoRef` is the explicit escape hatch for an intentional back-reference, e.g.
a `Contact` DTO referencing its parent `Customer` by id only, rather than re-embedding
a full `CustomerDto` (which would recreate the `Customer → Contact → Customer` cycle):
```java
public class ContactDto {
private final long id;
@DtoRef
private final Long customerId; // id-only, no nested CustomerDto re-embedded
// constructor / getters ...
}
```
The generated fetch spec adds the association to the **root** `select(...)` rather
than a nested `.fetch(...)` — this reads the foreign-key column directly off the base
table (no SQL join):
```java
this.fetchGroup = FetchGroup.of(ContactStats.class)
.select("customer,contactCount,engagementScore") // "customer" -> FK column, no join
.build();
```
```java
(source.getCustomer() == null ? null : source.getCustomer().getId())
```
If the same association is *also* independently nested-fetched elsewhere on the DTO
(e.g. `ContactDto` has both a nested `customer` field **and** `@DtoRef Long
customerId`), the generator recognizes the association is already covered and doesn't
add a redundant/duplicate select — no join is added twice.
---
## `@DtoConvert` — custom property conversion
Some properties need more than a plain getter copy — a scalar coercion (`short` to
`boolean`), an enum-to-`String` mapping, or a conversion needing a real dependency (e.g.
decrypting a value with a cipher). `@DtoConvert(value = ConverterType.class, method =
"name")` covers both, combinable with `@DtoPath` when the source value also needs a
path/rename override:
```java
public class ContactDto {
@DtoPath("status")
@DtoConvert(value = ContactConversions.class, method = "toActive")
private final boolean active; // Contact.status (Short) -> boolean
@DtoConvert(value = SecretCipher.class, method = "decode")
private final String secretCode; // decrypted via a registered SecretCipher
// constructor / getters ...
}
```
The generator resolves the referenced method at codegen time and dispatches one of two
ways, purely based on whether it's `static`:
- **Static method** — inlined as a direct static call
(`ContactConversions.toActive(source.getStatus())`). No registration needed at all —
use this for common, reusable, dependency-free coercions.
- **Instance method** — the generated mapper resolves one shared instance via
`DtoConverterManager.get(SecretCipher.class)`, wired as a constructor
parameter/field (the same shape as nested-mapper constructor injection), then calls
`secretCipher.decode(source.getSecretCode())`. Use this when the conversion needs a
real dependency.
### Registering an instance-dispatch converter
`DtoConverterManager` is a small, deliberately-scoped static put/get bridge — register
an already-constructed converter instance (e.g. built by your DI container) **before**
building the `Database`:
```java
AES256Cipher cipher = ...; // already DI-constructed
DtoConverterManager.put(SecretCipher.class, cipher::decrypt); // or a small adapter class
Database db = DatabaseFactory.create(...); // generated mappers resolve converters from here
```
If nothing is registered for a required type, `DtoConverterManager.get(...)` throws a
`PersistenceException` immediately — this happens as an eager field initializer on the
generated `EbeanDtoMapperRegister`, so a missing registration fails fast at `Database`
build time, not lazily on first `mapTo(...)` call.
> **Testing tip:** since `EbeanDtoMapperRegister`'s mapper fields are all constructed
> together when the `Database` starts, register converters via a `DatabaseConfigProvider`
> (a `ServiceLoader` hook that runs before the `Database` is built) rather than a test
> `@BeforeAll`, so registration always happens before *any* test triggers startup —
> regardless of which test class runs first.
## `@DtoMixin` — overlaying annotations onto a DTO you can't edit
Some DTOs are generated elsewhere (e.g. from an OpenAPI spec, regenerated on every
build) and can't be annotated directly. `@DtoMixin(Target.class)` overlays
`@DtoPath`/`@DtoRef`/`@DtoConvert` from a separate companion type instead — directly
mirroring avaje-jsonb's `@Json.MixIn` mechanism. Declare a companion interface (or
class) whose method names match the target DTO's property names:
```java
// ContactMixinDto itself carries no Ebean annotations at all
public class ContactMixinDto {
public ContactMixinDto(long id, String firstName, boolean active, String secretCode) { ... }
// getters ...
}
@DtoMixin(ContactMixinDto.class)
interface ContactMixinDtoMixin {
@DtoPath("status")
@DtoConvert(value = ContactConversions.class, method = "toActive")
boolean active();
@DtoConvert(value = SecretCipher.class, method = "decode")
String secretCode();
}
```
The processor matches each mixin method to the target's property by name and applies
whichever annotations are present as if they were declared on the target field itself.
The mixin type is never instantiated and carries no runtime footprint — it's purely a
compile-time/codegen-time hint.
---
## Computed / aggregate properties via `@Entity @View`
There's no dedicated "formula on DTO" annotation (a narrower `@Formula2`-on-DTO
variant was explored and rejected — see
[dto-mapping-design.md](../dto-mapping-design.md) for the reasoning). Instead, model
the computed value as its own read-only entity using `@View`, then map that entity to a
plain DTO with the same `@DtoMapping` machinery described above. `@View(name = "...")`
here just points a second entity at an **existing** table — it does not create a new
database view or table.
### Worked example — computed column (`@Formula2`)
```java
@Entity
@View(name = "contact") // reads the existing 'contact' table, no new DDL
public class ContactSummary {
@Id
private Long id;
private String firstName;
private String lastName;
@Formula2("concat(firstName, ' ', lastName)")
private String fullName;
// getters ...
}
```
```java
public class ContactSummaryDto {
private final Long id;
private final String fullName;
// constructor / getters ...
}
```
```java
@DtoMapping(source = ContactSummary.class, target = ContactSummaryDto.class)
```
```java
List<ContactSummaryDto> summaries = DB.find(ContactSummary.class)
.mapTo(ContactSummaryDto.class)
.findList();
```
### Worked example — group-by aggregation (`@Sum`/`@Aggregation`)
The same `@View`-on-base-table pattern applies to Ebean's `@Sum`/`@Aggregation`
group-by formulas — the Blaze-Persistence parallel is an `@EntityView` with
`@Mapping("SIZE(...)")`/`@Mapping("SUM(...)")` correlated mappings:
```java
@Entity
@View(name = "contact")
public class ContactStats {
@Id
private Long id; // required so @Aggregation("count(id)") has something to
// count; deliberately never selected/mapped - selecting it
// would defeat the aggregation (one row per contact
// instead of one row per customer)
@ManyToOne
private Customer customer;
@Aggregation("count(id)")
private Long contactCount;
@Sum
private Integer engagementScore;
// getters ...
}
```
```java
public class ContactStatsDto {
@DtoRef
private final Long customerId; // also the implicit GROUP BY key
private final Long contactCount;
private final Integer engagementScore;
// constructor / getters ...
}
```
Because `customerId` uses `@DtoRef`, the generated fetch spec is
`select("customer,contactCount,engagementScore")` with **no join** — the query groups
by the FK column directly:
```sql
select t0.customer_id, count(t0.id), sum(t0.engagement_score)
from contact t0
group by t0.customer_id
```
---
## Performance notes
### Fail-fast, no accidental lazy loading
`mapTo(...)` forces `query.setUnmodifiable(true)` under the hood. If the mapper ever
needs a property that wasn't fetched, it throws `LazyInitialisationException`
immediately rather than silently issuing an extra query per row or returning `null`.
`InterceptReadOnly` (the unmodifiable-graph bean state) is also cheap — a `boolean[]
loaded` flag array plus a `frozen` flag, not a full second copy of bean state.
### One mapper instance per pair
Generated mappers are constructed once (in dependency order — a mapper with nested
mappers takes them as constructor params) and reused across every `mapTo(...)` call for
that pair, resolved and cached by `DtoMapperManager` keyed on `(sourceType, dtoType)`.
### `DtoMapContext` overhead only where it earns its keep
As shown above, the generator only involves `DtoMapContext` for mappers that can
actually be reached via more than one path in some graph (dedup) or that have nested
mappers of their own (need to thread the context down); a DTO that's only ever a
top-level query result skips it entirely.
### Fetch strategy and pagination carry over unchanged
Existing fetch-strategy control (`+query`/`+lazy`, `fetchQuery()`) and pagination
(including keyset pagination and `findPagedList()`) work the same whether the query
target is an entity graph or a `mapTo(...)` DTO graph — no special-casing needed.
---
## Which should I use?
- **`mapTo(Dto.class)`** — the target is a **nested** shape (has its own ToOne/ToMany
DTO fields) that should mirror part of the entity graph; you want the fetch spec
derived automatically and verified to match the DTO's declared shape.
- **`asDto(Dto.class)`** / `DB.findDto(...)` — the target is a **flat** row (report,
summary, native/vendor SQL); you're comfortable with runtime-checked column-to-bean
matching, or the SQL doesn't map cleanly to entity property paths at all.
- **Plain entity query** — the caller needs a real, persistable, mutable entity — not a
read-only projection.
---
## Reference
### Why `package-info.java`?
`@DtoMapping` is declared on a package (`ElementType.PACKAGE`), not the DTO or the
entity, because:
- the DTO type is often owned/generated elsewhere (e.g. from an OpenAPI spec) and
shouldn't need to be annotated with an internal persistence/entity type;
- one entity may be the source for several different DTOs (e.g. a summary vs. a detail
view), and the same entity/DTO pair may need registering from multiple consuming
modules.
### Annotations at a glance
| Annotation | Target | Purpose |
|---|---|---|
| `@DtoMapping(source=, target=)` | `package-info.java` | Registers an entity → DTO pair, triggers mapper generation |
| `@DtoMapping(..., builder=)` | `package-info.java` | `AUTO` (default, threshold-based) / `ALWAYS` / `NEVER` - builder-chain vs positional constructor |
| `@DtoMapping(..., name=, exclude=)` | `package-info.java` | Registers a named variant sharing the base mapping's generated class, excluding nested paths |
| `@DtoPath("a.b.c")` | DTO field/getter | Renamed and/or flattened multi-hop property mapping |
| `@DtoRef` | DTO field/getter | Id-only back-reference; breaks a cycle; root-selects the FK (no join) |
| `@DtoConvert(value=, method=)` | DTO field/getter | Custom scalar conversion - static (no registration) or instance (via `DtoConverterManager`) dispatch |
| `@DtoMixin(Target.class)` | Companion interface/class | Overlays `@DtoPath`/`@DtoRef`/`@DtoConvert` onto a DTO that can't be annotated directly |
### Parallels with other tools
If you're coming from another mapping library, here's the rough correspondence:
| Ebean | MapStruct | Blaze-Persistence |
|---|---|---|
| Generated `DtoMapper` per (source, DTO) pair | Generated `@Mapper` implementation | `@EntityView` (interface + runtime proxy) |
| `@DtoPath("a.b.c")` | `@Mapping(target = "x", source = "a.b.c")` | `@Mapping("a.b.c")` |
| `@DtoRef` | `@Context`/manual cycle-breaking (no dedicated annotation) | Sub-view referencing an id-only projection |
| `@DtoConvert(value=, method=)` | `@Mapping(qualifiedByName = "...")` / custom mapper methods | Custom converter/`@Mapping` expression |
| `@DtoMixin(Target.class)` | N/A (annotate the `@Mapper` interface's abstract methods instead) | N/A |
| `DtoMapContext` identity de-dup | Not built in (opt-in `@MappingTarget`/manual caching) | Built in (entity-view identity) |
| `@Entity @View` + `@Formula2`/`@Sum`/`@Aggregation` for computed DTO values | N/A (MapStruct doesn't touch SQL) | `@Mapping("SIZE(...)")` / `@Mapping("SUM(...)")` correlated mappings |
See [dto-mapping-design.md](../dto-mapping-design.md) for the full design rationale and
[dto-mapping-requirements.md](../dto-mapping-requirements.md) for the accepted/rejected
requirements this feature was scoped against (issue
[#2540](https://github.com/ebean-orm/ebean/issues/2540)).
+5
View File
@@ -462,6 +462,11 @@ List<CustomerSummary> summaries = new QCustomer()
- the result is not going to be updated and saved back as an entity
- the query contains formulas or aggregation intended for a read model
`asDto(...)` maps a **flat**, single-row result. If the target DTO itself needs nested
DTO fields (ToOne/ToMany) mirroring part of the entity graph, use
`mapTo(Dto.class)` instead — see
[Mapping entity graphs to DTOs](mapping-entity-graphs-to-dtos.md).
---
## Step 9 - Only fall back to raw SQL when the ORM query is not a good fit
@@ -0,0 +1,65 @@
package io.ebean;
import jakarta.persistence.PersistenceException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Static bridge registering custom {@code @DtoConvert} converter instances so generated DTO
* mappers can reach them.
* <p>
* Generated mappers (see {@code query.mapTo(SomeDto.class)}) are wired via {@code ServiceLoader}
* as plain, no-arg-constructed, compile-time singletons (mirroring how entity/query-bean
* registration already works) - they have no way to reach a dependency-injection container, or
* any particular {@code Database} instance, at construction time. When a
* {@code @DtoConvert(value = ConverterType.class, method = "...")} property's converter is an
* <b>instance</b> method (as opposed to a {@code static} one, which is called directly with no
* registration needed at all), the generated mapper resolves it via {@link #get(Class)} - so the
* application must register an instance here, typically one already built by its own DI
* container, <b>before</b> building the {@code Database}:
* <pre>{@code
* AES256Cipher cipher = ...; // already DI-constructed
* DtoConverterManager.put(DriverConversions.class, new DriverConversionsImpl(cipher));
*
* Database db = DatabaseFactory.create(...); // generated mappers resolve converters from here
* }</pre>
* <p>
* This is a deliberate, narrowly-scoped exception to preferring dependency injection over static
* mutable state - it exists solely to bridge an already-DI-constructed singleton into
* {@code ServiceLoader}-discovered, no-arg-constructed generated code, which cannot otherwise
* reach a DI container or a specific {@code Database} instance. {@link #get(Class)} throws
* immediately if nothing was registered for the given type, so a missing/late registration fails
* fast at {@code Database} build time (a generated mapper's eager field initializer) rather than
* lazily on first use.
*/
public final class DtoConverterManager {
private static final Map<Class<?>, Object> converters = new ConcurrentHashMap<>();
private DtoConverterManager() {
}
/**
* Register a converter instance for the given type - must be called before the
* {@code Database} using it is built.
*/
public static <T> void put(Class<T> type, T instance) {
converters.put(type, instance);
}
/**
* Return the registered converter instance for the given type.
*
* @throws PersistenceException if no instance was registered for {@code type}.
*/
@SuppressWarnings("unchecked")
public static <T> T get(Class<T> type) {
T instance = (T) converters.get(type);
if (instance == null) {
throw new PersistenceException("No " + type.getName() + " registered - call "
+ "DtoConverterManager.put(" + type.getSimpleName() + ".class, ...) before starting the Database");
}
return instance;
}
}
@@ -0,0 +1,51 @@
package io.ebean;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.function.Function;
/**
* Identity-keyed cache of already-mapped source -&gt; target instances, shared across one
* top-level {@link DtoMapper#mapList(java.util.List)} call (or an explicitly shared context).
* <p>
* Keyed by source object <b>identity</b> (an {@link IdentityHashMap}, not {@code equals()}/
* {@code hashCode()}) because the source is an Ebean entity graph, where repeated references to
* the same row within one query already resolve to the same Java object instance.
* <p>
* The identity map is partitioned <b>per target DTO type</b>. This matters because the same
* source instance can legitimately need to be mapped to more than one target type within a
* single graph - e.g. a top-level {@code CustomerDtoMapper} maps a {@code Customer} to a full
* {@code CustomerDto}, while a nested {@code ContactDtoMapper} maps the very same {@code Customer}
* instance (accessed via {@code contact.getCustomer()}) to a shallow {@code CustomerRefDto} to
* avoid a cycle. A single un-partitioned {@code IdentityHashMap<Object,Object>} would have the
* two mappers collide on the same source key and incorrectly hand back the other mapper's
* (wrong-typed) cached result. Partitioning by target type keeps each mapper's cache isolated
* while still sharing one context/instance per top-level mapping call.
* <p>
* Not thread-safe - a context is expected to be created per top-level mapping call and not
* shared across threads.
*/
public final class DtoMapContext {
private final Map<Class<?>, Map<Object, Object>> mappedByType = new HashMap<>();
/**
* Return the already-mapped target for the given source instance if present, otherwise map it
* via {@code mappingFunction}, register it, and return it.
*
* @param targetType the DTO type being produced - used to partition the identity cache so that
* mapping the same source to different target types never collides.
*/
@SuppressWarnings("unchecked")
public <S, T> T computeIfAbsent(Class<T> targetType, S source, Function<S, T> mappingFunction) {
Map<Object, Object> mapped = mappedByType.computeIfAbsent(targetType, t -> new IdentityHashMap<>());
T existing = (T) mapped.get(source);
if (existing != null) {
return existing;
}
T created = mappingFunction.apply(source);
mapped.put(source, created);
return created;
}
}
@@ -0,0 +1,76 @@
package io.ebean;
import java.util.ArrayList;
import java.util.List;
/**
* Mapper interface implemented by generated (or hand-written) entity -&gt; DTO graph mappers.
* <p>
* Used with nested entity-to-DTO graph mapping (see {@code query.mapTo(SomeDto.class)}) as
* distinct from the existing flat, single-row {@link DtoQuery} pipeline. Each entity/DTO type
* pair gets its own small, composable mapper implementation (mirroring MapStruct's per-type
* mapper generation) rather than one large mapper inlining every nested type. Nested mappers are
* wired together via constructor injection, not static singletons - this keeps mappers stateless,
* substitutable (e.g. for tests) and avoids global mutable state.
* <p>
* A {@link DtoMapContext} is threaded through every nested {@code map(...)} call within one
* top-level {@link #mapList(List)} invocation, so that repeated references to the same source
* entity instance (e.g. several {@code Contact}s sharing the same {@code Customer}) map to the
* <b>same</b> target DTO instance rather than creating duplicate-but-equal copies. This mirrors
* the identity semantics Ebean's own entity graph already has, and is what makes the resulting
* DTO graph "graph shaped" rather than "tree of copies shaped".
* <p>
* Implementations contain no reflection or {@code MethodHandles} - only direct getter calls and
* constructor invocation - so generated mappers are safe under GraalVM native-image with zero
* additional reachability metadata.
*
* @param <SOURCE> the source entity (or embeddable) type
* @param <TARGET> the target DTO type
*/
public interface DtoMapper<SOURCE, TARGET> {
/**
* Return the {@link FetchGroup} of exactly the source properties (and nested paths) needed to
* populate the target DTO graph - the select()/fetch() spec is derived from the DTO's declared
* shape rather than maintained separately by hand. Used by {@code query.mapTo(TARGET.class)}
* to automatically apply the correct fetch spec before the query is executed.
*/
FetchGroup<SOURCE> fetchGroup();
/**
* Map a single source instance to its target DTO, reusing/registering the mapping in the
* given context so that repeated references to the same source instance de-duplicate to the
* same target instance. Must return {@code null} when given {@code null}.
*/
TARGET map(SOURCE source, DtoMapContext context);
/**
* Map a single source instance using a fresh, one-off context. Convenience for mapping a
* single object in isolation (no de-duplication opportunity since there's nothing else in
* scope to de-duplicate against).
*/
default TARGET map(SOURCE source) {
return map(source, new DtoMapContext());
}
/**
* Map a list of source instances to a list of target DTOs sharing the given context,
* preserving order.
*/
default List<TARGET> mapList(List<SOURCE> source, DtoMapContext context) {
List<TARGET> result = new ArrayList<>(source.size());
for (SOURCE s : source) {
result.add(map(s, context));
}
return result;
}
/**
* Map a list of source instances to a list of target DTOs using a fresh context shared across
* the whole list - this is the usual top-level entry point, e.g. mapping the result of a
* {@code query.findList()} call.
*/
default List<TARGET> mapList(List<SOURCE> source) {
return mapList(source, new DtoMapContext());
}
}
@@ -0,0 +1,125 @@
package io.ebean;
import io.ebean.config.DtoMapperRegister;
import jakarta.persistence.PersistenceException;
import java.util.ArrayList;
import java.util.List;
import java.util.ServiceLoader;
import java.util.concurrent.ConcurrentHashMap;
/**
* Loads all generated {@link DtoMapperRegister} implementations (via {@code ServiceLoader},
* mirroring how {@code EntityClassRegister} is discovered) once, and resolves the {@link
* DtoMapper} for a given (source, dto) pair, or by the generated mapper's own concrete type, on
* request.
* <p>
* Has no dependency on {@link Database} - it can be constructed independently, before (or
* without) a {@code Database} existing at all, e.g. as a DI-managed singleton constructed
* alongside the rest of an application's dependency graph. If you want the exact same instance
* (and hence the exact same underlying mapper instances) shared between {@code query.mapTo(...)}
* and your own application code, construct it yourself and register it via {@code
* DatabaseBuilder.putServiceObject(DtoMapperManager.class, myManager)} before building the {@code
* Database} - it is then used instead of a Database-internal default instance.
* <p>
* Resolved mappers are cached so that repeated lookups only ever pay the cost of iterating the
* generated registers and constructing the mapper (and its nested mapper/{@code FetchGroup}
* graph) once - after that, every lookup is a single hash-map hit regardless of how many entity/
* DTO pairs are registered.
*/
public final class DtoMapperManager {
private final List<DtoMapperRegister> registers;
private final ConcurrentHashMap<MapperKey, DtoMapper<?, ?>> pairCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Class<?>, Object> typeCache = new ConcurrentHashMap<>();
public DtoMapperManager() {
this.registers = load();
}
private static List<DtoMapperRegister> load() {
List<DtoMapperRegister> result = new ArrayList<>();
for (DtoMapperRegister register : ServiceLoader.load(DtoMapperRegister.class)) {
result.add(register);
}
return result;
}
/**
* Return the {@link DtoMapper} for the given (source, dto) pair.
*
* @throws PersistenceException if no generated mapper is registered for that pair.
*/
@SuppressWarnings("unchecked")
public <S, D> DtoMapper<S, D> mapperFor(Class<S> sourceType, Class<D> dtoType) {
return (DtoMapper<S, D>) pairCache.computeIfAbsent(new MapperKey(sourceType, dtoType), this::resolve);
}
/**
* Return the generated mapper instance of the given concrete mapper type - e.g. {@code
* manager.get(CustomerDtoMapper.class)} - typically used to resolve a mapper instance for
* dependency injection into application code (e.g. an avaje-inject {@code @Factory} bean
* method).
*
* @throws PersistenceException if no generated mapper of that type is registered.
*/
@SuppressWarnings("unchecked")
public <T> T get(Class<T> mapperType) {
return (T) typeCache.computeIfAbsent(mapperType, this::resolveByType);
}
private DtoMapper<?, ?> resolve(MapperKey key) {
for (DtoMapperRegister register : registers) {
DtoMapper<?, ?> mapper = register.mapperFor(key.sourceType, key.dtoType);
if (mapper != null) {
return mapper;
}
}
throw new PersistenceException("No DtoMapper registered mapping " + key.sourceType + " -> " + key.dtoType
+ " - check @DtoMapping(source = " + key.sourceType.getSimpleName() + ".class, target = "
+ key.dtoType.getSimpleName() + ".class) is declared on a package-info.java processed by querybean-generator");
}
private Object resolveByType(Class<?> mapperType) {
for (DtoMapperRegister register : registers) {
Object mapper = register.mapperOfType(mapperType);
if (mapper != null) {
return mapper;
}
}
throw new PersistenceException("No DtoMapper of type " + mapperType.getName() + " registered"
+ " - check a @DtoMapping(...) pair generating " + mapperType.getSimpleName()
+ " is declared on a package-info.java processed by querybean-generator");
}
/**
* Cache key pairing the source entity type and target DTO type.
*/
private static final class MapperKey {
private final Class<?> sourceType;
private final Class<?> dtoType;
MapperKey(Class<?> sourceType, Class<?> dtoType) {
this.sourceType = sourceType;
this.dtoType = dtoType;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof MapperKey)) {
return false;
}
MapperKey other = (MapperKey) o;
return sourceType == other.sourceType && dtoType == other.dtoType;
}
@Override
public int hashCode() {
return 31 * sourceType.hashCode() + dtoType.hashCode();
}
}
}
@@ -103,6 +103,29 @@ public interface ExpressionList<T> {
*/
<D> DtoQuery<D> asDto(Class<D> dtoClass);
/**
* Map the query result to a nested DTO graph, automatically deriving the select()/fetch() spec
* from the target DTO's declared shape and forcing {@code setUnmodifiable(true)}.
* <p>
* Distinct from {@link #asDto(Class)} (the flat, single-row SQL pipeline) - this supports
* nested ToOne/ToMany DTO graphs, mapped from the normal ORM entity query result.
*
* @throws jakarta.persistence.PersistenceException if no generated {@link DtoMapper} is
* registered for this (entity, dto) pair.
*/
<D> MappedQuery<D> mapTo(Class<D> dtoType);
/**
* Map the query result to a nested DTO graph using an already-resolved {@link DtoMapper}
* instance, rather than looking one up by (entity, dtoType) - e.g. to select a named variant
* mapper (see {@code @DtoMapping(name = "...", exclude = "...")}), such as
* {@code query.mapTo(User.class, userMapper.noFleets())}.
*
* @param dtoType the DTO type mapped to (must match {@code mapper}'s target type)
* @param mapper the mapper instance to use, e.g. a named variant accessor on a generated mapper
*/
<D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper);
/**
* Return the underlying query as an UpdateQuery.
* <p>
@@ -0,0 +1,92 @@
package io.ebean;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.sql.Connection;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
/**
* Query that maps an entity graph query result to a nested DTO graph, produced by
* {@code query.mapTo(SomeDto.class)}.
* <p>
* Distinct from the existing flat, single-row {@link DtoQuery} pipeline (see {@link
* QueryBuilder#asDto(Class)}) - this executes the underlying entity ORM query (with the
* select()/fetch() spec automatically derived from the target DTO's declared shape, see
* {@link DtoMapper#fetchGroup()}), forces {@code setUnmodifiable(true)}, and then maps the
* resulting (unmodifiable) entity graph into a DTO graph via the generated {@link DtoMapper},
* supporting nested ToOne/ToMany and identity-aware de-duplication.
*
* @param <D> the target DTO type
*/
@NullMarked
public interface MappedQuery<D> {
/**
* Execute the query returning the mapped DTO list.
*/
List<D> findList();
/**
* Execute the query returning a paged list of mapped DTOs.
* <p>
* Mirrors {@code Query#findPagedList()} - the underlying entity graph query is paged (via
* {@code setFirstRow(int)}/{@code setMaxRows(int)}) and executed as normal, then each page's
* result is mapped to the target DTO graph. Row-count/page-index metadata
* ({@link PagedList#getTotalCount()}, {@link PagedList#hasNext()}, etc.) reflects the
* underlying entity query and is unaffected by the DTO mapping.
*/
PagedList<D> findPagedList();
/**
* Execute the query returning the result as a Stream of mapped DTOs.
* <p>
* Mirrors {@link QueryBuilder#findStream()} - the underlying entity graph query is streamed
* (supporting very large queries iterating any number of results, potentially using multiple
* persistence contexts internally) and each entity is mapped to its target DTO lazily as the
* stream is consumed, sharing one {@link DtoMapContext} across the whole stream so that
* repeated references to the same source entity still de-duplicate to the same DTO instance.
* <pre>{@code
*
* // use try with resources to ensure Stream is closed
*
* try (Stream<CustomerDto> stream = query.mapTo(CustomerDto.class).findStream()) {
* stream
* .map(...)
* .collect(...);
* }
*
* }</pre>
*/
Stream<D> findStream();
/**
* Execute the query returning a single mapped DTO, or {@code null} if there is no matching row.
*/
@Nullable
D findOne();
/**
* Execute the query returning an optional mapped DTO.
*/
Optional<D> findOneOrEmpty();
/**
* Ensure the master DataSource is used when useMaster is true. Otherwise, the read only
* data source can be used if defined.
*/
MappedQuery<D> usingMaster(boolean useMaster);
/**
* Use the explicit transaction to execute the query.
*/
MappedQuery<D> usingTransaction(Transaction transaction);
/**
* Execute the query using the given connection.
*/
MappedQuery<D> usingConnection(Connection connection);
}
@@ -77,6 +77,32 @@ public interface QueryBuilder<SELF extends QueryBuilder<SELF, T>, T> extends Que
*/
<D> DtoQuery<D> asDto(Class<D> dtoClass);
/**
* Map the query result to a nested DTO graph, automatically deriving the select()/fetch() spec
* from the target DTO's declared shape and forcing {@code setUnmodifiable(true)}.
* <p>
* Distinct from {@link #asDto(Class)} (the flat, single-row SQL pipeline) - this supports
* nested ToOne/ToMany DTO graphs, mapped from the normal ORM entity query result.
*
* @throws jakarta.persistence.PersistenceException if no generated {@link DtoMapper} is
* registered for this (entity, dto) pair.
*/
<D> MappedQuery<D> mapTo(Class<D> dtoType);
/**
* Map the query result to a nested DTO graph using an already-resolved {@link DtoMapper}
* instance, rather than looking one up by (entity, dtoType). Bypasses {@link DtoMapperManager}
* entirely, so it's the way to select a named variant mapper (see {@code @DtoMapping(name =
* "...", exclude = "...")}) - e.g. {@code query.mapTo(User.class, userMapper.noFleets())}.
* <p>
* Also forces {@code setUnmodifiable(true)} and derives the select()/fetch() spec from
* {@code mapper.fetchGroup()}, same as {@link #mapTo(Class)}.
*
* @param dtoType the DTO type mapped to (must match {@code mapper}'s target type)
* @param mapper the mapper instance to use, e.g. a named variant accessor on a generated mapper
*/
<D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper);
/**
* Convert the query to a UpdateQuery.
* <p>
@@ -0,0 +1,31 @@
package io.ebean.config;
import io.ebean.DtoMapper;
/**
* Loads and returns the {@link DtoMapper} to use for a given DTO type, generated per-module by
* the querybean-generator annotation processor for each {@code io.ebean.annotation.DtoMapping}
* registered pair.
* <p>
* Implementations resolve purely via literal {@code Class} comparisons (no reflection,
* {@code Class.forName}, or {@code MethodHandles}) - safe under GraalVM native-image with zero
* additional reachability metadata - mirroring {@link EntityClassRegister}.
*/
public interface DtoMapperRegister {
/**
* Return the mapper for the given DTO type, or {@code null} if this register has no mapper for
* that type.
*/
<SOURCE,TARGET> DtoMapper<SOURCE, TARGET> mapperFor(Class<SOURCE> sourceType, Class<TARGET> targetType);
/**
* Return the mapper instance of the given concrete generated mapper type, or {@code null} if
* this register has no mapper of that type.
* <p>
* An alternative to {@link #mapperFor(Class, Class)} for looking up a mapper by its own class
* (e.g. {@code CustomerDtoMapper.class}) rather than by its (source, target) pair - typically
* used to resolve a mapper instance for dependency injection into application code.
*/
<T> T mapperOfType(Class<T> mapperType);
}
@@ -294,6 +294,16 @@ public interface SpiEbeanServer extends SpiServer, BeanCollectionLoader {
*/
<D> DtoQuery<D> findDto(Class<D> dtoType, SpiQuery<?> ormQuery);
/**
* Return the generated {@link DtoMapper} for mapping the given source entity type to the given
* DTO type, discovered (via {@code ServiceLoader}) from the {@code DtoMapperRegister}s
* generated by {@code querybean-generator} - used by {@code query.mapTo(dtoType)}.
*
* @throws jakarta.persistence.PersistenceException if no mapper is registered for that
* (source, dto) pair.
*/
<S, D> DtoMapper<S, D> dtoMapper(Class<S> sourceType, Class<D> dtoType);
/**
* Execute the underlying ORM query returning as a JDBC ResultSet to map to DTO beans.
*/
@@ -12,6 +12,7 @@ public final class SpiExpressionValidation {
private final BeanType<?> desc;
private final LinkedHashSet<String> unknown = new LinkedHashSet<>();
private final LinkedHashSet<String> all = new LinkedHashSet<>();
public SpiExpressionValidation(BeanType<?> desc) {
this.desc = desc;
@@ -21,6 +22,7 @@ public final class SpiExpressionValidation {
* Validate that the property expression (path) is valid.
*/
public void validate(String propertyName) {
all.add(propertyName);
if (!desc.isValidExpression(propertyName)) {
unknown.add(propertyName);
}
@@ -33,4 +35,14 @@ public final class SpiExpressionValidation {
return unknown;
}
/**
* Return the set of all property names visited during this validation, regardless of
* whether they were considered valid against the bean type. Used to inspect the shape of
* an expression (for example, to check whether it references any associated/joined path)
* without needing a correctly-typed bean descriptor.
*/
public Set<String> allProperties() {
return all;
}
}
@@ -90,6 +90,7 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
private final DtoQueryEngine dtoQueryEngine;
private final ServerCacheManager serverCacheManager;
private final DtoBeanManager dtoBeanManager;
private final DtoMapperManager dtoMapperManager;
private final BeanDescriptorManager descriptorManager;
private final AutoTuneService autoTuneService;
private final ReadAuditPrepare readAuditPrepare;
@@ -122,6 +123,7 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
public DefaultServer(InternalConfiguration config, ServerCacheManager cache) {
this.logManager = config.getLogManager();
this.dtoBeanManager = config.getDtoBeanManager();
this.dtoMapperManager = config.getDtoMapperManager();
this.config = config.getConfig();
this.disableL2Cache = this.config.isDisableL2Cache();
this.serverCacheManager = cache;
@@ -899,6 +901,11 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
return new DefaultDtoQuery<>(this, descriptor, ormQuery);
}
@Override
public <S, D> DtoMapper<S, D> dtoMapper(Class<S> sourceType, Class<D> dtoType) {
return dtoMapperManager.mapperFor(sourceType, dtoType);
}
@Override
public SpiResultSet findResultSet(SpiQuery<?> ormQuery) {
SpiOrmQueryRequest<?> request = createQueryRequest(ormQuery.type(), ormQuery);
@@ -2,6 +2,7 @@ package io.ebeaninternal.server.core;
import io.avaje.json.stream.JsonStream;
import io.ebean.DatabaseBuilder;
import io.ebean.DtoMapperManager;
import io.ebean.ExpressionFactory;
import io.ebean.annotation.Platform;
import io.ebean.cache.*;
@@ -76,6 +77,7 @@ public final class InternalConfiguration {
private final DeployInherit deployInherit;
private final TypeManager typeManager;
private final DtoBeanManager dtoBeanManager;
private final DtoMapperManager dtoMapperManager;
private final Clock clock;
private final DataTimeZone dataTimeZone;
private final Binder binder;
@@ -125,6 +127,7 @@ public final class InternalConfiguration {
final InternalConfigXmlMap xmlMap = initExternalMapping();
this.dtoBeanManager = new DtoBeanManager(typeManager, xmlMap.readDtoMapping());
this.dtoMapperManager = initDtoMapperManager();
this.dataSourceSupplier = createDataSourceSupplier();
this.beanDescriptorManager = new BeanDescriptorManager(this);
Map<String, String> asOfTableMapping = beanDescriptorManager.deploy(xmlMap.xmlDeployment());
@@ -153,6 +156,17 @@ public final class InternalConfiguration {
}
}
/**
* Use an application-provided {@link DtoMapperManager} (registered via {@code
* config.putServiceObject(DtoMapperManager.class, ...)} before building the Database) if
* present, so the exact same instance (and hence the same underlying mapper instances) can be
* shared between {@code query.mapTo(...)} and application code, otherwise construct a default.
*/
private DtoMapperManager initDtoMapperManager() {
DtoMapperManager manager = config.getServiceObject(DtoMapperManager.class);
return manager != null ? manager : new DtoMapperManager();
}
private List<XmapEbean> readExternalMapping() {
final XmapService xmapService = service(XmapService.class);
if (xmapService == null) {
@@ -520,6 +534,10 @@ public final class InternalConfiguration {
return dtoBeanManager;
}
DtoMapperManager getDtoMapperManager() {
return dtoMapperManager;
}
SpiLogManager getLogManager() {
return logManager;
}
@@ -181,4 +181,11 @@ public interface DbSqlContext {
* Include the filter many predicates if specified into the JOIN clause.
*/
void includeFilterMany();
/**
* Return true if the given fetch path (relative to the query root) is the exact join clause
* that the pending filterMany predicate must be attached to - i.e. the deepest path the
* filterMany expression itself references.
*/
boolean isFilterManyAttachPoint(String prefix);
}
@@ -254,17 +254,27 @@ public class DefaultExpressionList<T> implements SpiExpressionList<T> {
@Override
public Query<T> asOf(Timestamp asOf) {
return query.asOf(asOf);
return query().asOf(asOf);
}
@Override
public Query<T> asDraft() {
return query.asDraft();
return query().asDraft();
}
@Override
public <D> DtoQuery<D> asDto(Class<D> dtoClass) {
return query.asDto(dtoClass);
return query().asDto(dtoClass);
}
@Override
public <D> MappedQuery<D> mapTo(Class<D> dtoType) {
return query().mapTo(dtoType);
}
@Override
public <D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper) {
return query().mapTo(dtoType, mapper);
}
@Override
@@ -367,6 +367,16 @@ final class JunctionExpression<T> implements SpiJunction<T>, SpiExpression, Expr
return exprList.asDto(dtoClass);
}
@Override
public <D> MappedQuery<D> mapTo(Class<D> dtoType) {
return exprList.mapTo(dtoType);
}
@Override
public <D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper) {
return exprList.mapTo(dtoType, mapper);
}
@Override
public UpdateQuery<T> asUpdate() {
return exprList.asUpdate();
@@ -78,6 +78,11 @@ public final class CQueryPredicates {
*/
private Set<String> predicateIncludes;
private Set<String> orderByIncludes;
/**
* The fetch path (relative to the query root) of the many-root whose own join clause the
* filterMany-in-JOIN predicate is attached to
*/
private String filterManyAttachPath;
CQueryPredicates(Binder binder, OrmQueryRequest<?> request) {
this.binder = binder;
@@ -222,6 +227,8 @@ public final class CQueryPredicates {
filterMany = new DefaultExpressionRequest(request, deployParser, binder, filterManyExpr);
if (buildSql) {
dbFilterMany = filterMany.buildSql();
// safe as filterManyJoin only holds when the expression is root-property only -
filterManyAttachPath = manyProperty.path();
}
}
}
@@ -399,6 +406,14 @@ public final class CQueryPredicates {
return filterManyJoin ? dbFilterMany : null;
}
/**
* Return the fetch path of the filterMany-in-JOIN predicate - the path whose own join clause
* the predicate must be appended to (or null if there is no filterMany-in-JOIN predicate at all).
*/
String filterManyAttachPath() {
return filterManyJoin ? filterManyAttachPath : null;
}
/**
* Return the db column version of the order by clause.
*/
@@ -23,6 +23,7 @@ final class DefaultDbSqlContext implements DbSqlContext {
private final ArrayStack<String> prefixStack = new ArrayStack<>();
private final String fromForUpdate;
private final String dbFilterManyJoin;
private final String filterManyAttachPath;
private boolean useColumnAlias;
private int columnIndex;
private int asOfTableCount;
@@ -42,7 +43,8 @@ final class DefaultDbSqlContext implements DbSqlContext {
private boolean joinSuppressed;
DefaultDbSqlContext(SqlTreeAlias alias, String columnAliasPrefix, CQueryHistorySupport historySupport,
CQueryDraftSupport draftSupport, String fromForUpdate, String dbFilterManyJoin) {
CQueryDraftSupport draftSupport, String fromForUpdate, String dbFilterManyJoin,
String filterManyAttachPath) {
this.alias = alias;
this.columnAliasPrefix = columnAliasPrefix;
this.useColumnAlias = columnAliasPrefix != null;
@@ -51,6 +53,12 @@ final class DefaultDbSqlContext implements DbSqlContext {
this.historyQuery = (historySupport != null);
this.fromForUpdate = fromForUpdate;
this.dbFilterManyJoin = dbFilterManyJoin;
this.filterManyAttachPath = filterManyAttachPath;
}
@Override
public boolean isFilterManyAttachPoint(String prefix) {
return dbFilterManyJoin != null && filterManyAttachPath != null && filterManyAttachPath.equals(prefix);
}
@Override
@@ -149,6 +149,16 @@ final class DefaultFetchGroupQuery<T> implements SpiFetchGroupQuery<T>, SpiQuery
throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup");
}
@Override
public <D> MappedQuery<D> mapTo(Class<D> dtoType) {
throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup");
}
@Override
public <D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper) {
throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup");
}
@Override
public UpdateQuery<T> asUpdate() {
throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup");
@@ -108,7 +108,7 @@ public final class SqlTreeBuilder {
CQueryHistorySupport historySupport = builder.historySupport(query);
CQueryDraftSupport draftSupport = builder.draftSupport(query);
String colAlias = subQuery || rootNode.isSingleProperty() ? null : columnAliasPrefix;
this.ctx = new DefaultDbSqlContext(alias, colAlias, historySupport, draftSupport, fromForUpdate, predicates.dbFilterManyJoin());
this.ctx = new DefaultDbSqlContext(alias, colAlias, historySupport, draftSupport, fromForUpdate, predicates.dbFilterManyJoin(), predicates.filterManyAttachPath());
}
/**
@@ -342,6 +342,10 @@ class SqlTreeNodeBean implements SqlTreeNode {
if (desc.isSoftDelete() && temporalMode != SpiQuery.TemporalMode.SOFT_DELETED) {
ctx.append(" and ").append(desc.softDeletePredicate(ctx.tableAlias(prefix)));
}
if (prefix != null && ctx.isFilterManyAttachPoint(prefix)) {
// this node is where we inline the filterMany predicate
ctx.includeFilterMany();
}
return sqlJoinType;
}
@@ -45,6 +45,5 @@ final class SqlTreeNodeManyRoot extends SqlTreeNodeBean {
@Override
public void appendFrom(DbSqlContext ctx, SqlJoinType joinType) {
super.appendFrom(ctx, joinType.autoToOuter());
ctx.includeFilterMany();
}
}
@@ -0,0 +1,123 @@
package io.ebeaninternal.server.querydefn;
import io.ebean.DtoMapContext;
import io.ebean.DtoMapper;
import io.ebean.MappedQuery;
import io.ebean.PagedList;
import io.ebean.Transaction;
import io.ebeaninternal.api.SpiEbeanServer;
import io.ebeaninternal.api.SpiQuery;
import java.sql.Connection;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
/**
* Default implementation of {@link MappedQuery} backing {@code query.mapTo(dtoType)}.
* <p>
* Resolves the generated {@link DtoMapper} for the query's (entity, dto) pair, applies its
* {@link DtoMapper#fetchGroup()} (derived from the target DTO's declared shape) - unless the
* caller has already specified their own {@code select()}/{@code fetch()} spec, in which case
* that manual spec is left untouched - and forces {@code setUnmodifiable(true)} on the
* underlying query before it is executed, then maps the resulting entity graph into the
* target DTO graph.
*/
public final class DefaultMappedQuery<T, D> implements MappedQuery<D> {
private final SpiEbeanServer server;
private final SpiQuery<T> query;
private final Class<D> dtoType;
private DtoMapper<T, D> mapper;
private boolean applied;
public DefaultMappedQuery(SpiEbeanServer server, SpiQuery<T> query, Class<D> dtoType) {
this.server = server;
this.query = query;
this.dtoType = dtoType;
}
/**
* Use an already-resolved mapper instance directly - e.g. a named variant accessor on a
* generated mapper, such as {@code query.mapTo(User.class, userMapper.noFleets())} - bypassing
* {@code server.dtoMapper(...)} lookup entirely.
*/
public DefaultMappedQuery(SpiEbeanServer server, SpiQuery<T> query, Class<D> dtoType, DtoMapper<T, D> mapper) {
this.server = server;
this.query = query;
this.dtoType = dtoType;
this.mapper = mapper;
}
/**
* Apply the mapper's fetch spec + unmodifiable to the underlying query (once) - must happen
* before the query is executed. Uses an explicit {@code applied} flag rather than a
* {@code mapper == null} check since a pre-supplied mapper (see the constructor above) is
* already non-null before the fetch spec has been applied.
*/
private DtoMapper<T, D> mapper() {
if (!applied) {
if (mapper == null) {
mapper = server.dtoMapper(query.getBeanType(), dtoType);
}
if (query.detail().isEmpty()) {
// only apply the mapper's derived fetch spec if the caller hasn't already specified
// their own select()/fetch() - allowing manual query tuning/optimisation to take
// precedence over the DTO shape's default fetch spec when needed
query.select(mapper.fetchGroup());
}
query.setUnmodifiable(true);
applied = true;
}
return mapper;
}
@Override
public List<D> findList() {
DtoMapper<T, D> m = mapper();
return m.mapList(query.findList());
}
@Override
public D findOne() {
DtoMapper<T, D> m = mapper();
return m.map(query.findOne());
}
@Override
public Optional<D> findOneOrEmpty() {
return Optional.ofNullable(findOne());
}
@Override
public PagedList<D> findPagedList() {
DtoMapper<T, D> m = mapper();
return new MappedPagedList<>(query.findPagedList(), m);
}
@Override
public Stream<D> findStream() {
DtoMapper<T, D> m = mapper();
DtoMapContext context = new DtoMapContext();
return query.findStream().map(source -> m.map(source, context));
}
@Override
public MappedQuery<D> usingMaster(boolean useMaster) {
query.usingMaster(useMaster);
return this;
}
@Override
public MappedQuery<D> usingTransaction(Transaction transaction) {
query.usingTransaction(transaction);
return this;
}
@Override
public MappedQuery<D> usingConnection(Connection connection) {
query.usingConnection(connection);
return this;
}
}
@@ -184,6 +184,16 @@ public class DefaultOrmQuery<T> extends AbstractQuery implements SpiQuery<T> {
return server.findDto(dtoClass, this);
}
@Override
public final <D> MappedQuery<D> mapTo(Class<D> dtoType) {
return new DefaultMappedQuery<>(server, this, dtoType);
}
@Override
public final <D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper) {
return new DefaultMappedQuery<>(server, this, dtoType, mapper);
}
@Override
public final UpdateQuery<T> asUpdate() {
return new DefaultUpdateQuery<>(this);
@@ -0,0 +1,87 @@
package io.ebeaninternal.server.querydefn;
import io.ebean.DtoMapper;
import io.ebean.PagedList;
import java.util.List;
import java.util.concurrent.Future;
import java.util.concurrent.locks.ReentrantLock;
/**
* {@link PagedList} adapter backing {@code query.mapTo(dtoType).findPagedList()}.
* <p>
* Delegates all page metadata (total count, page index, {@code hasNext()}/{@code hasPrev()}, ...)
* straight through to the underlying entity-typed {@link PagedList} unchanged - paging is a
* property of the query, not of the DTO shape. Only {@link #getList()} differs: it maps the
* delegate's entity list to the target DTO list (once, cached) via the given {@link DtoMapper}.
*/
public final class MappedPagedList<T, D> implements PagedList<D> {
private final ReentrantLock lock = new ReentrantLock();
private final PagedList<T> delegate;
private final DtoMapper<T, D> mapper;
private List<D> mappedList;
public MappedPagedList(PagedList<T> delegate, DtoMapper<T, D> mapper) {
this.delegate = delegate;
this.mapper = mapper;
}
@Override
public void loadCount() {
delegate.loadCount();
}
@Override
public Future<Integer> getFutureCount() {
return delegate.getFutureCount();
}
@Override
public List<D> getList() {
lock.lock();
try {
if (mappedList == null) {
mappedList = mapper.mapList(delegate.getList());
}
return mappedList;
} finally {
lock.unlock();
}
}
@Override
public int getTotalCount() {
return delegate.getTotalCount();
}
@Override
public int getTotalPageCount() {
return delegate.getTotalPageCount();
}
@Override
public int getPageSize() {
return delegate.getPageSize();
}
@Override
public int getPageIndex() {
return delegate.getPageIndex();
}
@Override
public boolean hasNext() {
return delegate.hasNext();
}
@Override
public boolean hasPrev() {
return delegate.hasPrev();
}
@Override
public String getDisplayXtoYofZ(String to, String of) {
return delegate.getDisplayXtoYofZ(to, of);
}
}
@@ -384,14 +384,18 @@ public final class OrmQueryDetail implements Serializable {
OrmQueryProperties chunk = pair.getProperties();
if (isQueryJoinCandidate(lazyLoadManyPath, chunk)) {
// this is a 'fetch join' (included in main query)
if (fetchJoinFirstMany) {
BeanDescriptor<?> targetDescriptor = ((BeanPropertyAssoc<?>) elProp.beanProperty()).targetDescriptor();
if (fetchJoinFirstMany && !chunk.filterManyHasNestedProperty(targetDescriptor)) {
// letting the first one remain a 'fetch join'
fetchJoinFirstMany = false;
manyFetchProperty = pair.getPath();
chunk.filterManyInline();
many = elProp;
} else {
// convert this one over to a 'query join'
// convert this one over to a 'query join' - either because another many has already claimed the
// 'fetch join' slot, or because its filterMany references a property that requires crossing into
// an associated bean and can't safely be included as a JOIN predicate (see
// OrmQueryProperties.filterManyHasNestedProperty)
chunk.markForQueryJoin();
}
}
@@ -9,7 +9,10 @@ import io.ebean.util.SplitName;
import io.ebeaninternal.api.SpiExpression;
import io.ebeaninternal.api.SpiExpressionFactory;
import io.ebeaninternal.api.SpiExpressionList;
import io.ebeaninternal.api.SpiExpressionValidation;
import io.ebeaninternal.api.SpiQuery;
import io.ebeaninternal.server.deploy.BeanDescriptor;
import io.ebeaninternal.server.el.ElPropertyValue;
import io.ebeaninternal.server.expression.FilterExprPath;
import io.ebeaninternal.server.expression.FilterExpressionList;
@@ -234,6 +237,26 @@ public final class OrmQueryProperties implements Serializable {
return filterMany != null && !markForQueryJoin;
}
/**
* Return true if the filterMany expression (if any) references a property that requires
* crossing into an associated bean/join - e.g. {@code "group.name"} - rather than only
* plain/embedded properties resolving to columns on the many bean's own base table.
*/
boolean filterManyHasNestedProperty(BeanDescriptor<?> targetDescriptor) {
if (filterMany == null) {
return false;
}
SpiExpressionValidation validation = new SpiExpressionValidation(targetDescriptor);
filterMany.validate(validation);
for (String property : validation.allProperties()) {
ElPropertyValue elProp = targetDescriptor.elGetValue(property);
if (elProp != null && elProp.isAssocProperty()) {
return true;
}
}
return false;
}
/**
* Adjust filterMany expressions for inclusion in main query.
*/
@@ -435,6 +435,16 @@ public abstract class QueryBean<T, R extends QueryBean<T, R>> implements IQueryB
return query.asDto(dtoClass);
}
@Override
public final <D> MappedQuery<D> mapTo(Class<D> dtoType) {
return query.mapTo(dtoType);
}
@Override
public final <D> MappedQuery<D> mapTo(Class<D> dtoType, DtoMapper<T, D> mapper) {
return query.mapTo(dtoType, mapper);
}
@Override
public final R setId(Object id) {
query.setId(id);
@@ -0,0 +1,37 @@
package io.ebean.redis;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import redis.clients.jedis.Jedis;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Auto-detected JUnit5 extension (see {@code junit-platform.properties}) that flushes the shared
* Redis test container exactly once per JVM, before the first test class in this module runs.
*
* <p>The Redis test container is intentionally long-lived and reused across test runs - and even
* shared with the sibling ebean-redisson module (same fixed-name/fixed-port container) - to avoid
* restart cost, especially with parallel reactor builds. It is never flushed between runs. Without
* this, stale keys/counters left over from an earlier run can leak into hit/miss/TTL assertions
* that assume a cold cache, causing hard-to-reproduce flakiness.
*/
public class RedisFlushExtension implements BeforeAllCallback {
private static final AtomicBoolean FLUSHED = new AtomicBoolean();
@Override
public void beforeAll(ExtensionContext context) {
if (FLUSHED.compareAndSet(false, true)) {
flush();
}
}
private static void flush() {
try (Jedis jedis = new Jedis("localhost", 6379)) {
jedis.flushDB();
} catch (Exception e) {
// best effort - if Redis isn't reachable here, individual tests skip via their own checks
}
}
}
@@ -0,0 +1 @@
io.ebean.redis.RedisFlushExtension
@@ -0,0 +1 @@
junit.jupiter.extensions.autodetection.enabled=true
@@ -0,0 +1,55 @@
package io.ebean.redisson;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.io.InputStream;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Auto-detected JUnit5 extension (see {@code junit-platform.properties}) that flushes the shared
* Redis test container exactly once per JVM, before the first test class in this module runs.
*
* <p>The Redis test container is intentionally long-lived and reused across test runs - and even
* shared with the sibling ebean-redis module (same fixed-name/fixed-port container) - to avoid
* restart cost, especially with parallel reactor builds. It is never flushed between runs. Without
* this, stale keys/counters left over from an earlier run can leak into hit/miss/TTL assertions
* that assume a cold cache, causing hard-to-reproduce flakiness.
*/
public class RedisFlushExtension implements BeforeAllCallback {
private static final AtomicBoolean FLUSHED = new AtomicBoolean();
@Override
public void beforeAll(ExtensionContext context) {
if (FLUSHED.compareAndSet(false, true)) {
flush();
}
}
private static void flush() {
try {
RedissonClient client = Redisson.create(loadConfig());
try {
client.getKeys().flushdb();
} finally {
client.shutdown();
}
} catch (Exception e) {
// best effort - if Redis isn't reachable here, individual tests skip via their own checks
}
}
private static Config loadConfig() {
InputStream is = RedisFlushExtension.class.getClassLoader().getResourceAsStream("redisson-config.yaml");
if (is != null) {
return Config.fromYAML(is);
}
Config cfg = new Config();
cfg.useSingleServer().setAddress("redis://localhost:6379");
return cfg;
}
}
@@ -132,9 +132,20 @@ class RedissonCacheFactoryTest {
ServerCacheNotify notify = factory.createCacheNotify(n -> {});
notify.notify(new ServerCacheNotification(Set.of("orders", "items")));
Thread.sleep(500);
assertThat(received).isNotEmpty();
assertThat(received.get(0).getDependentTables()).contains("orders", "items");
// Wait (with polling rather than a single fixed sleep) for the expected notification to
// arrive. The shared "ebean.l2cache" topic is global/unscoped so other factories/tests in
// the same JVM (e.g. real entity saves elsewhere) can publish unrelated notifications on it
// around the same time - assert on content rather than position/count to stay robust to that.
long deadline = System.currentTimeMillis() + 2000;
boolean found = false;
while (System.currentTimeMillis() < deadline) {
found = received.stream().anyMatch(n -> n.getDependentTables().containsAll(Set.of("orders", "items")));
if (found) break;
Thread.sleep(50);
}
assertThat(found)
.as("Expected a notification with dependent tables [orders, items] in %s", received)
.isTrue();
}
@Test
@@ -0,0 +1 @@
io.ebean.redisson.RedisFlushExtension
@@ -0,0 +1 @@
junit.jupiter.extensions.autodetection.enabled=true
@@ -454,6 +454,11 @@ public class TDSpiEbeanServer extends TDSpiServer implements SpiEbeanServer {
return null;
}
@Override
public <S, D> DtoMapper<S, D> dtoMapper(Class<S> sourceType, Class<D> dtoType) {
return null;
}
@Override
public SpiResultSet findResultSet(SpiQuery<?> ormQuery) {
return null;
@@ -174,7 +174,7 @@ public class TestQueryFilterMany extends BaseTestCase {
assertThat(customers).isNotEmpty();
List<String> sqlList = LoggedSql.stop();
assertEquals(1, sqlList.size());
assertThat(sqlList.get(0)).contains(" left join o_customer t2 on t2.id = t1.kcustomer_id and t1.status = ? where ");
assertThat(sqlList.get(0)).contains(" left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null and t1.status = ? left join o_customer t2 on t2.id = t1.kcustomer_id where ");
assertThat(sqlList.get(0)).contains(" where lower(t0.name) = ? order by t0.id");
}
@@ -211,7 +211,7 @@ public class TestQueryFilterMany extends BaseTestCase {
assertThat(result).isNotEmpty();
List<String> sql = LoggedSql.stop();
assertThat(sql).hasSize(1);
assertThat(sql.get(0)).contains("from o_customer t0 left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null left join o_customer t2 on t2.id = t1.kcustomer_id and t1.order_date is not null order by t0.id");
assertThat(sql.get(0)).contains("from o_customer t0 left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null and t1.order_date is not null left join o_customer t2 on t2.id = t1.kcustomer_id order by t0.id");
}
@Test
@@ -340,9 +340,9 @@ public class TestQueryFilterMany extends BaseTestCase {
List<String> sqlList = LoggedSql.stop();
assertEquals(1, sqlList.size());
if (isPostgresCompatible()) {
assertThat(sqlList.get(0)).contains("left join o_customer t2 on t2.id = t1.kcustomer_id and t1.status = any(?) order by t0.id");
assertThat(sqlList.get(0)).contains("left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null and t1.status = any(?) left join o_customer t2 on t2.id = t1.kcustomer_id order by t0.id");
} else {
assertThat(sqlList.get(0)).contains("left join o_customer t2 on t2.id = t1.kcustomer_id and t1.status in (?) order by t0.id");
assertThat(sqlList.get(0)).contains("left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null and t1.status in (?) left join o_customer t2 on t2.id = t1.kcustomer_id order by t0.id");
}
}
@@ -386,7 +386,7 @@ public class TestQueryFilterMany extends BaseTestCase {
List<String> sql = LoggedSql.stop();
assertEquals(1, sql.size());
assertSql(sql.get(0)).contains(" left join o_customer t2 on t2.id = t1.kcustomer_id and (t1.status = ? or t1.order_date = ?) order by t0.id");
assertSql(sql.get(0)).contains(" left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null and (t1.status = ? or t1.order_date = ?) left join o_customer t2 on t2.id = t1.kcustomer_id order by t0.id");
}
@Test
@@ -459,7 +459,10 @@ public class TestQueryFilterMany extends BaseTestCase {
List<String> sql = LoggedSql.stop();
assertThat(sql).hasSize(1);
assertSql(sql.get(0)).contains(" from o_customer t0 left join contact t1 on t1.customer_id = t0.id left join contact_group t2 on t2.id = t1.group_id and t2.name = ? and t1.cretime is not null order by t0.id");
// nested "group.name" reference forces this to a query join so the filter is applied
// as a genuine WHERE clause (not misapplied to a LEFT JOIN's ON clause)
assertThat(sql).hasSize(2);
assertSql(sql.get(1)).contains(" from contact t0 left join contact_group t1 on t1.id = t0.group_id where (t0.customer_id) in (");
assertSql(sql.get(1)).contains(" and t1.name = ? and t0.cretime is not null");
}
}
@@ -35,7 +35,7 @@ public class TestQueryFilterManySimple extends BaseTestCase {
list.get(0).getOrders().size();
List<String> sql = LoggedSql.stop();
assertThat(sql).hasSize(2);
assertThat(sql.get(0)).contains("left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null left join o_customer t2 on t2.id = t1.kcustomer_id and t1.status = ? and t1.order_date > ?");
assertThat(sql.get(0)).contains("left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null and t1.status = ? and t1.order_date > ? left join o_customer t2 on t2.id = t1.kcustomer_id");
assertThat(sql.get(0)).contains("order by t0.id");
if (isPostgresCompatible()) {
assertThat(sql.get(1)).contains("from contact t0 where (t0.customer_id) = any(?) and t0.first_name is not null;");
@@ -55,7 +55,10 @@ public class TestQueryFilterManySimple extends BaseTestCase {
.findList();
List<String> sql = LoggedSql.stop();
assertThat(sql).hasSize(1);
assertThat(sql.get(0)).contains("from o_customer t0 left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null left join o_customer t2 on t2.id = t1.kcustomer_id and t2.status = ? order by t0.id");
// nested "customer.status" reference forces this to a query join so the filter is
// applied as a genuine WHERE clause (not misapplied to a LEFT JOIN's ON clause)
assertThat(sql).hasSize(2);
assertThat(sql.get(1)).contains("from o_order t0 join o_customer t1 on t1.id = t0.kcustomer_id where t0.order_date is not null and (t0.kcustomer_id) in (");
assertThat(sql.get(1)).contains(" and t1.status = ?");
}
}
@@ -0,0 +1,73 @@
package org.tests.query;
import io.ebean.DB;
import io.ebean.test.LoggedSql;
import io.ebean.xtest.BaseTestCase;
import org.junit.jupiter.api.Test;
import org.tests.model.basic.Contact;
import org.tests.model.basic.Customer;
import org.tests.model.basic.ResetBasicData;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Reproduces a bug where filterMany() on a property of the many-root itself is misapplied
* to the ON clause of a deeper, unrelated nested fetch join instead of the many-root's own join
* clause - when an additional fetch path exists beneath the many property that the filterMany
* expression itself does not reference.
*/
public class TestQueryFilterManyWithDeeperNestedFetch extends BaseTestCase {
@Test
void filterMany_onRootProperty_withUnrelatedDeeperNestedFetch_expectFilterOnOwnJoin() {
ResetBasicData.reset();
Customer customer = DB.find(Customer.class).where().ieq("name", "Rob").findOne();
assertThat(customer).isNotNull();
List<Contact> allContacts = DB.find(Contact.class).where().eq("customer", customer).findList();
assertThat(allContacts).isNotEmpty();
// ensure at least one contact isMember=true and one isMember=false
Contact memberContact = allContacts.get(0);
memberContact.setMember(true);
DB.save(memberContact);
Contact nonMemberContact;
if (allContacts.size() > 1) {
nonMemberContact = allContacts.get(1);
} else {
nonMemberContact = new Contact();
nonMemberContact.setFirstName("Extra");
nonMemberContact.setLastName("NonMember");
nonMemberContact.setCustomer(customer);
}
nonMemberContact.setMember(false);
DB.save(nonMemberContact);
LoggedSql.start();
// filterMany only references "isMember" (a property of Contact - the many-root itself) but
// the query ALSO fetches a further nested path beneath "contacts" (contacts.group) that the
// filterMany expression does NOT reference at all.
List<Customer> found = DB.find(Customer.class)
.setBeanCacheMode(io.ebean.CacheMode.OFF)
.setPersistenceContextScope(io.ebean.PersistenceContextScope.QUERY)
.fetch("contacts", "id,firstName,lastName,isMember")
.fetch("contacts.group", "id,name")
.filterMany("contacts").eq("isMember", true)
.where().idEq(customer.getId())
.findList();
List<String> sql = LoggedSql.stop();
assertThat(found).isNotEmpty();
assertThat(sql).hasSize(1);
// the filterMany predicate must be on contact's own join (t1), not misapplied to the
// unrelated deeper contact_group join (t2)
assertThat(sql.get(0)).contains("left join contact t1 on t1.customer_id = t0.id and t1.is_member = ? left join contact_group t2 on t2.id = t1.group_id");
// every contact returned must be a member - the filter must actually exclude non-members
assertThat(found.get(0).getContacts()).isNotEmpty();
assertThat(found.get(0).getContacts()).allMatch(Contact::isMember);
}
}
@@ -0,0 +1,145 @@
package org.tests.query;
import io.ebean.DB;
import io.ebean.LazyInitialisationException;
import io.ebean.PagedList;
import io.ebean.Paging;
import io.ebean.test.LoggedSql;
import io.ebean.xtest.BaseTestCase;
import org.junit.jupiter.api.Test;
import org.tests.model.basic.Contact;
import org.tests.model.basic.Customer;
import org.tests.model.basic.Order;
import org.tests.model.basic.ResetBasicData;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Validates that fetch strategy (fetchQuery / fetchLazy secondary query hints) and pagination
* (setPaging / findPagedList) carry over correctly onto setUnmodifiable(true) entity graphs.
* <p>
* This is groundwork for issue #2540 (nested DTO mapping) - the plan is to map an unmodifiable
* entity graph into a nested DTO graph, so we need confidence that these existing query features
* continue to behave as expected on that unmodifiable graph.
*/
class TestUnmodifiableFetchStrategyAndPaging extends BaseTestCase {
@Test
void fetchQuery_withUnmodifiable_expectSecondaryQueryEagerlyLoadedAndUnmodifiable() {
ResetBasicData.reset();
LoggedSql.start();
List<Customer> customers = DB.find(Customer.class)
.setUnmodifiable(true)
.select("id,name")
.fetchQuery("contacts", "firstName,lastName")
.orderBy("id")
.findList();
List<String> sql = LoggedSql.stop();
// 1 query for customers + 1 secondary query for contacts (fetchQuery = eager secondary query)
assertThat(sql).hasSize(2);
assertThat(customers).isNotEmpty();
Customer customer = customers.get(0);
assertThat(DB.beanState(customer).isUnmodifiable()).isTrue();
// fetched via fetchQuery so already loaded - accessing it must not throw and must not
// trigger any further lazy loading query
LoggedSql.start();
List<Contact> contacts = customer.getContacts();
assertThat(LoggedSql.stop()).isEmpty();
assertThat(contacts).isNotEmpty();
Contact contact = contacts.get(0);
assertThat(DB.beanState(contact).isUnmodifiable()).isTrue();
assertThatThrownBy(() -> contact.setFirstName("junk"))
.isInstanceOf(io.ebean.UnmodifiableEntityException.class);
}
@Test
void fetchLazy_withUnmodifiable_expectNotEagerlyLoadedAndFailsFastOnAccess() {
ResetBasicData.reset();
LoggedSql.start();
List<Order> orders = DB.find(Order.class)
.setUnmodifiable(true)
.select("status")
.fetchLazy("customer", "name")
.findList();
List<String> sql = LoggedSql.stop();
// fetchLazy defers loading to first access - with disableLazyLoading (via unmodifiable)
// that deferred load can never happen, so only the root query should run
assertThat(sql).hasSize(1);
assertThat(orders).isNotEmpty();
Order order = orders.get(0);
assertThat(DB.beanState(order).isUnmodifiable()).isTrue();
// the *ToOne reference itself (just the foreign key / id) is available without lazy
// loading, so getCustomer() alone does not throw
Customer customerRef = order.getCustomer();
assertThat(customerRef).isNotNull();
assertThat(customerRef.getId()).isNotNull();
// but accessing a property that requires the fetchLazy relation to actually be loaded
// must fail fast rather than silently lazy load
assertThatThrownBy(customerRef::getName)
.isInstanceOf(LazyInitialisationException.class)
.hasMessageContaining("Property not loaded: name");
}
@Test
void setPaging_withUnmodifiable_expectCorrectSqlAndUnmodifiableResults() {
ResetBasicData.reset();
var paging = Paging.of(0, 2, "id");
LoggedSql.start();
List<Customer> customers = DB.find(Customer.class)
.setUnmodifiable(true)
.select("id,name")
.setPaging(paging)
.findList();
List<String> sql = LoggedSql.stop();
assertThat(sql).hasSize(1);
if (isLimitOffset()) {
assertThat(sql.get(0)).contains("order by t0.id limit 2");
}
assertThat(customers).hasSizeLessThanOrEqualTo(2);
for (Customer customer : customers) {
assertThat(DB.beanState(customer).isUnmodifiable()).isTrue();
assertThatThrownBy(() -> customer.setName("junk"))
.isInstanceOf(io.ebean.UnmodifiableEntityException.class);
}
}
@Test
void findPagedList_withUnmodifiable_expectTotalCountAndUnmodifiableResults() {
ResetBasicData.reset();
PagedList<Customer> pagedList = DB.find(Customer.class)
.setUnmodifiable(true)
.select("id,name")
.orderBy("id")
.setFirstRow(0)
.setMaxRows(2)
.findPagedList();
List<Customer> customers = pagedList.getList();
assertThat(customers).hasSizeLessThanOrEqualTo(2);
assertThat(pagedList.getTotalCount()).isGreaterThanOrEqualTo(customers.size());
for (Customer customer : customers) {
assertThat(DB.beanState(customer).isUnmodifiable()).isTrue();
}
}
}
+3 -3
View File
@@ -45,14 +45,14 @@
<ebean-persistence-api.version>3.2</ebean-persistence-api.version>
<ebean-types.version>3.0</ebean-types.version>
<ebean-annotation.version>8.5</ebean-annotation.version>
<ebean-annotation.version>8.6</ebean-annotation.version>
<ebean-ddl-runner.version>2.3</ebean-ddl-runner.version>
<ebean-migration-auto.version>1.2</ebean-migration-auto.version>
<ebean-migration.version>14.3.0</ebean-migration.version>
<ebean-test-containers.version>8.2</ebean-test-containers.version>
<ebean-datasource.version>10.10</ebean-datasource.version>
<ebean-agent.version>18.1.0</ebean-agent.version>
<ebean-maven-plugin.version>18.1.0</ebean-maven-plugin.version>
<ebean-agent.version>18.3.0</ebean-agent.version>
<ebean-maven-plugin.version>18.3.0</ebean-maven-plugin.version>
<surefire.useModulePath>false</surefire.useModulePath>
</properties>
+46 -2
View File
@@ -11,6 +11,45 @@
<artifactId>querybean-generator</artifactId>
<description>Generates Ebean Query beans for type safe ORM query building and execution</description>
<properties>
<avaje.prisms.version>2.0</avaje.prisms.version>
</properties>
<dependencies>
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-prisms</artifactId>
<version>${avaje.prisms.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-annotation</artifactId>
<version>${ebean-annotation.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>jakarta-persistence-api</artifactId>
<version>${ebean-persistence-api.version}</version>
<scope>test</scope>
</dependency>
<!--
Test-only - lets DtoMapperRecordSourceTest fully compile a generated DTO mapper source
in-process (references io.ebean.DtoMapper/FetchGroup/etc.). Safe as a real dependency here
(unlike ebean-querybean) since ebean-api does not depend back on querybean-generator.
-->
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-api</artifactId>
<version>18.2.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
@@ -19,8 +58,13 @@
<configuration>
<source>11</source>
<target>11</target>
<!-- Turn off annotation processing for building -->
<compilerArgs>-proc:none</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>io.avaje</groupId>
<artifactId>avaje-prisms</artifactId>
<version>${avaje.prisms.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
@@ -26,6 +26,18 @@ interface Constants {
String METAINF_MANIFEST = "META-INF/ebean-generated-info.mf";
String METAINF_SERVICES_MODULELOADER = "META-INF/services/io.ebean.config.EntityClassRegister";
String DTO_MAPPING = "io.ebean.annotation.DtoMapping";
// DtoMapping is @Repeatable - when used more than once on the same element javac wraps
// the repeated instances in this synthetic container annotation instead, so it must also
// be registered as a supported annotation type or the processor is never invoked for it.
String DTO_MAPPING_LIST = "io.ebean.annotation.DtoMapping.List";
String DTO_PATH = "io.ebean.annotation.DtoPath";
String DTO_REF = "io.ebean.annotation.DtoRef";
String DTO_CONVERT = "io.ebean.annotation.DtoConvert";
String DTO_MIXIN = "io.ebean.annotation.DtoMixin";
String DTO_CONVERTER_MANAGER = "io.ebean.DtoConverterManager";
String METAINF_SERVICES_DTOMAPPERREGISTER = "META-INF/services/io.ebean.config.DtoMapperRegister";
String AVAJE_LANG_NULLABLE = "org.jspecify.annotations.Nullable";
String JAVA_COLLECTION = "java.util.Collection";
String EXPRESSIONLIST = "io.ebean.ExpressionList";
@@ -0,0 +1,134 @@
package io.ebean.querybean.generator;
import javax.lang.model.element.TypeElement;
import java.util.ArrayList;
import java.util.List;
/**
* Metadata for one {@code @DtoMapping(source, target)} pair - drives generation of a single
* {@code XxxMapper} class implementing {@code io.ebean.DtoMapper<source, target>}.
*/
class DtoBeanMeta {
private final TypeElement source;
private final TypeElement target;
private final String mapperPackage;
private final String mapperShortName;
private final String mapperFullName;
private final List<DtoPropertyMeta> properties = new ArrayList<>();
private final List<DtoMapperVariant> variants = new ArrayList<>();
private DtoBuilderMeta builderMeta;
/** Cycle detection state - see {@link DtoMappingReader#checkForCycles()}. */
enum Visit { WHITE, GRAY, BLACK }
Visit visit = Visit.WHITE;
/**
* Set true if this type is used as a {@code NESTED_ONE}/{@code NESTED_MANY} property by any
* other {@code @DtoMapping} pair - see {@link DtoMappingReader#markNestedElsewhere()}. Drives
* whether {@link DtoMapperWriter} needs to route this mapper's own construction through the
* {@code DtoMapContext} identity cache (only ever useful when the same source instance can be
* reached via more than one path in the graph).
*/
private boolean nestedElsewhere;
DtoBeanMeta(TypeElement source, TypeElement target, String mapperPackage) {
this.source = source;
this.target = target;
this.mapperPackage = mapperPackage;
this.mapperShortName = target.getSimpleName().toString() + "Mapper";
this.mapperFullName = mapperPackage + "." + mapperShortName;
}
TypeElement source() {
return source;
}
TypeElement target() {
return target;
}
String mapperPackage() {
return mapperPackage;
}
String mapperShortName() {
return mapperShortName;
}
String mapperFullName() {
return mapperFullName;
}
String sourceFullName() {
return source.getQualifiedName().toString();
}
String targetFullName() {
return target.getQualifiedName().toString();
}
List<DtoPropertyMeta> properties() {
return properties;
}
void addProperty(DtoPropertyMeta property) {
properties.add(property);
}
List<DtoMapperVariant> variants() {
return variants;
}
void addVariant(DtoMapperVariant variant) {
variants.add(variant);
}
/**
* The detected+selected builder-based construction path for this target, or {@code null} if
* the mapper should construct the target via a plain positional constructor call instead - see
* {@link DtoMappingReader#resolveBuilder(DtoBeanMeta, String)}.
*/
DtoBuilderMeta builderMeta() {
return builderMeta;
}
void builderMeta(DtoBuilderMeta builderMeta) {
this.builderMeta = builderMeta;
}
/**
* Return the distinct instance-dispatch {@code @DtoConvert} converters used by this mapper's
* properties (static-dispatch converters need no field/constructor wiring, so are excluded),
* in first-seen order, deduplicated by converter type - so a converter type used by more than
* one property still results in a single shared constructor parameter/field.
*/
List<DtoConverterMeta> converterDeps() {
List<DtoConverterMeta> deps = new ArrayList<>();
for (DtoPropertyMeta property : properties) {
DtoConverterMeta converter = property.converter();
if (converter != null && !converter.isStatic() && !deps.contains(converter)) {
deps.add(converter);
}
}
return deps;
}
/**
* Return true if this type is nested ({@code NESTED_ONE}/{@code NESTED_MANY}) under some other
* DTO - i.e. the same source instance could be reached via more than one path in the graph, so
* identity de-duplication via {@code DtoMapContext} can actually matter for it.
*/
boolean nestedElsewhere() {
return nestedElsewhere;
}
void markNestedElsewhere() {
this.nestedElsewhere = true;
}
@Override
public String toString() {
return sourceFullName() + " -> " + targetFullName();
}
}
@@ -0,0 +1,31 @@
package io.ebean.querybean.generator;
/**
* Metadata for a detected builder-style construction path for a {@link DtoBeanMeta}'s target -
* see {@code @DtoMapping(builder = ...)}.
* <p>
* Detected via the {@code avaje-recordbuilder}-style convention: a static no-arg
* {@code Target.builder()} factory method returning some builder type, which in turn has a
* fluent (returns-itself), same-named setter method per target property, plus a no-arg
* {@code build()} method returning the target type. When present (and either explicitly
* requested via {@code builder = ALWAYS}, or the target has more properties than the
* auto-detection threshold under {@code builder = AUTO}), the generated mapper constructs the
* target via {@code Target.builder().prop(x)....build()} instead of a positional constructor
* call.
*/
final class DtoBuilderMeta {
private final String builderTypeFullName;
DtoBuilderMeta(String builderTypeFullName) {
this.builderTypeFullName = builderTypeFullName;
}
String builderTypeFullName() {
return builderTypeFullName;
}
String builderTypeShortName() {
return Split.shortName(builderTypeFullName);
}
}
@@ -0,0 +1,70 @@
package io.ebean.querybean.generator;
/**
* Metadata for a single {@code @DtoConvert(value = ConverterType.class, method = "name")}
* property conversion.
* <p>
* Dispatch strategy is decided purely by whether the referenced method is {@code static}:
* <ul>
* <li>{@code static} - the generated mapper emits a direct static call
* ({@code ConverterType.method(source.getX())}), no registration/constructor wiring needed at
* all - for common, reusable, dependency-free conversions.</li>
* <li>instance - the generated mapper resolves one shared instance via
* {@code DtoConverterManager.get(ConverterType.class)}, wired as a constructor
* parameter/field (same shape as nested-mapper constructor injection) - for conversions
* needing a real dependency.</li>
* </ul>
*/
final class DtoConverterMeta {
private final String typeFullName;
private final String methodName;
private final boolean isStatic;
DtoConverterMeta(String typeFullName, String methodName, boolean isStatic) {
this.typeFullName = typeFullName;
this.methodName = methodName;
this.isStatic = isStatic;
}
String typeFullName() {
return typeFullName;
}
String typeShortName() {
return Split.shortName(typeFullName);
}
String methodName() {
return methodName;
}
boolean isStatic() {
return isStatic;
}
/**
* Field/parameter name used to hold the resolved instance for instance-dispatch conversions -
* derived from the converter type's short name, lower-camel-cased.
*/
String fieldName() {
String name = typeShortName();
return Character.toLowerCase(name.charAt(0)) + name.substring(1);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof DtoConverterMeta)) {
return false;
}
return typeFullName.equals(((DtoConverterMeta) o).typeFullName);
}
@Override
public int hashCode() {
return typeFullName.hashCode();
}
}
@@ -0,0 +1,274 @@
package io.ebean.querybean.generator;
import javax.tools.FileObject;
import java.io.IOException;
import java.io.Writer;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Writes the per-module {@code DtoMapperRegister} implementation aggregating all generated DTO
* mappers, and registers it via {@code META-INF/services} - mirroring
* {@link SimpleModuleInfoWriter}'s {@code EbeanEntityRegister} mechanism. Dispatch is via
* literal {@code Class ==} comparisons only (no reflection/{@code Class.forName}), so this is
* the compile-time-safe backing for the {@code query.mapTo(SomeDto.class)} runtime API.
* <p>
* Each generated mapper is held as a single shared instance field on the register (mappers are
* stateless/thread-safe - only direct getter/constructor calls, no per-call state) rather than
* being constructed with {@code new} on every {@code mapperFor()} call - this avoids repeatedly
* rebuilding the nested mapper graph and {@code FetchGroup} on every {@code mapTo(...)} query.
* Fields are declared in dependency order (a mapper's nested mappers are declared, and so
* constructed, before it) so each field initializer can directly reference the already-built
* nested mapper fields it depends on.
*/
class DtoMapperRegisterWriter {
private static final String SHORT_NAME = "EbeanDtoMapperRegister";
private final ProcessingContext ctx;
private final List<DtoBeanMeta> metas;
private final List<DtoBeanMeta> sortedMetas;
private final String factoryPackage;
private final String factoryFullName;
private Append writer;
DtoMapperRegisterWriter(ProcessingContext ctx, List<DtoBeanMeta> metas) {
this.ctx = ctx;
this.metas = metas;
this.sortedMetas = sortByDependency(metas);
this.factoryPackage = shortestMapperPackage(metas);
this.factoryFullName = factoryPackage + "." + SHORT_NAME;
}
/**
* Order {@code metas} so that every mapper appears after all the (nested) mappers its own
* field initializer depends on - a dependency-first (topological) ordering over the
* {@code NESTED_ONE}/{@code NESTED_MANY} edges. Cross-reference cycles were already detected
* and excluded upstream in {@code DtoMappingReader}, so a simple DFS post-order is sufficient
* (the {@code visited} guard is defensive only).
*/
private static List<DtoBeanMeta> sortByDependency(List<DtoBeanMeta> metas) {
List<DtoBeanMeta> sorted = new ArrayList<>(metas.size());
Set<DtoBeanMeta> visited = new HashSet<>();
for (DtoBeanMeta meta : metas) {
visitForSort(meta, visited, sorted);
}
return sorted;
}
private static void visitForSort(DtoBeanMeta meta, Set<DtoBeanMeta> visited, List<DtoBeanMeta> sorted) {
if (!visited.add(meta)) {
return;
}
for (DtoPropertyMeta property : meta.properties()) {
DtoBeanMeta nested = property.nested();
if (nested != null) {
visitForSort(nested, visited, sorted);
}
}
sorted.add(meta);
}
/**
* Shortest of the (per-pair) mapper packages, used as the single package the aggregating
* {@code EbeanDtoMapperRegister} is written to - mirrors {@code SimpleModuleInfoWriter}'s
* entity-package heuristic, but computed from the dto mapper packages themselves rather than
* {@code ProcessingContext.getFactoryPackage()} (which only tracks {@code @Entity} packages
* and is never populated during a test-source-only compilation round, which would otherwise
* make this fall back to its "unknown" default).
*/
private static String shortestMapperPackage(List<DtoBeanMeta> metas) {
String shortest = null;
for (DtoBeanMeta meta : metas) {
String pkg = meta.mapperPackage();
if (shortest == null || pkg.length() < shortest.length()) {
shortest = pkg;
}
}
return shortest;
}
void write() throws IOException {
if (hasSimpleNameCollision()) {
return;
}
var javaFileObject = ctx.createWriter(factoryFullName);
writer = new Append(javaFileObject.openWriter());
writePackage();
writeClassStart();
writeFields();
writeMapperForMethod();
writeMapperOfTypeMethod();
writeClassEnd();
writer.close();
writeServicesFile();
}
/**
* Fail fast, with a clear diagnostic, if two different types referenced by the aggregated
* register (across every {@code @DtoMapping} pair in this module) share the same simple class
* name - dispatch and imports are both simple-name based (see {@link #writePackage()}), so an
* undetected collision would otherwise surface as a confusing duplicate-import compile error
* in the generated source rather than a clear message pointing at the actual cause.
*/
private boolean hasSimpleNameCollision() {
Set<String> allReferenced = new LinkedHashSet<>();
for (DtoBeanMeta meta : metas) {
allReferenced.add(meta.sourceFullName());
allReferenced.add(meta.targetFullName());
allReferenced.add(meta.mapperFullName());
for (DtoConverterMeta converter : meta.converterDeps()) {
allReferenced.add(converter.typeFullName());
}
}
String[] collision = Split.findSimpleNameCollision(allReferenced);
if (collision != null) {
ctx.logError(null, "@DtoMapping simple name collision generating %s - %s and %s share the same"
+ " simple class name; this is not currently supported within one module, use distinct class names",
SHORT_NAME, collision[0], collision[1]);
return true;
}
return false;
}
private void writePackage() {
writer.append("package %s;", factoryPackage).eol().eol();
Set<String> imports = new LinkedHashSet<>();
imports.add("io.ebean.DtoMapper");
imports.add("io.ebean.config.DtoMapperRegister");
for (DtoBeanMeta meta : metas) {
addImportIfNeeded(imports, meta.sourceFullName());
addImportIfNeeded(imports, meta.targetFullName());
addImportIfNeeded(imports, meta.mapperFullName());
for (DtoConverterMeta converter : meta.converterDeps()) {
addImportIfNeeded(imports, converter.typeFullName());
}
if (!meta.converterDeps().isEmpty()) {
imports.add("io.ebean.DtoConverterManager");
}
}
for (String imp : imports) {
writer.append("import %s;", imp).eol();
}
writer.eol();
}
private void addImportIfNeeded(Set<String> imports, String fullName) {
if (!Split.split(fullName)[0].equals(factoryPackage)) {
imports.add(fullName);
}
}
private void writeClassStart() {
writer.append(Constants.AT_GENERATED).eol();
writer.append("public class %s implements DtoMapperRegister {", SHORT_NAME).eol().eol();
}
/**
* Declare one field per generated mapper, in dependency order, each initialized exactly once -
* a single shared instance reused by every {@code mapperFor()} call rather than a new instance
* per call. A mapper with nested (ToOne/ToMany) properties and/or instance-dispatch
* {@code @DtoConvert} converters is constructed via its all-args constructor, passing the
* already-declared nested mapper fields and {@code DtoConverterManager.get(...)}-resolved
* converter instances it depends on.
*/
private void writeFields() {
for (DtoBeanMeta meta : sortedMetas) {
List<DtoBeanMeta> deps = nestedMetas(meta);
List<DtoConverterMeta> converters = meta.converterDeps();
String shortName = meta.mapperShortName();
String fieldName = mapperFieldName(meta);
if (deps.isEmpty() && converters.isEmpty()) {
writer.append(" private final %s %s = new %s();", shortName, fieldName, shortName).eol();
} else {
StringBuilder args = new StringBuilder();
for (DtoBeanMeta dep : deps) {
if (args.length() > 0) {
args.append(", ");
}
args.append(mapperFieldName(dep));
}
for (DtoConverterMeta converter : converters) {
if (args.length() > 0) {
args.append(", ");
}
args.append("DtoConverterManager.get(").append(converter.typeShortName()).append(".class)");
}
writer.append(" private final %s %s = new %s(%s);", shortName, fieldName, shortName, args).eol();
}
}
writer.eol();
}
/**
* The other {@link DtoBeanMeta}s a mapper's all-args constructor depends on, in the same order
* {@link DtoMapperWriter} declares its nested mapper constructor parameters (declaration order
* of the target DTO's {@code NESTED_ONE}/{@code NESTED_MANY} properties).
*/
private List<DtoBeanMeta> nestedMetas(DtoBeanMeta meta) {
List<DtoBeanMeta> nested = new ArrayList<>();
for (DtoPropertyMeta property : meta.properties()) {
if (property.nested() != null) {
nested.add(property.nested());
}
}
return nested;
}
private String mapperFieldName(DtoBeanMeta meta) {
String name = meta.mapperShortName();
return Character.toLowerCase(name.charAt(0)) + name.substring(1);
}
private void writeMapperForMethod() {
writer.append(" @Override").eol();
writer.append(" @SuppressWarnings(\"unchecked\")").eol();
writer.append(" public <SOURCE, TARGET> DtoMapper<SOURCE, TARGET> mapperFor(Class<SOURCE> sourceType, Class<TARGET> targetType) {").eol();
for (DtoBeanMeta meta : metas) {
String sourceShort = Split.shortName(meta.sourceFullName());
String targetShort = Split.shortName(meta.targetFullName());
writer.append(" if (sourceType == %s.class && targetType == %s.class) return (DtoMapper<SOURCE, TARGET>) %s;",
sourceShort, targetShort, mapperFieldName(meta)).eol();
}
writer.append(" return null;").eol();
writer.append(" }").eol();
}
/**
* Look up a mapper instance by its own concrete generated type - e.g. {@code
* DtoMapperManager.get(CustomerDtoMapper.class)} - as an alternative to {@link
* #writeMapperForMethod()}'s (source, target) pair lookup, typically used to resolve a mapper
* instance for dependency injection into application code.
*/
private void writeMapperOfTypeMethod() {
writer.eol();
writer.append(" @Override").eol();
writer.append(" @SuppressWarnings(\"unchecked\")").eol();
writer.append(" public <T> T mapperOfType(Class<T> mapperType) {").eol();
for (DtoBeanMeta meta : metas) {
writer.append(" if (mapperType == %s.class) return (T) %s;", meta.mapperShortName(), mapperFieldName(meta)).eol();
}
writer.append(" return null;").eol();
writer.append(" }").eol();
}
private void writeClassEnd() {
writer.append("}").eol();
}
private void writeServicesFile() {
try {
FileObject jfo = ctx.createMetaInfWriter(Constants.METAINF_SERVICES_DTOMAPPERREGISTER);
if (jfo != null) {
Writer w = jfo.openWriter();
w.write(factoryFullName);
w.close();
}
} catch (IOException e) {
ctx.logError(null, "Failed to write DtoMapperRegister services file " + e.getMessage());
}
}
}
@@ -0,0 +1,62 @@
package io.ebean.querybean.generator;
import java.util.Set;
/**
* A named variant of a {@link DtoBeanMeta} - see {@code @DtoMapping(name = "...", exclude =
* "...")}. Generated into the *same* mapper class as the base mapping (not a separate class),
* excluding one or more nested {@code ToOne}/{@code ToMany} properties from both its own
* {@code fetchGroup} and its mapped output.
* <p>
* Exposed on the generated mapper as a same-named accessor method returning its own
* {@code DtoMapper<SOURCE, TARGET>} view (a small inner class delegating back to the shared,
* once-written {@code build(...)} method) - e.g. {@code name = "noFleets"} generates a
* {@code noFleets()} method, so callers can do
* {@code query.mapTo(Target.class, mapper.noFleets())} with no string-based lookup.
*/
final class DtoMapperVariant {
private final String name;
private final String suffix;
private final Set<String> excludedProperties;
DtoMapperVariant(String name, Set<String> excludedProperties) {
this.name = name;
this.suffix = Character.toUpperCase(name.charAt(0)) + name.substring(1);
this.excludedProperties = excludedProperties;
}
/** The variant name as declared, e.g. {@code "noFleets"} - also the accessor method name. */
String name() {
return name;
}
/** {@link #name()} capitalized, e.g. {@code "NoFleets"} - used to derive generated identifiers. */
String suffix() {
return suffix;
}
/** Dto field names (of {@code NESTED_ONE}/{@code NESTED_MANY} properties) this variant excludes. */
Set<String> excludedProperties() {
return excludedProperties;
}
boolean excludes(DtoPropertyMeta property) {
return excludedProperties.contains(property.dtoFieldName());
}
/** Field name holding this variant's own {@code FetchGroup}, e.g. {@code fetchGroupNoFleets}. */
String fetchGroupFieldName() {
return "fetchGroup" + suffix;
}
/** Inner class name implementing {@code DtoMapper<SOURCE, TARGET>} for this variant. */
String innerClassName() {
return suffix + "Mapper";
}
/** Field name holding the (lazily unnecessary - eagerly built) inner mapper instance. */
String fieldName() {
return name + "View";
}
}
@@ -0,0 +1,593 @@
package io.ebean.querybean.generator;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Writes a single generated {@code XxxMapper.java} implementing {@code io.ebean.DtoMapper<S,T>}
* for one {@link DtoBeanMeta}, in the exact shape validated by the hand-written
* dto-spike-toone-mapper / dto-spike-tomany-identity spikes: no reflection, nested mappers wired
* via constructor injection (a no-arg constructor supplying default instances, plus an explicit
* overload for substitution in tests). Identity de-duplication via the shared
* {@code DtoMapContext}'s {@code computeIfAbsent} is only generated for types that are actually
* nested elsewhere in the DTO graph (see {@link DtoBeanMeta#nestedElsewhere()}) - a pure
* top-level type constructs its DTO directly, since the cache could never produce a hit for it.
*/
class DtoMapperWriter {
private final ProcessingContext ctx;
private final DtoBeanMeta meta;
private Append writer;
DtoMapperWriter(ProcessingContext ctx, DtoBeanMeta meta) {
this.ctx = ctx;
this.meta = meta;
}
void write() throws IOException {
if (hasSimpleNameCollision()) {
return;
}
var javaFileObject = ctx.createWriter(meta.mapperFullName(), meta.target());
writer = new Append(javaFileObject.openWriter());
writePackage();
writeImports();
writeClassStart();
writeConstructors();
writeFetchGroupMethod();
writeMapMethod();
if (!meta.variants().isEmpty()) {
writeBuildMethod();
writeVariantAccessors();
}
writeClassEnd();
writer.close();
}
/**
* Fail fast, with a clear diagnostic, if two different types referenced by this single
* generated mapper (source, target, any nested source/target/mapper, any converter type) share
* the same simple class name - {@link #writeImports()} relies on simple names being unambiguous
* within the file, so an undetected collision would otherwise surface as a confusing
* duplicate-import compile error rather than a clear message pointing at the actual cause.
*/
private boolean hasSimpleNameCollision() {
Set<String> allReferenced = new LinkedHashSet<>();
allReferenced.add(meta.sourceFullName());
allReferenced.add(meta.targetFullName());
for (DtoPropertyMeta property : meta.properties()) {
if (property.nested() != null) {
allReferenced.add(property.nested().sourceFullName());
allReferenced.add(property.nested().targetFullName());
allReferenced.add(property.nested().mapperFullName());
}
if (property.converter() != null) {
allReferenced.add(property.converter().typeFullName());
}
}
String[] collision = Split.findSimpleNameCollision(allReferenced);
if (collision != null) {
ctx.logError(meta.target(), "@DtoMapping simple name collision generating %s - %s and %s share the"
+ " same simple class name; this is not currently supported within one generated mapper, use"
+ " distinct class names", meta.mapperShortName(), collision[0], collision[1]);
return true;
}
return false;
}
private void writePackage() {
writer.append("package %s;", meta.mapperPackage()).eol().eol();
}
private void writeImports() {
Set<String> imports = new LinkedHashSet<>();
imports.add("io.ebean.DtoMapper");
imports.add("io.ebean.DtoMapContext");
imports.add("io.ebean.FetchGroup");
addImportIfNeeded(imports, meta.sourceFullName());
addImportIfNeeded(imports, meta.targetFullName());
for (DtoPropertyMeta property : meta.properties()) {
if (property.nested() != null) {
addImportIfNeeded(imports, property.nested().sourceFullName());
addImportIfNeeded(imports, property.nested().targetFullName());
addImportIfNeeded(imports, property.nested().mapperFullName());
}
if (property.converter() != null) {
addImportIfNeeded(imports, property.converter().typeFullName());
}
}
if (!meta.converterDeps().isEmpty()) {
imports.add("io.ebean.DtoConverterManager");
}
if (variableProperties().stream().anyMatch(p -> p.kind() == DtoPropertyMeta.Kind.NESTED_MANY)) {
imports.add("java.util.List");
}
for (String imp : imports) {
writer.append("import %s;", imp).eol();
}
writer.eol();
}
private void addImportIfNeeded(Set<String> imports, String fullName) {
String pkg = Split.split(fullName)[0];
if (pkg != null && !pkg.equals(meta.mapperPackage())) {
imports.add(fullName);
}
}
private void writeClassStart() {
String sourceShort = Split.shortName(meta.sourceFullName());
String targetShort = Split.shortName(meta.targetFullName());
writer.append(Constants.AT_GENERATED).eol();
writer.append("public final class %s implements DtoMapper<%s, %s> {", meta.mapperShortName(),
sourceShort, targetShort).eol().eol();
for (DtoPropertyMeta property : nestedProperties()) {
writer.append(" private final DtoMapper<%s, %s> %s;",
Split.shortName(property.nested().sourceFullName()),
Split.shortName(property.nested().targetFullName()),
mapperFieldName(property)).eol();
}
for (DtoConverterMeta converter : meta.converterDeps()) {
writer.append(" private final %s %s;", converter.typeShortName(), converter.fieldName()).eol();
}
writer.append(" private final FetchGroup<%s> fetchGroup;", sourceShort).eol();
for (DtoMapperVariant variant : meta.variants()) {
writer.append(" private final FetchGroup<%s> %s;", sourceShort, variant.fetchGroupFieldName()).eol();
}
// one shared, stateless instance per variant - same "singleton, not `new` per call" approach
// DtoMapperRegisterWriter already uses for the top-level generated mappers themselves
for (DtoMapperVariant variant : meta.variants()) {
writer.append(" private final DtoMapper<%s, %s> %s;", sourceShort, targetShort, variant.fieldName()).eol();
}
writer.eol();
}
private void writeConstructors() {
var nested = nestedProperties();
var converters = meta.converterDeps();
if (nested.isEmpty() && converters.isEmpty()) {
writer.append(" public %s() {", meta.mapperShortName()).eol();
writeAllFetchGroupAssignments(" ");
writeAllVariantViewAssignments(" ");
writer.append(" }").eol().eol();
return;
}
writer.append(" public %s() {", meta.mapperShortName()).eol();
StringBuilder args = new StringBuilder();
for (DtoPropertyMeta property : nested) {
if (args.length() > 0) {
args.append(", ");
}
args.append("new ").append(property.nested().mapperShortName()).append("()");
}
for (DtoConverterMeta converter : converters) {
if (args.length() > 0) {
args.append(", ");
}
args.append("DtoConverterManager.get(").append(converter.typeShortName()).append(".class)");
}
writer.append(" this(%s);", args).eol();
writer.append(" }").eol().eol();
writer.append(" public %s(", meta.mapperShortName());
StringBuilder params = new StringBuilder();
for (DtoPropertyMeta property : nested) {
if (params.length() > 0) {
params.append(", ");
}
params.append("DtoMapper<").append(Split.shortName(property.nested().sourceFullName()))
.append(", ").append(Split.shortName(property.nested().targetFullName())).append("> ")
.append(mapperFieldName(property));
}
for (DtoConverterMeta converter : converters) {
if (params.length() > 0) {
params.append(", ");
}
params.append(converter.typeShortName()).append(" ").append(converter.fieldName());
}
writer.append("%s) {", params).eol();
for (DtoPropertyMeta property : nested) {
writer.append(" this.%s = %s;", mapperFieldName(property), mapperFieldName(property)).eol();
}
for (DtoConverterMeta converter : converters) {
writer.append(" this.%s = %s;", converter.fieldName(), converter.fieldName()).eol();
}
writeAllFetchGroupAssignments(" ");
writeAllVariantViewAssignments(" ");
writer.append(" }").eol().eol();
}
/**
* Build the list of {@code .select(...)}/{@code .fetch(...)} chain calls (without the leading
* dot) needed to populate the target DTO - derived from the DTO's declared shape rather than
* maintained by hand. {@code REF} properties (the {@code @DtoRef} id-only escape hatch) add
* their association's name to the root {@code select(...)} - this reads the FK column directly
* off the base table (no join, unlike {@code .fetch(assoc, "id")}) and, importantly, actually
* populates it rather than relying on it being "already there" (which only holds when some
* other property on this same DTO independently fetches that association). Skipped only when
* the same association is already fully fetched via a {@code NESTED_ONE}/{@code NESTED_MANY}
* property, to avoid a redundant/duplicate select of a path that's already covered by a fetch.
*/
private List<String> fetchGroupChainCalls(Set<String> excludedFieldNames) {
List<DtoPropertyMeta> activeProperties = new ArrayList<>();
for (DtoPropertyMeta property : meta.properties()) {
if (!excludedFieldNames.contains(property.dtoFieldName())) {
activeProperties.add(property);
}
}
Set<String> nestedAssocPaths = new LinkedHashSet<>();
for (DtoPropertyMeta property : activeProperties) {
if (property.kind() == DtoPropertyMeta.Kind.NESTED_ONE || property.kind() == DtoPropertyMeta.Kind.NESTED_MANY) {
nestedAssocPaths.add(property.sourcePropertyPath().get(0));
}
}
Set<String> rootSelect = new LinkedHashSet<>();
Map<String, List<String>> pathSelect = new LinkedHashMap<>();
List<String> fetchCalls = new ArrayList<>();
for (DtoPropertyMeta property : activeProperties) {
switch (property.kind()) {
case NESTED_ONE:
case NESTED_MANY:
fetchCalls.add(String.format("fetch(\"%s\", %s.fetchGroup())",
property.sourcePropertyPath().get(0), mapperFieldName(property)));
break;
case SCALAR:
List<String> path = property.sourcePropertyPath();
if (path.size() == 1) {
rootSelect.add(path.get(0));
} else {
String fetchPath = String.join(".", path.subList(0, path.size() - 1));
if (nestedAssocPaths.contains(fetchPath)) {
if (excludedFieldNames.isEmpty()) {
reportFetchPathCollision(property, fetchPath);
}
break;
}
pathSelect.computeIfAbsent(fetchPath, p -> new ArrayList<>())
.add(path.get(path.size() - 1));
}
break;
case REF:
default:
String assoc = property.sourcePropertyPath().get(0);
if (!nestedAssocPaths.contains(assoc)) {
rootSelect.add(assoc);
}
break;
}
}
for (var entry : pathSelect.entrySet()) {
fetchCalls.add(String.format("fetch(\"%s\", \"%s\")", entry.getKey(), String.join(",", entry.getValue())));
}
List<String> calls = new ArrayList<>();
if (!rootSelect.isEmpty()) {
calls.add(String.format("select(\"%s\")", String.join(",", rootSelect)));
}
calls.addAll(fetchCalls);
return calls;
}
/**
* Fail fast at codegen time rather than silently generating broken code: {@code OrmQueryDetail}
* keys its fetch paths by exact path string and {@code fetch(path, ...)} replaces (not merges)
* any existing entry for that same key - so a {@code @DtoPath} property whose fetch path is
* identical to an existing {@code NESTED_ONE}/{@code NESTED_MANY} property's own fetch path would
* silently overwrite (or be overwritten by) that nested fetch, discarding whichever call happens
* to be emitted first/last. Detected here instead so the failure is a clear compile-time error.
*/
private void reportFetchPathCollision(DtoPropertyMeta property, String fetchPath) {
ctx.logError(meta.target(),
"@DtoPath property '%s' on %s resolves to fetch path '%s', which collides with the nested"
+ " mapping already using that same fetch path - Ebean's fetch spec can only carry one set"
+ " of properties per path, so one silently discards the other. Move '%s' onto the nested"
+ " DTO type instead (its own mapper's fetchGroup already fetches path '%s'), or choose a"
+ " @DtoPath that reaches into a different, non-colliding path.",
property.dtoFieldName(), meta.targetFullName(), fetchPath, property.dtoFieldName(), fetchPath);
}
/**
* Write the {@code this.fetchGroup = FetchGroup.of(Source.class)...build();} assignment (and
* one per named {@link DtoMapperVariant}, each skipping its own excluded properties from the
* fetch spec), with each chained {@code .select()}/{@code .fetch()}/{@code .build()} call on
* its own line for readability.
*/
private void writeAllFetchGroupAssignments(String indent) {
writeFetchGroupAssignment(indent, "fetchGroup", Set.of());
for (DtoMapperVariant variant : meta.variants()) {
writeFetchGroupAssignment(indent, variant.fetchGroupFieldName(), variant.excludedProperties());
}
}
private void writeFetchGroupAssignment(String indent, String fieldName, Set<String> excludedFieldNames) {
writer.append("%sthis.%s = FetchGroup.of(%s.class)", indent, fieldName, Split.shortName(meta.sourceFullName()));
for (String call : fetchGroupChainCalls(excludedFieldNames)) {
writer.eol().append("%s .%s", indent, call);
}
writer.eol().append("%s .build();", indent).eol();
}
/**
* Assign each named variant's shared, stateless inner-class instance (its {@code DtoMapper}
* view) once, in the constructor - a single instance is reused across every call to the
* variant's accessor method (e.g. {@code noFleets()}), rather than allocating a fresh one per
* call, matching {@code DtoMapperRegisterWriter}'s "singleton, not {@code new} per call"
* approach for the top-level generated mappers themselves.
*/
private void writeAllVariantViewAssignments(String indent) {
for (DtoMapperVariant variant : meta.variants()) {
writer.append("%sthis.%s = new %s();", indent, variant.fieldName(), variant.innerClassName()).eol();
}
}
private void writeFetchGroupMethod() {
writer.append(" @Override").eol();
writer.append(" public FetchGroup<%s> fetchGroup() {", Split.shortName(meta.sourceFullName())).eol();
writer.append(" return fetchGroup;").eol();
writer.append(" }").eol().eol();
}
private void writeMapMethod() {
String sourceShort = Split.shortName(meta.sourceFullName());
String targetShort = Split.shortName(meta.targetFullName());
Set<DtoPropertyMeta> variableProps = variableProperties();
writer.append(" @Override").eol();
writer.append(" public %s map(%s source, DtoMapContext context) {", targetShort, sourceShort).eol();
writer.append(" if (source == null) {").eol();
writer.append(" return null;").eol();
writer.append(" }").eol();
if (!variableProps.isEmpty()) {
// named variants exist - route construction through the shared build(...) method, this
// (base) mapping includes every variable property (each still evaluated inline, at its own
// declared position, inside build() - see buildCallArgs())
if (meta.nestedElsewhere()) {
writer.append(" // dedup using DtoMapContext, same %s instance can be reached via more than one path in the graph", sourceShort).eol();
writer.append(" return context.computeIfAbsent(%s.class, source, s -> build(s, context%s));",
targetShort, buildCallArgs(variableProps)).eol();
} else {
writer.append(" // DtoMapContext for nested mappers only").eol();
writer.append(" return build(source, context%s);", buildCallArgs(variableProps)).eol();
}
} else if (meta.nestedElsewhere()) {
writer.append(" // dedup using DtoMapContext, same %s instance can be reached via more than one path in the graph", sourceShort).eol();
writeConstructionExpression(
String.format(" return context.computeIfAbsent(%s.class, source, s -> ", targetShort),
"s", variableProps, ")");
} else if (nestedProperties().isEmpty()) {
writer.append(" // skip DtoMapContext, only ever a top-level mapping").eol();
writeConstructionExpression(" return ", "source", variableProps, "");
} else {
writer.append(" // DtoMapContext for nested mappers only").eol();
writeConstructionExpression(" return ", "source", variableProps, "");
}
writer.append(" }").eol();
}
/**
* Emit the target construction expression - either a positional constructor call or, when
* {@link DtoBeanMeta#builderMeta()} is present (see {@code @DtoMapping(builder = ...)}), a
* {@code Target.builder()....build()} fluent chain. {@code linePrefix} is written before the
* expression starts (e.g. {@code " return "} or a {@code computeIfAbsent(...)} lambda
* opener); {@code wrapperClosing} is any additional closing needed for an enclosing call this
* expression is nested inside (e.g. {@code ")"} to close an enclosing
* {@code computeIfAbsent(...)} call, or {@code ""} for a plain {@code return}) - the trailing
* {@code ";"} is always added here. Every property - including ones in {@code variableProps} -
* is evaluated inline, in true {@link DtoBeanMeta#properties()} declared order; a variable
* property's expression is simply guarded by its {@code includeXxx} boolean parameter (see
* {@link #writeBuildMethod()}), so no property's evaluation is ever hoisted out of its declared
* position (only non-empty when named variants exist - see {@link #variableProperties()}).
*/
private void writeConstructionExpression(String linePrefix, String rootVariable, Set<DtoPropertyMeta> variableProps, String wrapperClosing) {
String targetShort = Split.shortName(meta.targetFullName());
if (meta.builderMeta() == null) {
writer.append("%snew %s(", linePrefix, targetShort).eol();
writeConstructionArgs(rootVariable, variableProps, ")" + wrapperClosing + ";");
} else {
writer.append("%s%s.builder()", linePrefix, targetShort).eol();
writeBuilderChain(rootVariable, variableProps);
writer.append(" .build()%s;", wrapperClosing).eol();
}
}
private void writeConstructionArgs(String rootVariable, Set<DtoPropertyMeta> variableProps, String closing) {
var properties = meta.properties();
for (int i = 0; i < properties.size(); i++) {
DtoPropertyMeta property = properties.get(i);
writer.append(" %s%s", constructionValueExpression(property, rootVariable, variableProps),
i < properties.size() - 1 ? "," : closing).eol();
}
if (properties.isEmpty()) {
writer.append(" %s", closing).eol();
}
}
private void writeBuilderChain(String rootVariable, Set<DtoPropertyMeta> variableProps) {
for (DtoPropertyMeta property : meta.properties()) {
writer.append(" .%s(%s)", property.dtoFieldName(), constructionValueExpression(property, rootVariable, variableProps)).eol();
}
}
/**
* A variable property (excluded by at least one named variant) is guarded by its own
* {@code includeXxx} boolean parameter (see {@link #writeBuildMethod()}) - a ternary choosing
* between its real computed expression and its empty default - so it stays evaluated inline, at
* its true declared position, exactly like a non-variable property. This preserves the DTO's
* declared property order as the actual evaluation order, regardless of which properties happen
* to be excluded by a variant.
*/
private String constructionValueExpression(DtoPropertyMeta property, String rootVariable, Set<DtoPropertyMeta> variableProps) {
String expression = propertyValueExpression(property, rootVariable);
return variableProps.contains(property)
? includeFlagName(property) + " ? " + expression + " : " + defaultValueFor(property)
: expression;
}
/**
* The set of properties (in {@link DtoBeanMeta#properties()} order) excluded by at least one
* {@link DtoMapperVariant} - each becomes an extra {@code boolean includeXxx} parameter on the
* shared {@link #writeBuildMethod()} (rather than a precomputed value), so build() itself
* decides - at the property's own declared position - whether to compute it or substitute its
* empty default. Empty when {@link DtoBeanMeta#variants()} is empty.
*/
private Set<DtoPropertyMeta> variableProperties() {
Set<DtoPropertyMeta> result = new LinkedHashSet<>();
for (DtoPropertyMeta property : meta.properties()) {
for (DtoMapperVariant variant : meta.variants()) {
if (variant.excludes(property)) {
result.add(property);
break;
}
}
}
return result;
}
/** {@code true} literal (comma-prefixed) per variable property - the base mapping includes every property. */
private String buildCallArgs(Set<DtoPropertyMeta> variableProps) {
StringBuilder sb = new StringBuilder();
for (DtoPropertyMeta property : variableProps) {
sb.append(", true");
}
return sb.toString();
}
/**
* The private construction helper shared by the base {@code map()} and every named variant -
* written once, so the common field population logic isn't duplicated per variant. Each
* variable property (see {@link #variableProperties()}) is accepted as a {@code boolean
* includeXxx} parameter rather than a precomputed value - build() evaluates every property
* inline, at its own declared position, choosing the real expression or the empty default based
* on the flag - so introducing variants never changes evaluation order relative to the DTO's
* declared property order.
*/
private void writeBuildMethod() {
Set<DtoPropertyMeta> variableProps = variableProperties();
String sourceShort = Split.shortName(meta.sourceFullName());
String targetShort = Split.shortName(meta.targetFullName());
writer.append(" private %s build(%s source, DtoMapContext context%s) {",
targetShort, sourceShort, buildMethodParams(variableProps)).eol();
writeConstructionExpression(" return ", "source", variableProps, "");
writer.append(" }").eol().eol();
}
private String buildMethodParams(Set<DtoPropertyMeta> variableProps) {
StringBuilder sb = new StringBuilder();
for (DtoPropertyMeta property : variableProps) {
sb.append(", boolean ").append(includeFlagName(property));
}
return sb.toString();
}
/** Parameter/flag name for a variable property, e.g. {@code contacts} -> {@code includeContacts}. */
private String includeFlagName(DtoPropertyMeta property) {
String name = property.dtoFieldName();
return "include" + Character.toUpperCase(name.charAt(0)) + name.substring(1);
}
/**
* The empty/default value a variant supplies for a property it excludes - {@code null} for a
* {@code NESTED_ONE}, {@code List.of()} for a {@code NESTED_MANY} (always valid since
* {@code NESTED_MANY} only ever derives from a {@code java.util.List<X>} target property).
*/
private String defaultValueFor(DtoPropertyMeta property) {
return property.kind() == DtoPropertyMeta.Kind.NESTED_MANY ? "List.of()" : "null";
}
/**
* Write each {@link DtoMapperVariant}'s public accessor method (returning its own
* {@code DtoMapper<SOURCE, TARGET>} view, e.g. {@code noFleets()}) followed by its small inner
* class delegating back to the shared {@link #writeBuildMethod()}. Deliberately skips the
* {@code DtoMapContext} identity-dedup wrapper used by the base mapping when
* {@link DtoBeanMeta#nestedElsewhere()} - named variants are only ever used as an independent,
* top-level {@code query.mapTo(...)} result, never nested inside another DTO graph.
*/
private void writeVariantAccessors() {
Set<DtoPropertyMeta> variableProps = variableProperties();
String sourceShort = Split.shortName(meta.sourceFullName());
String targetShort = Split.shortName(meta.targetFullName());
for (DtoMapperVariant variant : meta.variants()) {
writer.append(" /** Named variant excluding {%s} - see @DtoMapping(name = \"%s\", exclude = ...). */",
String.join(", ", variant.excludedProperties()), variant.name()).eol();
writer.append(" public DtoMapper<%s, %s> %s() {", sourceShort, targetShort, variant.name()).eol();
writer.append(" return %s;", variant.fieldName()).eol();
writer.append(" }").eol().eol();
}
for (DtoMapperVariant variant : meta.variants()) {
writeVariantInnerClass(variant, variableProps, sourceShort, targetShort);
}
}
private void writeVariantInnerClass(DtoMapperVariant variant, Set<DtoPropertyMeta> variableProps, String sourceShort, String targetShort) {
writer.append(" private final class %s implements DtoMapper<%s, %s> {", variant.innerClassName(), sourceShort, targetShort).eol().eol();
writer.append(" @Override").eol();
writer.append(" public FetchGroup<%s> fetchGroup() {", sourceShort).eol();
writer.append(" return %s;", variant.fetchGroupFieldName()).eol();
writer.append(" }").eol().eol();
writer.append(" @Override").eol();
writer.append(" public %s map(%s source, DtoMapContext context) {", targetShort, sourceShort).eol();
writer.append(" if (source == null) {").eol();
writer.append(" return null;").eol();
writer.append(" }").eol();
writer.append(" return build(source, context%s);", variantBuildArgs(variant, variableProps)).eol();
writer.append(" }").eol();
writer.append(" }").eol().eol();
}
/** {@code true}/{@code false} literal (comma-prefixed) per variable property - {@code false} for ones this variant excludes. */
private String variantBuildArgs(DtoMapperVariant variant, Set<DtoPropertyMeta> variableProps) {
StringBuilder sb = new StringBuilder();
for (DtoPropertyMeta property : variableProps) {
sb.append(", ").append(!variant.excludes(property));
}
return sb.toString();
}
private String propertyValueExpression(DtoPropertyMeta property, String rootVariable) {
switch (property.kind()) {
case NESTED_ONE:
return mapperFieldName(property) + ".map(" + property.sourceValueExpression(rootVariable) + ", context)";
case NESTED_MANY:
return mapperFieldName(property) + ".mapList(" + property.sourceValueExpression(rootVariable) + ", context)";
case SCALAR:
case REF:
default:
return applyConverter(property, property.sourceValueExpression(rootVariable));
}
}
/**
* Wrap {@code rawExpression} in the property's {@code @DtoConvert} conversion call, if any -
* a direct static call ({@code ConverterType.method(rawExpression)}) or a call against the
* resolved instance field ({@code converterField.method(rawExpression)}) depending on
* {@link DtoConverterMeta#isStatic()}.
*/
private String applyConverter(DtoPropertyMeta property, String rawExpression) {
DtoConverterMeta converter = property.converter();
if (converter == null) {
return rawExpression;
}
String receiver = converter.isStatic() ? converter.typeShortName() : converter.fieldName();
return receiver + "." + converter.methodName() + "(" + rawExpression + ")";
}
private List<DtoPropertyMeta> nestedProperties() {
List<DtoPropertyMeta> nested = new ArrayList<>();
for (DtoPropertyMeta property : meta.properties()) {
if (property.nested() != null) {
nested.add(property);
}
}
return nested;
}
private String mapperFieldName(DtoPropertyMeta property) {
return property.dtoFieldName() + "Mapper";
}
private void writeClassEnd() {
writer.append("}").eol();
}
}
@@ -0,0 +1,714 @@
package io.ebean.querybean.generator;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.element.AnnotationValue;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.ModuleElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.lang.model.type.DeclaredType;
import javax.lang.model.type.TypeKind;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.ElementFilter;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Reads all {@code @DtoMapping(source, target)} declarations present in the current round,
* builds a {@link DtoBeanMeta} (with matched properties) for each pair, and excludes any that
* take part in a cycle in the nested-DTO type graph (logging a clear error for each).
*/
class DtoMappingReader {
/** Auto-detection threshold for {@code @DtoMapping(builder = AUTO)} - see {@link #resolveBuilder}. */
private static final int BUILDER_AUTO_THRESHOLD = 5;
private final ProcessingContext ctx;
private final Map<String, DtoBeanMeta> byTargetName = new LinkedHashMap<>();
/**
* {@code @DtoMixin}-annotated companion types, keyed by the (qualified name of the) real DTO
* type they overlay annotations onto - see {@link #collectMixins(RoundEnvironment)}.
*/
private final Map<String, TypeElement> mixinsByTarget = new LinkedHashMap<>();
/**
* Raw {@code @DtoMapping(name = "...", exclude = "...")} variant registrations, keyed by
* target FQN - resolved into {@link DtoMapperVariant}s (attached to the matching base
* {@link DtoBeanMeta}) once properties are known, in {@link #resolveVariants()}.
*/
private final Map<String, List<RawVariant>> variantsByTarget = new LinkedHashMap<>();
/** {@code builder()} attribute value of each target's base (unnamed) registration. */
private final Map<String, String> builderModeByTarget = new LinkedHashMap<>();
private static final class RawVariant {
private final Element declaringElement;
private final TypeElement source;
private final String name;
private final List<String> exclude;
RawVariant(Element declaringElement, TypeElement source, String name, List<String> exclude) {
this.declaringElement = declaringElement;
this.source = source;
this.name = name;
this.exclude = exclude;
}
Element declaringElement() {
return declaringElement;
}
TypeElement source() {
return source;
}
String name() {
return name;
}
List<String> exclude() {
return exclude;
}
}
DtoMappingReader(ProcessingContext ctx) {
this.ctx = ctx;
}
/**
* Register all {@code @DtoMapping} pairs and {@code @DtoMixin} companion types found in this
* round (called once per processing round - both are typically only present in the first
* round, but later rounds may still contribute if generated sources declare them too).
*/
void collect(RoundEnvironment roundEnv) {
Set<Element> elements = new LinkedHashSet<>();
addAnnotatedElements(roundEnv, Constants.DTO_MAPPING, elements);
// DtoMapping is @Repeatable - when used more than once on the same element javac wraps the
// repeated instances in a single synthetic container annotation (@DtoMapping.List) rather
// than repeating the plain annotation, so it must also be checked explicitly, and
// DtoMappingPrism doesn't unwrap that container for us - see allDtoMappingMirrors() below.
addAnnotatedElements(roundEnv, Constants.DTO_MAPPING_LIST, elements);
for (Element element : elements) {
for (AnnotationMirror mirror : allDtoMappingMirrors(element)) {
DtoMappingPrism prism = DtoMappingPrism.getInstance(mirror);
if (prism != null) {
register(element, prism);
}
}
}
collectMixins(roundEnv);
}
/**
* Discover {@code @DtoMixin(Target.class)} companion types (proactively, via {@code
* roundEnv.getElementsAnnotatedWith(...)} - unlike {@code @DtoPath}/{@code @DtoRef}/{@code
* @DtoConvert}, a mixin doesn't annotate an already-being-iterated field of a known {@code
* @DtoMapping} target, so it can't be discovered lazily from within {@link #resolveProperty}).
*/
private void collectMixins(RoundEnvironment roundEnv) {
TypeElement annotationType = ctx.elementUtils().getTypeElement(Constants.DTO_MIXIN);
if (annotationType == null) {
return;
}
for (Element element : roundEnv.getElementsAnnotatedWith(annotationType)) {
DtoMixinPrism prism = DtoMixinPrism.getInstanceOn(element);
if (prism == null) {
continue;
}
if (element.getKind() != ElementKind.INTERFACE && element.getKind() != ElementKind.CLASS) {
ctx.logError(element, "@DtoMixin must be declared on an interface or class");
continue;
}
TypeElement target = asTypeElement(prism.value());
if (target == null) {
ctx.logError(element, "@DtoMixin value() must be a class type");
continue;
}
mixinsByTarget.put(target.getQualifiedName().toString(), (TypeElement) element);
}
}
private void addAnnotatedElements(RoundEnvironment roundEnv, String annotationTypeName, Set<Element> elements) {
TypeElement annotationType = ctx.elementUtils().getTypeElement(annotationTypeName);
if (annotationType != null) {
elements.addAll(roundEnv.getElementsAnnotatedWith(annotationType));
}
}
/**
* Return the {@code @DtoMapping} annotation mirrors directly present on {@code element},
* unwrapping the synthetic {@code @DtoMapping.List} container mirror (produced by javac when
* {@code @DtoMapping} is repeated on the same element) into its individual mirrors.
*/
private List<AnnotationMirror> allDtoMappingMirrors(Element element) {
List<AnnotationMirror> result = new ArrayList<>();
for (AnnotationMirror mirror : element.getAnnotationMirrors()) {
String fqn = ((TypeElement) mirror.getAnnotationType().asElement()).getQualifiedName().toString();
if (Constants.DTO_MAPPING.equals(fqn)) {
result.add(mirror);
} else if (Constants.DTO_MAPPING_LIST.equals(fqn)) {
for (var entry : mirror.getElementValues().entrySet()) {
if ("value".contentEquals(entry.getKey().getSimpleName())) {
@SuppressWarnings("unchecked")
List<? extends AnnotationValue> nested = (List<? extends AnnotationValue>) entry.getValue().getValue();
for (AnnotationValue value : nested) {
result.add((AnnotationMirror) value.getValue());
}
}
}
}
}
return result;
}
/**
* Match properties and check for cycles across all pairs collected so far (call once, at the
* end of processing - i.e. {@code roundEnv.processingOver()}). Returns an empty list if no
* {@code @DtoMapping} pairs were ever collected.
*/
List<DtoBeanMeta> resolveAndValidate() {
if (byTargetName.isEmpty()) {
return List.of();
}
for (DtoBeanMeta meta : byTargetName.values()) {
resolveProperties(meta);
}
resolveVariants();
for (DtoBeanMeta meta : byTargetName.values()) {
resolveBuilder(meta, builderModeByTarget.get(meta.targetFullName()));
}
List<DtoBeanMeta> result = excludeCycles();
markNestedElsewhere(result);
return result;
}
/**
* Attach each raw {@code @DtoMapping(name = ..., exclude = ...)} variant registration (see
* {@link #register}) to its base mapping's {@link DtoBeanMeta}, validating that a base exists,
* variant names are unique per target, the variant's source matches the base's source, and
* each excluded name resolves to a {@code NESTED_ONE}/{@code NESTED_MANY} property.
*/
private void resolveVariants() {
for (var entry : variantsByTarget.entrySet()) {
String targetFqn = entry.getKey();
DtoBeanMeta meta = byTargetName.get(targetFqn);
if (meta == null) {
ctx.logError(entry.getValue().get(0).declaringElement(),
"@DtoMapping variant(s) declared for target %s but no base @DtoMapping (without name())"
+ " was found - declare one first", targetFqn);
continue;
}
Set<String> seenNames = new LinkedHashSet<>();
for (RawVariant raw : entry.getValue()) {
if (!seenNames.add(raw.name())) {
ctx.logError(raw.declaringElement(), "Duplicate @DtoMapping variant name \"%s\" for target %s",
raw.name(), targetFqn);
continue;
}
if (raw.source() != meta.source()) {
ctx.logError(raw.declaringElement(),
"@DtoMapping(name = \"%s\") variant's source (%s) must match the base mapping's source"
+ " (%s) for target %s", raw.name(), raw.source().getQualifiedName(), meta.sourceFullName(), targetFqn);
continue;
}
Set<String> excluded = resolveExcludedProperties(meta, raw);
if (excluded != null) {
meta.addVariant(new DtoMapperVariant(raw.name(), excluded));
}
}
}
}
/**
* Validate and resolve one variant's {@code exclude()} property names against {@code meta}'s
* already-resolved properties - only {@code NESTED_ONE}/{@code NESTED_MANY} properties can be
* excluded (there's no type-safe "absent" value for an arbitrary scalar type). Returns
* {@code null} (with error(s) already logged) if anything doesn't resolve.
*/
private Set<String> resolveExcludedProperties(DtoBeanMeta meta, RawVariant raw) {
if (raw.exclude().isEmpty()) {
ctx.logError(raw.declaringElement(), "@DtoMapping(name = \"%s\") on target %s must exclude() at"
+ " least one nested ToOne/ToMany property", raw.name(), meta.targetFullName());
return null;
}
Set<String> result = new LinkedHashSet<>();
boolean ok = true;
for (String propName : raw.exclude()) {
DtoPropertyMeta property = findProperty(meta, propName);
if (property == null) {
ctx.logError(raw.declaringElement(), "@DtoMapping(name = \"%s\") exclude(\"%s\") does not match"
+ " any property on target %s", raw.name(), propName, meta.targetFullName());
ok = false;
} else if (property.kind() != DtoPropertyMeta.Kind.NESTED_ONE && property.kind() != DtoPropertyMeta.Kind.NESTED_MANY) {
ctx.logError(raw.declaringElement(), "@DtoMapping(name = \"%s\") exclude(\"%s\") on target %s is"
+ " not a nested ToOne/ToMany DTO property - only nested properties can be excluded (there's"
+ " no type-safe \"absent\" value for an arbitrary scalar type)", raw.name(), propName, meta.targetFullName());
ok = false;
} else {
result.add(propName);
}
}
return ok ? result : null;
}
private DtoPropertyMeta findProperty(DtoBeanMeta meta, String name) {
for (DtoPropertyMeta property : meta.properties()) {
if (property.dtoFieldName().equals(name)) {
return property;
}
}
return null;
}
/**
* Decide (and, if applicable, detect) the builder-based construction path for {@code meta} -
* see {@code @DtoMapping(builder = ...)} and {@link DtoBuilderMeta}. {@code NEVER} always uses
* a positional constructor. {@code ALWAYS} requires a matching builder to be found (a codegen
* error otherwise). {@code AUTO} (the default) silently falls back to a positional constructor
* if no matching builder is found, and only actually uses one it does find once the target has
* more than {@link #BUILDER_AUTO_THRESHOLD} properties.
*/
private void resolveBuilder(DtoBeanMeta meta, String builderMode) {
String mode = (builderMode == null || builderMode.isEmpty()) ? "AUTO" : builderMode;
if ("NEVER".equals(mode)) {
return;
}
boolean required = "ALWAYS".equals(mode);
DtoBuilderMeta builder = detectBuilder(meta, required);
if (builder != null && (required || meta.properties().size() > BUILDER_AUTO_THRESHOLD)) {
meta.builderMeta(builder);
}
}
/**
* Detect an {@code avaje-recordbuilder}-style builder on {@code meta.target()}: a static no-arg
* {@code builder()} method, whose return type has a no-arg {@code build()} method returning the
* target type, plus a fluent (returns-itself) same-named setter method for every one of
* {@code meta}'s properties. Returns {@code null} if any part of that shape is missing - logging
* a codegen error only when {@code required} (i.e. {@code builder = ALWAYS}); silent otherwise
* (i.e. {@code builder = AUTO}, where a missing builder just means "use a constructor instead").
*/
private DtoBuilderMeta detectBuilder(DtoBeanMeta meta, boolean required) {
TypeElement target = meta.target();
ExecutableElement builderFactory = findStaticNoArgMethod(target, "builder");
if (builderFactory == null) {
if (required) {
ctx.logError(target, "@DtoMapping(builder = ALWAYS) on target %s but no static no-arg"
+ " builder() method was found", meta.targetFullName());
}
return null;
}
TypeElement builderType = asTypeElement(builderFactory.getReturnType());
if (builderType == null) {
if (required) {
ctx.logError(target, "@DtoMapping(builder = ALWAYS) on target %s but builder() does not"
+ " return a class type", meta.targetFullName());
}
return null;
}
ExecutableElement buildMethod = findMethod(builderType, "build");
if (buildMethod == null || !sameType(buildMethod.getReturnType(), target)) {
if (required) {
ctx.logError(target, "@DtoMapping(builder = ALWAYS) on target %s but %s has no build()"
+ " method returning %s", meta.targetFullName(), builderType.getQualifiedName(), meta.targetFullName());
}
return null;
}
for (DtoPropertyMeta property : meta.properties()) {
if (findFluentSetter(builderType, property.dtoFieldName()) == null) {
if (required) {
ctx.logError(target, "@DtoMapping(builder = ALWAYS) on target %s but %s has no fluent"
+ " \"%s(...)\" method", meta.targetFullName(), builderType.getQualifiedName(), property.dtoFieldName());
}
return null;
}
}
return new DtoBuilderMeta(builderType.getQualifiedName().toString());
}
private ExecutableElement findStaticNoArgMethod(TypeElement type, String name) {
for (ExecutableElement method : ElementFilter.methodsIn(type.getEnclosedElements())) {
if (method.getSimpleName().contentEquals(name) && method.getModifiers().contains(Modifier.STATIC)
&& method.getParameters().isEmpty()) {
return method;
}
}
return null;
}
private ExecutableElement findFluentSetter(TypeElement builderType, String propertyName) {
for (ExecutableElement method : ElementFilter.methodsIn(builderType.getEnclosedElements())) {
if (method.getSimpleName().contentEquals(propertyName) && method.getParameters().size() == 1
&& sameType(method.getReturnType(), builderType)) {
return method;
}
}
return null;
}
private boolean sameType(TypeMirror mirror, TypeElement expected) {
TypeElement actual = asTypeElement(mirror);
return actual != null && actual.getQualifiedName().contentEquals(expected.getQualifiedName());
}
/**
* Mark every {@link DtoBeanMeta} that's used as a {@code NESTED_ONE}/{@code NESTED_MANY}
* property by some other mapper in {@code metas} - see {@link DtoBeanMeta#nestedElsewhere()}.
*/
private void markNestedElsewhere(List<DtoBeanMeta> metas) {
for (DtoBeanMeta meta : metas) {
for (DtoPropertyMeta property : meta.properties()) {
DtoBeanMeta nested = property.nested();
if (nested != null) {
nested.markNestedElsewhere();
}
}
}
}
private void register(Element declaringElement, DtoMappingPrism prism) {
TypeElement sourceType = asTypeElement(prism.source());
TypeElement targetType = asTypeElement(prism.target());
if (sourceType == null || targetType == null) {
ctx.logError(declaringElement, "@DtoMapping source/target must be class types");
return;
}
String targetFqn = targetType.getQualifiedName().toString();
String name = prism.name() == null ? "" : prism.name().trim();
if (name.isEmpty()) {
if (byTargetName.containsKey(targetFqn)) {
ctx.logError(declaringElement, "Duplicate base @DtoMapping for target %s - only one base"
+ " (unnamed) mapping is allowed per target, use name() for additional variants", targetFqn);
return;
}
String mapperPackage = resolveMapperPackage(prism, targetType, declaringElement);
byTargetName.put(targetFqn, new DtoBeanMeta(sourceType, targetType, mapperPackage));
builderModeByTarget.put(targetFqn, prism.builder());
} else {
variantsByTarget.computeIfAbsent(targetFqn, t -> new ArrayList<>())
.add(new RawVariant(declaringElement, sourceType, name, prism.exclude()));
}
}
private TypeElement asTypeElement(TypeMirror mirror) {
if (mirror == null || mirror.getKind() != TypeKind.DECLARED) {
return null;
}
return (TypeElement) ((DeclaredType) mirror).asElement();
}
/**
* Resolve the package the generated mapper for {@code target} lives in: an explicit
* {@code mapperPackage()} override always wins; otherwise the target DTO's own package is
* reused, unless the DTO belongs to a different (named) Java module than the one declaring
* the {@code @DtoMapping}, in which case reusing it would be a JPMS split-package violation -
* in that case a package derived from the declaring module's own name is used instead
* (mirroring {@code LookupWriter}'s module-name-derived package fallback).
*/
private String resolveMapperPackage(DtoMappingPrism prism, TypeElement target, Element declaringElement) {
String override = prism.mapperPackage();
if (override != null && !override.isEmpty()) {
return override;
}
ModuleElement targetModule = ctx.elementUtils().getModuleOf(target);
ModuleElement declaringModule = ctx.elementUtils().getModuleOf(declaringElement);
if (targetModule == null || declaringModule == null
|| targetModule.isUnnamed() || declaringModule.isUnnamed()
|| targetModule.getQualifiedName().contentEquals(declaringModule.getQualifiedName())) {
return Split.split(target.getQualifiedName().toString())[0];
}
return declaringModule.getQualifiedName() + ".dto";
}
private void resolveProperties(DtoBeanMeta meta) {
for (VariableElement field : ElementFilter.fieldsIn(meta.target().getEnclosedElements())) {
if (field.getModifiers().contains(Modifier.STATIC)) {
continue;
}
meta.addProperty(resolveProperty(field, meta));
}
}
private DtoPropertyMeta resolveProperty(VariableElement field, DtoBeanMeta meta) {
String name = field.getSimpleName().toString();
DtoConverterMeta converter = resolveConverter(field, meta);
DtoRefPrism refPrism = prismOn(field, meta, DtoRefPrism::getInstanceOn);
if (refPrism != null) {
String assocName = (name.endsWith("Id") && name.length() > 2) ? name.substring(0, name.length() - 2) : name;
String assocGetter = getterName(meta.source(), assocName);
TypeElement assocType = getterReturnType(meta.source(), assocGetter);
String idGetter = getterName(assocType, "id");
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.REF, List.of(assocGetter, idGetter), List.of(assocName, "id"), null, converter);
}
DtoPathPrism pathPrism = prismOn(field, meta, DtoPathPrism::getInstanceOn);
if (pathPrism != null) {
List<String> getters = new ArrayList<>();
List<String> properties = new ArrayList<>();
TypeElement currentType = meta.source();
for (String segment : pathPrism.value().split("\\.")) {
String getter = getterName(currentType, segment);
getters.add(getter);
properties.add(segment);
currentType = currentType != null ? getterReturnType(currentType, getter) : null;
}
// a single-hop @DtoPath rename (e.g. @DtoPath("eboxStatus") on a field named "status")
// can still target a type with its own registered @DtoMapping - detect that the same way
// the plain (non-@DtoPath) branches below do, rather than always falling back to a raw
// scalar getter call that would fail to compile with a type mismatch against the nested
// DTO type. Multi-hop paths keep the existing scalar/flattening behaviour since fetch spec
// derivation for NESTED_ONE/MANY only supports a single association name.
if (properties.size() == 1) {
TypeMirror fieldType = field.asType();
TypeMirror listElementType = listElementType(fieldType);
if (listElementType != null) {
DtoBeanMeta nested = lookupByTarget(listElementType);
if (nested != null) {
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.NESTED_MANY, getters, properties, nested);
}
} else {
DtoBeanMeta nested = lookupByTarget(fieldType);
if (nested != null) {
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.NESTED_ONE, getters, properties, nested);
}
}
}
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.SCALAR, getters, properties, null, converter);
}
TypeMirror fieldType = field.asType();
TypeMirror listElementType = listElementType(fieldType);
if (listElementType != null) {
DtoBeanMeta nested = lookupByTarget(listElementType);
if (nested != null) {
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.NESTED_MANY, List.of(getterName(meta.source(), name)), List.of(name), nested);
}
} else {
DtoBeanMeta nested = lookupByTarget(fieldType);
if (nested != null) {
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.NESTED_ONE, List.of(getterName(meta.source(), name)), List.of(name), nested);
}
}
return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.SCALAR, List.of(getterName(meta.source(), name)), List.of(name), null, converter);
}
/**
* Resolve a prism instance for {@code field}, falling back to the corresponding
* {@code @DtoMixin} companion method (same simple name) when {@code field} carries no such
* annotation directly and {@code meta.target()} has a registered mixin - this is what lets
* {@code @DtoPath}/{@code @DtoRef}/{@code @DtoConvert} be declared on a mixin instead of the
* (possibly generated/unowned) DTO type itself.
*/
private <A> A prismOn(VariableElement field, DtoBeanMeta meta, java.util.function.Function<Element, A> lookup) {
A direct = lookup.apply(field);
if (direct != null) {
return direct;
}
ExecutableElement mixinMethod = findMixinMethod(meta, field.getSimpleName().toString());
return mixinMethod == null ? null : lookup.apply(mixinMethod);
}
/**
* The method on {@code meta.target()}'s registered {@code @DtoMixin} companion (if any) whose
* name matches {@code propertyName}, or {@code null} if no mixin is registered for this target
* or it declares no matching method.
*/
private ExecutableElement findMixinMethod(DtoBeanMeta meta, String propertyName) {
TypeElement mixin = mixinsByTarget.get(meta.target().getQualifiedName().toString());
return mixin == null ? null : findMethod(mixin, propertyName);
}
/**
* Resolve the {@code @DtoConvert} conversion declared on {@code field} (or, failing that, its
* {@code @DtoMixin} companion method - see {@link #prismOn}), if any - determining
* static-vs-instance dispatch by locating the named method on the converter type. Returns
* {@code null} (with no error) if {@code @DtoConvert} isn't present at all.
*/
private DtoConverterMeta resolveConverter(VariableElement field, DtoBeanMeta meta) {
DtoConvertPrism prism = prismOn(field, meta, DtoConvertPrism::getInstanceOn);
if (prism == null) {
return null;
}
TypeElement converterType = asTypeElement(prism.value());
if (converterType == null) {
ctx.logError(field, "@DtoConvert value() must be a class type");
return null;
}
String methodName = prism.method();
ExecutableElement method = findMethod(converterType, methodName);
if (method == null) {
ctx.logError(field, "@DtoConvert method \"%s\" not found on %s", methodName, converterType.getQualifiedName());
return null;
}
boolean isStatic = method.getModifiers().contains(Modifier.STATIC);
return new DtoConverterMeta(converterType.getQualifiedName().toString(), methodName, isStatic);
}
private ExecutableElement findMethod(TypeElement type, String methodName) {
for (ExecutableElement method : ElementFilter.methodsIn(type.getEnclosedElements())) {
if (method.getSimpleName().contentEquals(methodName)) {
return method;
}
}
return null;
}
private DtoBeanMeta lookupByTarget(TypeMirror type) {
TypeElement element = asTypeElement(type);
return element == null ? null : byTargetName.get(element.getQualifiedName().toString());
}
/**
* Return the {@code java.util.List} element type of {@code type}, or {@code null} if it isn't
* a (raw or parameterized) {@code java.util.List}.
*/
private TypeMirror listElementType(TypeMirror type) {
if (type.getKind() != TypeKind.DECLARED) {
return null;
}
DeclaredType declaredType = (DeclaredType) type;
TypeElement typeElement = (TypeElement) declaredType.asElement();
if (!typeElement.getQualifiedName().contentEquals("java.util.List")) {
return null;
}
List<? extends TypeMirror> typeArguments = declaredType.getTypeArguments();
return typeArguments.isEmpty() ? null : typeArguments.get(0);
}
/**
* Resolve the accessor method name for {@code propertyName} on {@code type} (searching
* {@code type} and its superclass chain) by checking, in order, which shape actually exists as
* a real no-arg method: (1) {@code isXxx()} returning {@code boolean} (standard JavaBean
* convention for a primitive boolean accessor), (2) {@code getXxx()} (standard JavaBean
* convention), (3) the bare {@code propertyName()} itself - a "record-style" accessor, which
* isn't limited to an actual {@code record} type (Ebean does support record entity beans, but
* an ordinary class can just as easily expose bare/fluent-style accessors with no {@code get}/
* {@code is} prefix at all). Falls back to the guessed {@code getXxx()} name if {@code type} is
* {@code null} (unresolvable, e.g. a later {@code @DtoPath} segment whose receiver type
* couldn't be determined) or none of the three shapes actually exist.
*/
private String getterName(TypeElement type, String propertyName) {
String capitalized = Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1);
String isName = "is" + capitalized;
String getName = "get" + capitalized;
if (type == null) {
return getName;
}
if (hasNoArgMethod(type, isName, TypeKind.BOOLEAN)) {
return isName;
}
if (hasNoArgMethod(type, getName, null)) {
return getName;
}
if (hasNoArgMethod(type, propertyName, null)) {
return propertyName;
}
return getName;
}
/**
* Whether a no-arg method named {@code methodName} exists on {@code type} (searching
* {@code type} and its superclass chain), optionally constrained to a specific return
* {@code TypeKind} (e.g. {@code TypeKind.BOOLEAN} for a primitive boolean accessor) - pass
* {@code null} to accept any return type.
*/
private boolean hasNoArgMethod(TypeElement type, String methodName, TypeKind returnKind) {
for (TypeElement current = type; current != null; current = superclassOf(current)) {
for (ExecutableElement method : ElementFilter.methodsIn(current.getEnclosedElements())) {
if (method.getParameters().isEmpty() && method.getSimpleName().contentEquals(methodName)
&& (returnKind == null || method.getReturnType().getKind() == returnKind)) {
return true;
}
}
}
return false;
}
/**
* The return type of the no-arg method {@code getterMethodName} resolved on {@code type}
* (searching {@code type} and its superclass chain) - used to walk each successive segment of
* a multi-segment {@code @DtoPath} through the actual intermediate types, so each segment's
* own accessor name can be resolved correctly against its real receiver type (e.g. a
* {@code boolean} property partway through the path). Returns {@code null} if unresolvable, in
* which case later segments fall back to the guessed {@code getXxx()} name.
*/
private TypeElement getterReturnType(TypeElement type, String getterMethodName) {
for (TypeElement current = type; current != null; current = superclassOf(current)) {
for (ExecutableElement method : ElementFilter.methodsIn(current.getEnclosedElements())) {
if (method.getParameters().isEmpty() && method.getSimpleName().contentEquals(getterMethodName)) {
return asTypeElement(method.getReturnType());
}
}
}
return null;
}
private TypeElement superclassOf(TypeElement type) {
return asTypeElement(type.getSuperclass());
}
/**
* DFS-based cycle detection over the nested-DTO type graph (edges are {@code NESTED_ONE}/
* {@code NESTED_MANY} properties only - {@code REF}/{@code SCALAR} properties don't recurse
* into another mapper, so they can't participate in a cycle). Any {@link DtoBeanMeta} that
* takes part in a detected cycle is logged as an error and excluded from the returned list.
*/
private List<DtoBeanMeta> excludeCycles() {
List<DtoBeanMeta> result = new ArrayList<>();
java.util.Set<DtoBeanMeta> cyclic = new java.util.HashSet<>();
for (DtoBeanMeta meta : byTargetName.values()) {
if (meta.visit == DtoBeanMeta.Visit.WHITE) {
detectCycle(meta, new ArrayDeque<>(), cyclic);
}
}
for (DtoBeanMeta meta : byTargetName.values()) {
if (!cyclic.contains(meta)) {
result.add(meta);
}
}
return result;
}
private void detectCycle(DtoBeanMeta meta, Deque<DtoBeanMeta> path, java.util.Set<DtoBeanMeta> cyclic) {
meta.visit = DtoBeanMeta.Visit.GRAY;
path.push(meta);
for (DtoPropertyMeta property : meta.properties()) {
DtoBeanMeta nested = property.nested();
if (nested == null) {
continue;
}
if (nested.visit == DtoBeanMeta.Visit.GRAY) {
reportCycle(path, nested, cyclic);
} else if (nested.visit == DtoBeanMeta.Visit.WHITE) {
detectCycle(nested, path, cyclic);
}
}
path.pop();
meta.visit = DtoBeanMeta.Visit.BLACK;
}
private void reportCycle(Deque<DtoBeanMeta> path, DtoBeanMeta cycleStart, java.util.Set<DtoBeanMeta> cyclic) {
StringBuilder chain = new StringBuilder(cycleStart.targetFullName());
for (DtoBeanMeta onPath : path) {
chain.insert(0, onPath.targetFullName() + " -> ");
cyclic.add(onPath);
if (onPath == cycleStart) {
break;
}
}
cyclic.add(cycleStart);
ctx.logError(cycleStart.target(),
"@DtoMapping cycle detected, not generating mappers for: %s -> %s "
+ "(introduce a separate shallow DTO type, or an @DtoRef id-only field, to break the cycle)",
chain, cycleStart.targetFullName());
}
}
@@ -0,0 +1,107 @@
package io.ebean.querybean.generator;
import java.util.List;
/**
* Metadata for a single target DTO property (constructor argument), describing how its value is
* obtained from the source entity.
*/
class DtoPropertyMeta {
enum Kind {
/** Direct (or {@code @DtoPath}) scalar getter chain on the source, e.g. {@code s.getName()}. */
SCALAR,
/** {@code @DtoRef} id-only shortcut, e.g. {@code s.getCustomer().getId()}. */
REF,
/** Nested ToOne - delegates to another generated mapper. */
NESTED_ONE,
/** Nested ToMany - delegates to another generated mapper's {@code mapList}. */
NESTED_MANY
}
private final String dtoFieldName;
private final Kind kind;
private final List<String> sourceGetterPath;
private final List<String> sourcePropertyPath;
private final DtoBeanMeta nested;
private final DtoConverterMeta converter;
DtoPropertyMeta(String dtoFieldName, Kind kind, List<String> sourceGetterPath, List<String> sourcePropertyPath, DtoBeanMeta nested) {
this(dtoFieldName, kind, sourceGetterPath, sourcePropertyPath, nested, null);
}
DtoPropertyMeta(String dtoFieldName, Kind kind, List<String> sourceGetterPath, List<String> sourcePropertyPath, DtoBeanMeta nested, DtoConverterMeta converter) {
this.dtoFieldName = dtoFieldName;
this.kind = kind;
this.sourceGetterPath = sourceGetterPath;
this.sourcePropertyPath = sourcePropertyPath;
this.nested = nested;
this.converter = converter;
}
String dtoFieldName() {
return dtoFieldName;
}
Kind kind() {
return kind;
}
List<String> sourceGetterPath() {
return sourceGetterPath;
}
/**
* The literal Ebean bean-property name for each hop of {@link #sourceGetterPath()} (e.g.
* {@code ["billingAddress", "line1"]}) - kept separately from the getter-call path since the
* two diverge for accessor styles other than a plain {@code getXxx()}/{@code isXxx()} JavaBean
* getter (record-style bare accessors, boolean {@code isXxx()} accessors). Used to build
* {@code FetchGroup.select(...)}/{@code .fetch(...)} property strings without having to
* reverse-parse an accessor method name back into a property name.
*/
List<String> sourcePropertyPath() {
return sourcePropertyPath;
}
/**
* The other {@link DtoBeanMeta} this property delegates to, for {@link Kind#NESTED_ONE} and
* {@link Kind#NESTED_MANY} properties. {@code null} otherwise.
*/
DtoBeanMeta nested() {
return nested;
}
/**
* The {@code @DtoConvert} conversion to apply to this property's raw source value, or
* {@code null} if this property is mapped directly with no conversion.
*/
DtoConverterMeta converter() {
return converter;
}
/**
* Return a source expression chaining {@link #sourceGetterPath()} getters off the given root
* variable. A single getter is a plain call, e.g. {@code s.getName()}; a multi-hop chain (from
* {@code @DtoPath} or {@code @DtoRef}) null-guards each intermediate hop, e.g.
* {@code (s.getBillingAddress() == null ? null : s.getBillingAddress().getLine1())}.
*/
String sourceValueExpression(String rootVariable) {
if (sourceGetterPath.size() == 1) {
return rootVariable + "." + sourceGetterPath.get(0) + "()";
}
StringBuilder sb = new StringBuilder();
appendGuardedChain(sb, rootVariable, 0);
return sb.toString();
}
private void appendGuardedChain(StringBuilder sb, String prefix, int index) {
if (index == sourceGetterPath.size() - 1) {
sb.append(prefix).append('.').append(sourceGetterPath.get(index)).append("()");
return;
}
String nextPrefix = prefix + "." + sourceGetterPath.get(index) + "()";
sb.append('(').append(nextPrefix).append(" == null ? null : ");
appendGuardedChain(sb, nextPrefix, index + 1);
sb.append(')');
}
}
@@ -105,6 +105,14 @@ class ProcessingContext implements Constants {
this.readModuleInfo = new ReadModuleInfo(this);
}
Elements elementUtils() {
return elementUtils;
}
Types typeUtils() {
return typeUtils;
}
TypeElement entityAnnotation() {
return elementUtils.getTypeElement(ENTITY);
}
@@ -693,6 +701,17 @@ class ProcessingContext implements Constants {
return prefixEntities;
}
/**
* True if any entity, embeddable or other (converter/component) class has been registered in
* this compilation - i.e. there is actually something for an {@code EbeanEntityRegister} to
* report. Guards against writing an empty, meaningless registry (with no factory package to
* derive a sensible location from) for compilation units - such as a test-source-only module
* that only declares {@code @DtoMapping} - that never contain any {@code @Entity} classes.
*/
boolean hasAnyEntitiesOrOther() {
return !prefixEntities.isEmpty() || !otherClasses.isEmpty();
}
Set<String> getDbEntities() {
return dbEntities;
}
@@ -17,8 +17,8 @@ import java.util.Set;
public class Processor extends AbstractProcessor implements Constants {
private ProcessingContext processingContext;
private SimpleModuleInfoWriter moduleWriter;
private boolean initModuleWriter;
private DtoMappingReader dtoMappingReader;
private boolean wroteDtoMappers;
private boolean wroteLookup;
@@ -26,6 +26,7 @@ public class Processor extends AbstractProcessor implements Constants {
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
this.processingContext = new ProcessingContext(processingEnv);
this.dtoMappingReader = new DtoMappingReader(processingContext);
}
@Override
@@ -38,6 +39,9 @@ public class Processor extends AbstractProcessor implements Constants {
annotations.add(MODULEINFO);
annotations.add(TYPEQUERYBEAN);
annotations.add(GENERATED);
annotations.add(DTO_MAPPING);
annotations.add(DTO_MAPPING_LIST);
annotations.add(DTO_MIXIN);
return annotations;
}
@@ -52,7 +56,10 @@ public class Processor extends AbstractProcessor implements Constants {
int count = processEntities(roundEnv);
processOthers(roundEnv);
final int loaded = processingContext.complete();
initModuleInfoBean();
dtoMappingReader.collect(roundEnv);
if (!roundEnv.processingOver()) {
writeDtoMappers();
}
if (roundEnv.processingOver()) {
writeModuleInfoBean();
}
@@ -101,29 +108,56 @@ public class Processor extends AbstractProcessor implements Constants {
}
}
private void initModuleInfoBean() {
/**
* Write the {@code EbeanEntityRegister} at the end of processing - deferred until there is
* something to actually register (rather than eagerly reserving/creating the source file up
* front), since a compilation unit with no {@code @Entity}/{@code @Embeddable}/{@code @Converter}
* /other classes at all - e.g. a test-source-only module that only declares
* {@code @DtoMapping} - has nothing meaningful to write and no factory package to derive a
* sensible location from.
*/
private void writeModuleInfoBean() {
if (!processingContext.hasAnyEntitiesOrOther()) {
processingContext.logNote("EbeanEntityRegister skipped - no entities or other classes found");
return;
}
try {
if (!initModuleWriter) {
moduleWriter = new SimpleModuleInfoWriter(processingContext);
}
SimpleModuleInfoWriter moduleWriter = new SimpleModuleInfoWriter(processingContext);
moduleWriter.write();
} catch (FilerException e) {
processingContext.logWarn(null, "FilerException trying to write EntityClassRegister error: " + e);
} catch (Throwable e) {
processingContext.logError(null, "Failed to initialise EntityClassRegister error:" + e + " stack:" + Arrays.toString(e.getStackTrace()));
} finally {
initModuleWriter = true;
processingContext.logError(null, "Failed to write EntityClassRegister error:" + e + " stack:" + Arrays.toString(e.getStackTrace()));
}
}
private void writeModuleInfoBean() {
/**
* Generate the dto mappers as soon as all {@code @DtoMapping} pairs collected so far are
* available - i.e. as early as possible, not deferred to {@code roundEnv.processingOver()}.
* Generated types (unlike the {@code EbeanEntityRegister}/module-info registrations) are
* directly referenced by hand-written source code, so they must be written in a round prior
* to the final one or javac will not compile them at all (only a warning, no error, is
* produced for files created in the very last round - see "will not be subject to annotation
* processing").
*/
private void writeDtoMappers() {
if (wroteDtoMappers) {
return;
}
try {
if (moduleWriter == null) {
processingContext.logNote(null, "EntityClassRegister skipped");
} else {
moduleWriter.write();
var metas = dtoMappingReader.resolveAndValidate();
if (metas.isEmpty()) {
return;
}
wroteDtoMappers = true;
for (DtoBeanMeta meta : metas) {
new DtoMapperWriter(processingContext, meta).write();
}
new DtoMapperRegisterWriter(processingContext, metas).write();
processingContext.logNote("Ebean APT generated %s dto mappers", metas.size());
} catch (Throwable e) {
processingContext.logError(null, "Failed to write EntityClassRegister error:" + e + " stack:" + Arrays.toString(e.getStackTrace()));
wroteDtoMappers = true;
processingContext.logError(null, "Failed to generate dto mappers error:" + e + " stack:" + Arrays.toString(e.getStackTrace()));
}
}
@@ -1,7 +1,9 @@
package io.ebean.querybean.generator;
import java.util.AbstractMap.SimpleEntry;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.StringTokenizer;
@@ -54,4 +56,22 @@ class Split {
return new SimpleEntry<>(simpleSignature.toString(), assocImports);
}
/**
* Returns the first pair of distinct full names in {@code fullNames} that share the same
* simple (unqualified) class name, or {@code null} if there's no such collision - used to fail
* fast, with a clear diagnostic, on an ambiguous set of imports (two different types sharing a
* simple name) rather than silently generating source with a duplicate-import/undefined-symbol
* compile error that's much harder to trace back to its cause.
*/
static String[] findSimpleNameCollision(Set<String> fullNames) {
Map<String, String> seenBySimpleName = new HashMap<>();
for (String fullName : fullNames) {
String existing = seenBySimpleName.putIfAbsent(shortName(fullName), fullName);
if (existing != null && !existing.equals(fullName)) {
return new String[] {existing, fullName};
}
}
return null;
}
}
@@ -0,0 +1,11 @@
/**
* Annotation processor generating type-safe Ebean Query beans and DTO graph mappers.
*/
@GeneratePrism(io.ebean.annotation.DtoPath.class)
@GeneratePrism(io.ebean.annotation.DtoRef.class)
@GeneratePrism(io.ebean.annotation.DtoMapping.class)
@GeneratePrism(io.ebean.annotation.DtoConvert.class)
@GeneratePrism(io.ebean.annotation.DtoMixin.class)
package io.ebean.querybean.generator;
import io.avaje.prism.GeneratePrism;
@@ -4,4 +4,6 @@ module io.ebean.querybean.generator {
requires java.compiler;
requires java.sql;
requires static io.avaje.prism;
requires static io.ebean.annotation;
}
@@ -0,0 +1,144 @@
package io.ebean.querybean.generator;
import org.junit.jupiter.api.Test;
import javax.tools.Diagnostic;
import javax.tools.DiagnosticCollector;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Regression test for the fetch-path collision fail-fast added to {@code DtoMapperWriter} - a
* {@code @DtoPath} property whose fetch path is identical to a {@code NESTED_ONE}/{@code NESTED_MANY}
* property's own fetch path (e.g. both resolve to path {@code "bar"}) must be a clear compile-time
* error, not silently generated code where one {@code .fetch("bar", ...)} call discards the other
* (see {@code OrmQueryDetail.fetch()}, which replaces rather than merges same-key entries).
* <p>
* Compiles a minimal in-memory source set directly through {@code javax.tools.JavaCompiler} with
* this module's {@link Processor} registered explicitly - no external compile-testing dependency
* required.
*/
class DtoMapperFetchPathCollisionTest {
@Test
void dtoPathCollidingWithNestedFetchPath_expectCompileError() throws IOException {
Path sourceDir = Files.createTempDirectory("dto-collision-src");
Path outDir = Files.createTempDirectory("dto-collision-out");
writeSource(sourceDir, "org.tests.collision.Bar",
"package org.tests.collision;\n"
+ "\n"
+ "public class Bar {\n"
+ " private Long id;\n"
+ " private String name;\n"
+ "\n"
+ " public Long getId() { return id; }\n"
+ " public String getName() { return name; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.collision.Foo",
"package org.tests.collision;\n"
+ "\n"
+ "public class Foo {\n"
+ " private Bar bar;\n"
+ "\n"
+ " public Bar getBar() { return bar; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.collision.BarDto",
"package org.tests.collision;\n"
+ "\n"
+ "public class BarDto {\n"
+ " private final Long id;\n"
+ " private final String name;\n"
+ "\n"
+ " public BarDto(Long id, String name) {\n"
+ " this.id = id;\n"
+ " this.name = name;\n"
+ " }\n"
+ "\n"
+ " public Long getId() { return id; }\n"
+ " public String getName() { return name; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.collision.FooDto",
"package org.tests.collision;\n"
+ "\n"
+ "import io.ebean.annotation.DtoPath;\n"
+ "\n"
+ "public class FooDto {\n"
+ " private final BarDto bar;\n"
+ "\n"
+ " @DtoPath(\"bar.name\")\n"
+ " private final String barName;\n"
+ "\n"
+ " public FooDto(BarDto bar, String barName) {\n"
+ " this.bar = bar;\n"
+ " this.barName = barName;\n"
+ " }\n"
+ "\n"
+ " public BarDto getBar() { return bar; }\n"
+ " public String getBarName() { return barName; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.collision.package-info",
"@DtoMapping(source = Bar.class, target = BarDto.class)\n"
+ "@DtoMapping(source = Foo.class, target = FooDto.class)\n"
+ "package org.tests.collision;\n"
+ "\n"
+ "import io.ebean.annotation.DtoMapping;\n");
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, Locale.getDefault(), null)) {
List<Path> sourceFiles;
try (var walk = Files.walk(sourceDir)) {
sourceFiles = walk.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList());
}
Iterable<? extends JavaFileObject> compilationUnits =
fileManager.getJavaFileObjectsFromPaths(sourceFiles);
List<String> options = List.of(
"-d", outDir.toString(),
"-classpath", System.getProperty("java.class.path"),
"-processor", Processor.class.getName());
JavaCompiler.CompilationTask task = compiler.getTask(
null, fileManager, diagnostics, options, null, compilationUnits);
boolean success = task.call();
assertFalse(success, "compilation should fail due to the fetch path collision");
List<Diagnostic<? extends JavaFileObject>> errors = diagnostics.getDiagnostics().stream()
.filter(d -> d.getKind() == Diagnostic.Kind.ERROR)
.collect(Collectors.toList());
assertFalse(errors.isEmpty(), "expected at least one compile ERROR diagnostic");
boolean matched = errors.stream()
.map(d -> d.getMessage(Locale.getDefault()))
.anyMatch(msg -> msg.contains("resolves to fetch path 'bar'")
&& msg.contains("collides with the nested mapping"));
assertTrue(matched, "expected the fetch-path-collision error message, got: " + errors);
}
}
private void writeSource(Path sourceDir, String fqn, String content) {
try {
Path pkgDir = sourceDir.resolve(fqn.substring(0, fqn.lastIndexOf('.')).replace('.', '/'));
Files.createDirectories(pkgDir);
String simpleName = fqn.substring(fqn.lastIndexOf('.') + 1);
Path file = pkgDir.resolve(simpleName + ".java");
try (Writer writer = Files.newBufferedWriter(file)) {
writer.write(content);
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@@ -0,0 +1,252 @@
package io.ebean.querybean.generator;
import org.junit.jupiter.api.Test;
import javax.tools.Diagnostic;
import javax.tools.DiagnosticCollector;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Regression test proving DTO mapping resolves the correct source accessor for two non-JavaBean
* accessor shapes - both a genuine Java {@code record} source (see {@code
* org.example.records.CourseRecordEntity} in {@code test-java16}) and, separately, an ordinary
* (non-record) class that simply exposes bare/fluent-style accessors (e.g. {@code active()},
* {@code name()}) with no {@code get}/{@code is} prefix at all - Ebean supports record entity
* beans, but a bare-name accessor isn't exclusive to an actual {@code record} type. Both the
* accessor call generated for reading the source value, and the Ebean bean-property name used in
* the generated {@code FetchGroup.select(...)}/{@code .fetch(...)} calls, must match whichever
* shape the source type actually exposes, rather than blindly guessing a JavaBean {@code get}/
* {@code is} prefix.
*/
class DtoMapperRecordSourceTest {
@Test
void recordSource_expectBareAccessorsAndPropertyNames() throws IOException {
Path sourceDir = Files.createTempDirectory("dto-record-src");
Path outDir = Files.createTempDirectory("dto-record-out");
Path genSourceDir = Files.createTempDirectory("dto-record-gensrc");
writeSource(sourceDir, "org.tests.recordsrc.Region",
"package org.tests.recordsrc;\n"
+ "\n"
+ "public record Region(long id, String code) {\n"
+ "}\n");
writeSource(sourceDir, "org.tests.recordsrc.ItemRecord",
"package org.tests.recordsrc;\n"
+ "\n"
+ "public record ItemRecord(long id, String name, boolean active, Region region) {\n"
+ "}\n");
writeSource(sourceDir, "org.tests.recordsrc.ItemDto",
"package org.tests.recordsrc;\n"
+ "\n"
+ "import io.ebean.annotation.DtoRef;\n"
+ "\n"
+ "public class ItemDto {\n"
+ " private final long id;\n"
+ " private final String name;\n"
+ " private final boolean active;\n"
+ " @DtoRef\n"
+ " private final Long regionId;\n"
+ "\n"
+ " public ItemDto(long id, String name, boolean active, Long regionId) {\n"
+ " this.id = id;\n"
+ " this.name = name;\n"
+ " this.active = active;\n"
+ " this.regionId = regionId;\n"
+ " }\n"
+ "\n"
+ " public long getId() { return id; }\n"
+ " public String getName() { return name; }\n"
+ " public boolean isActive() { return active; }\n"
+ " public Long getRegionId() { return regionId; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.recordsrc.package-info",
"@DtoMapping(source = ItemRecord.class, target = ItemDto.class)\n"
+ "package org.tests.recordsrc;\n"
+ "\n"
+ "import io.ebean.annotation.DtoMapping;\n");
writeTypequeryGeneratedStub(sourceDir);
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, Locale.getDefault(), null)) {
List<Path> sourceFiles;
try (var walk = Files.walk(sourceDir)) {
sourceFiles = walk.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList());
}
Iterable<? extends JavaFileObject> compilationUnits =
fileManager.getJavaFileObjectsFromPaths(sourceFiles);
List<String> options = List.of(
"-d", outDir.toString(),
"-s", genSourceDir.toString(),
"-processor", Processor.class.getName());
JavaCompiler.CompilationTask task = compiler.getTask(
null, fileManager, diagnostics, options, null, compilationUnits);
boolean success = task.call();
List<Diagnostic<? extends JavaFileObject>> errors = diagnostics.getDiagnostics().stream()
.filter(d -> d.getKind() == Diagnostic.Kind.ERROR)
.collect(Collectors.toList());
assertTrue(success, "compilation should succeed for a record source type, got errors: " + errors);
Path mapperFile;
try (var walk = Files.walk(genSourceDir)) {
mapperFile = walk.filter(p -> p.getFileName().toString().equals("ItemDtoMapper.java"))
.findFirst()
.orElseThrow(() -> new AssertionError("generated mapper source not found under " + genSourceDir));
}
String generated = Files.readString(mapperFile);
assertTrue(generated.contains("source.name()"), "expected bare record accessor source.name(), got:\n" + generated);
assertTrue(generated.contains("source.active()"), "expected bare record accessor source.active(), got:\n" + generated);
assertFalse(generated.contains("getName()"), "should not guess a JavaBean getter for a record source, got:\n" + generated);
assertFalse(generated.contains("isActive()"), "should not guess an isXxx() getter for a record source, got:\n" + generated);
assertFalse(generated.contains("getActive()"), "should not guess a getXxx() getter for a record source, got:\n" + generated);
assertTrue(generated.contains("select(\"id,name,active,region\")"),
"expected FetchGroup.select(...) to use bare Ebean property names, got:\n" + generated);
assertTrue(generated.contains("source.region() == null ? null : source.region().id()"),
"expected @DtoRef id shortcut to use the associated record's bare id() accessor, got:\n" + generated);
}
}
@Test
void plainClassWithBareAccessors_expectBareAccessorsResolved() throws IOException {
Path sourceDir = Files.createTempDirectory("dto-bare-src");
Path outDir = Files.createTempDirectory("dto-bare-out");
Path genSourceDir = Files.createTempDirectory("dto-bare-gensrc");
writeSource(sourceDir, "org.tests.baresrc.WidgetSource",
"package org.tests.baresrc;\n"
+ "\n"
+ "public class WidgetSource {\n"
+ " private final long id;\n"
+ " private final String name;\n"
+ " private final boolean active;\n"
+ "\n"
+ " public WidgetSource(long id, String name, boolean active) {\n"
+ " this.id = id;\n"
+ " this.name = name;\n"
+ " this.active = active;\n"
+ " }\n"
+ "\n"
+ " public long id() { return id; }\n"
+ " public String name() { return name; }\n"
+ " public boolean active() { return active; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.baresrc.WidgetDto",
"package org.tests.baresrc;\n"
+ "\n"
+ "public class WidgetDto {\n"
+ " private final long id;\n"
+ " private final String name;\n"
+ " private final boolean active;\n"
+ "\n"
+ " public WidgetDto(long id, String name, boolean active) {\n"
+ " this.id = id;\n"
+ " this.name = name;\n"
+ " this.active = active;\n"
+ " }\n"
+ "\n"
+ " public long getId() { return id; }\n"
+ " public String getName() { return name; }\n"
+ " public boolean isActive() { return active; }\n"
+ "}\n");
writeSource(sourceDir, "org.tests.baresrc.package-info",
"@DtoMapping(source = WidgetSource.class, target = WidgetDto.class)\n"
+ "package org.tests.baresrc;\n"
+ "\n"
+ "import io.ebean.annotation.DtoMapping;\n");
writeTypequeryGeneratedStub(sourceDir);
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, Locale.getDefault(), null)) {
List<Path> sourceFiles;
try (var walk = Files.walk(sourceDir)) {
sourceFiles = walk.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList());
}
Iterable<? extends JavaFileObject> compilationUnits =
fileManager.getJavaFileObjectsFromPaths(sourceFiles);
List<String> options = List.of(
"-d", outDir.toString(),
"-s", genSourceDir.toString(),
"-processor", Processor.class.getName());
JavaCompiler.CompilationTask task = compiler.getTask(
null, fileManager, diagnostics, options, null, compilationUnits);
boolean success = task.call();
List<Diagnostic<? extends JavaFileObject>> errors = diagnostics.getDiagnostics().stream()
.filter(d -> d.getKind() == Diagnostic.Kind.ERROR)
.collect(Collectors.toList());
assertTrue(success, "compilation should succeed for a plain class with bare accessors, got errors: " + errors);
Path mapperFile;
try (var walk = Files.walk(genSourceDir)) {
mapperFile = walk.filter(p -> p.getFileName().toString().equals("WidgetDtoMapper.java"))
.findFirst()
.orElseThrow(() -> new AssertionError("generated mapper source not found under " + genSourceDir));
}
String generated = Files.readString(mapperFile);
assertTrue(generated.contains("source.id()"), "expected bare accessor source.id(), got:\n" + generated);
assertTrue(generated.contains("source.name()"), "expected bare accessor source.name(), got:\n" + generated);
assertTrue(generated.contains("source.active()"), "expected bare accessor source.active(), got:\n" + generated);
assertFalse(generated.contains("getName()"), "should not guess a JavaBean getter, got:\n" + generated);
assertFalse(generated.contains("isActive()"), "should not guess an isXxx() getter, got:\n" + generated);
assertFalse(generated.contains("getActive()"), "should not guess a getXxx() getter, got:\n" + generated);
assertTrue(generated.contains("select(\"id,name,active\")"),
"expected FetchGroup.select(...) to use bare Ebean property names, got:\n" + generated);
}
}
/**
* The generated mapper source references {@code io.ebean.typequery.Generated} ({@code
* ebean-querybean}) - that can't be a real dependency here (it would create a circular reactor
* module dependency since {@code ebean-querybean} depends on {@code querybean-generator}).
* Write a self-contained stub of just that one annotation alongside the test sources so the
* generated mapper source fully compiles without needing the real module.
*/
private void writeTypequeryGeneratedStub(Path sourceDir) {
writeSource(sourceDir, "io.ebean.typequery.Generated",
"package io.ebean.typequery;\n"
+ "\n"
+ "public @interface Generated {\n"
+ " String value();\n"
+ "}\n");
}
private void writeSource(Path sourceDir, String fqn, String content) {
try {
Path pkgDir = sourceDir.resolve(fqn.substring(0, fqn.lastIndexOf('.')).replace('.', '/'));
Files.createDirectories(pkgDir);
String simpleName = fqn.substring(fqn.lastIndexOf('.') + 1);
Path file = pkgDir.resolve(simpleName + ".java");
try (Writer writer = Files.newBufferedWriter(file)) {
writer.write(content);
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<artifactId>tests</artifactId>
<groupId>io.ebean</groupId>
<version>18.2.0</version>
</parent>
<artifactId>test-dto-mapping</artifactId>
<name>test dto mapping</name>
<description>
Standalone validation module for issue #2540 nested DTO mapping codegen
(dto-codegen-mapper) - wires querybean-generator as an annotation processor (via
provided-scope dependency, mirroring test-java16) so its generated XxxMapper classes and
EbeanDtoMapperRegister can be exercised end-to-end, which the main ebean-test module cannot
do since it does not wire querybean-generator at all.
</description>
<dependencies>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-h2</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.3.12</version>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>querybean-generator</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.7</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>io.ebean</groupId>
<artifactId>querybean-generator</artifactId>
<version>${project.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>io.ebean</groupId>
<artifactId>ebean-maven-plugin</artifactId>
<version>${ebean-maven-plugin.version}</version>
<executions>
<execution>
<id>main</id>
<phase>process-classes</phase>
<configuration>
<transformArgs>debug=0</transformArgs>
</configuration>
<goals>
<goal>enhance</goal>
</goals>
</execution>
<execution>
<id>test</id>
<phase>process-test-classes</phase>
<configuration>
<transformArgs>debug=0</transformArgs>
</configuration>
<goals>
<goal>testEnhance</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,40 @@
package org.tests.dtomapping.model;
import io.ebean.Model;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class Address extends Model {
@Id
private Long id;
private String line1;
private String city;
public Address(String line1, String city) {
this.line1 = line1;
this.city = city;
}
public Long getId() {
return id;
}
public String getLine1() {
return line1;
}
public void setLine1(String line1) {
this.line1 = line1;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
}
@@ -0,0 +1,107 @@
package org.tests.dtomapping.model;
import io.ebean.Model;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
@Entity
public class Contact extends Model {
@Id
private Long id;
private String firstName;
private String lastName;
/**
* Primitive {@code boolean} field, mapped directly (no {@code @DtoPath}/{@code @DtoConvert}) -
* exercises the generator resolving {@code isActive()} rather than guessing {@code getActive()}
* for the source accessor (standard JavaBean convention for a primitive boolean).
*/
private boolean active = true;
@ManyToOne
private Customer customer;
/** Per-contact engagement score - purely so {@link ContactStats} has something to {@code @Sum}. */
private Integer engagementScore;
/**
* Stored as a raw {@code Short} (0/1) - a stand-in for the kind of legacy scalar encoding
* {@code @DtoConvert}'s static dispatch is meant for (e.g. {@code short} to {@code boolean}).
*/
private Short status;
/**
* Stand-in for a value needing a real dependency to convert (e.g. a cipher) - a stand-in for
* {@code @DtoConvert}'s instance dispatch, resolved via {@code DtoConverterManager}.
*/
private String secretCode;
public Contact(String firstName, String lastName, Customer customer) {
this.firstName = firstName;
this.lastName = lastName;
this.customer = customer;
}
public Long getId() {
return id;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
public Customer getCustomer() {
return customer;
}
public void setCustomer(Customer customer) {
this.customer = customer;
}
public Integer getEngagementScore() {
return engagementScore;
}
public void setEngagementScore(Integer engagementScore) {
this.engagementScore = engagementScore;
}
public Short getStatus() {
return status;
}
public void setStatus(Short status) {
this.status = status;
}
public String getSecretCode() {
return secretCode;
}
public void setSecretCode(String secretCode) {
this.secretCode = secretCode;
}
}
@@ -0,0 +1,54 @@
package org.tests.dtomapping.model;
import io.ebean.Model;
import io.ebean.annotation.Aggregation;
import io.ebean.annotation.Sum;
import io.ebean.annotation.View;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
/**
* Per-customer contact aggregates, read through the same {@code contact} table as {@link Contact}
* (rather than a real database view or a new table - {@code @View(name = "contact")} just points
* this entity's base table at the existing one, so no new DDL is generated).
* <p>
* This is the worked example for Blaze-Persistence-style aggregate/group-by mappings (see
* docs/dto-mapping-design.md, "Aggregate/group-by computed properties"): {@code contactCount} and
* {@code engagementScore} are group-by aggregate formulas ({@code @Aggregation}/{@code @Sum}),
* grouped implicitly by whichever non-aggregate properties end up selected - here {@code customer}.
* The Blaze-Persistence parallel is an {@code @EntityView} with {@code @Mapping("SIZE(contacts)")}
* / {@code @Mapping("SUM(contacts.engagementScore)")} correlated mappings.
* <p>
* {@code id} is declared (required so {@code @Aggregation("count(id)")} has a property to count)
* but deliberately never selected/mapped - selecting it would defeat the aggregation by grouping
* one row per contact instead of one row per customer.
*/
@Entity
@View(name = "contact")
public class ContactStats extends Model {
@Id
private Long id;
@ManyToOne
private Customer customer;
@Aggregation("count(id)")
private Long contactCount;
@Sum
private Integer engagementScore;
public Customer getCustomer() {
return customer;
}
public Long getContactCount() {
return contactCount;
}
public Integer getEngagementScore() {
return engagementScore;
}
}
@@ -0,0 +1,53 @@
package org.tests.dtomapping.model;
import io.ebean.annotation.Formula2;
import io.ebean.annotation.View;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
/**
* Worked example: a read-only "view" entity mapped onto the *same* underlying table as
* {@link Contact} (via {@code @View(name = "contact")}) rather than a real database view -
* this deliberately generates no DDL of its own, it just reads {@link Contact}'s table
* through a second, purpose-built entity shape.
* <p>
* This is the pattern proposed in docs/dto-mapping-design.md's "Ad-hoc computed/formula
* properties" section as the preferred way to get a Blaze-Persistence-style computed DTO
* property: model the computed value as a plain {@code @Formula2} on a dedicated read
* entity, then map that entity to a DTO with the existing (unmodified) {@code @DtoMapping}
* machinery - no new ad-hoc-SQL-on-DTO annotation required.
*/
@Entity
@View(name = "contact")
public class ContactSummary {
@Id
private Long id;
private String firstName;
private String lastName;
/**
* Computed purely from {@link #firstName} and {@link #lastName} - no join required, but
* demonstrates the same {@code @Formula2} mechanism used for join-requiring formulas
* elsewhere (see {@code AggStockProduct}/{@code ParentPerson} in ebean-test).
*/
@Formula2("concat(firstName, ' ', lastName)")
private String fullName;
public Long getId() {
return id;
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
public String getFullName() {
return fullName;
}
}
@@ -0,0 +1,52 @@
package org.tests.dtomapping.model;
import io.ebean.Model;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Customer extends Model {
@Id
private Long id;
private String name;
@ManyToOne
private Address billingAddress;
@OneToMany(mappedBy = "customer")
private List<Contact> contacts = new ArrayList<>();
public Customer(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Address getBillingAddress() {
return billingAddress;
}
public void setBillingAddress(Address billingAddress) {
this.billingAddress = billingAddress;
}
public List<Contact> getContacts() {
return contacts;
}
}
@@ -0,0 +1,29 @@
package org.tests.dtomapping;
/**
* Plain DTO with no framework attachment - nested ToOne target for {@link CustomerDto}.
*/
public class AddressDto {
private final Long id;
private final String line1;
private final String city;
public AddressDto(Long id, String line1, String city) {
this.id = id;
this.line1 = line1;
this.city = city;
}
public Long getId() {
return id;
}
public String getLine1() {
return line1;
}
public String getCity() {
return city;
}
}
@@ -0,0 +1,103 @@
package org.tests.dtomapping;
import io.ebean.annotation.DtoRef;
/**
* Builder-constructed DTO for requirement r18 - forced via {@code @DtoMapping(builder = ALWAYS)}
* on the {@code package-info.java} declaration. Follows the {@code avaje-recordbuilder}
* convention by hand (no dependency on that library needed just to test detection): a static
* no-arg {@code builder()} factory method, a fluent (returns-itself) same-named setter per
* property, and a no-arg {@code build()} method returning the target type.
*/
public class ContactBuilderDto {
private final Long id;
private final String firstName;
private final String lastName;
private final Short status;
private final String secretCode;
@DtoRef
private final Long customerId;
private ContactBuilderDto(Builder builder) {
this.id = builder.id;
this.firstName = builder.firstName;
this.lastName = builder.lastName;
this.status = builder.status;
this.secretCode = builder.secretCode;
this.customerId = builder.customerId;
}
public static Builder builder() {
return new Builder();
}
public Long getId() {
return id;
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
public Short getStatus() {
return status;
}
public String getSecretCode() {
return secretCode;
}
public Long getCustomerId() {
return customerId;
}
public static final class Builder {
private Long id;
private String firstName;
private String lastName;
private Short status;
private String secretCode;
private Long customerId;
public Builder id(Long id) {
this.id = id;
return this;
}
public Builder firstName(String firstName) {
this.firstName = firstName;
return this;
}
public Builder lastName(String lastName) {
this.lastName = lastName;
return this;
}
public Builder status(Short status) {
this.status = status;
return this;
}
public Builder secretCode(String secretCode) {
this.secretCode = secretCode;
return this;
}
public Builder customerId(Long customerId) {
this.customerId = customerId;
return this;
}
public ContactBuilderDto build() {
return new ContactBuilderDto(this);
}
}
}
@@ -0,0 +1,22 @@
package org.tests.dtomapping;
import io.ebean.DatabaseBuilder;
import io.ebean.DtoConverterManager;
import io.ebean.config.DatabaseConfigProvider;
/**
* Registers the {@link SecretCipher} instance needed by {@link ContactConversionDto}'s
* {@code @DtoConvert} instance-dispatch conversion, via the standard
* {@code DatabaseConfigProvider} ServiceLoader hook (see
* {@code META-INF/services/io.ebean.config.DatabaseConfigProvider}) - which runs before the
* {@code Database} (and so the generated {@code EbeanDtoMapperRegister}) is built, satisfying
* {@code DtoConverterManager}'s "register before starting the Database" requirement regardless
* of which test happens to trigger the default {@code Database}'s startup first.
*/
public class ContactConversionDbConfigProvider implements DatabaseConfigProvider {
@Override
public void apply(DatabaseBuilder config) {
DtoConverterManager.put(SecretCipher.class, new ReverseSecretCipher());
}
}
@@ -0,0 +1,47 @@
package org.tests.dtomapping;
import io.ebean.annotation.DtoConvert;
import io.ebean.annotation.DtoPath;
/**
* Standalone (not nested under {@link ContactDto}) DTO exercising {@code @DtoConvert} - both the
* static-dispatch (requirement r13, {@link #active}) and instance-dispatch (requirement r14,
* {@link #secretCode}) cases - see {@code docs/dto-mapping-requirements.md} "Section E: Custom
* property conversion".
*/
public class ContactConversionDto {
private final long id;
private final String firstName;
/** {@code Contact.status} (a raw {@code Short}) converted via the static {@link ContactConversions#toActive}. */
@DtoPath("status")
@DtoConvert(value = ContactConversions.class, method = "toActive")
private final boolean active;
@DtoConvert(value = SecretCipher.class, method = "decode")
private final String secretCode;
public ContactConversionDto(long id, String firstName, boolean active, String secretCode) {
this.id = id;
this.firstName = firstName;
this.active = active;
this.secretCode = secretCode;
}
public long getId() {
return id;
}
public String getFirstName() {
return firstName;
}
public boolean isActive() {
return active;
}
public String getSecretCode() {
return secretCode;
}
}
@@ -0,0 +1,16 @@
package org.tests.dtomapping;
/**
* Static, dependency-free scalar conversions - the {@code @DtoConvert} static-dispatch case
* (requirement r13): no registration needed, the generated mapper calls
* {@code ContactConversions.toActive(...)} directly.
*/
public final class ContactConversions {
private ContactConversions() {
}
public static boolean toActive(Short status) {
return status != null && status != 0;
}
}
@@ -0,0 +1,66 @@
package org.tests.dtomapping;
import io.ebean.annotation.DtoPath;
import io.ebean.annotation.DtoRef;
public class ContactDto {
private final long id;
private final String firstName;
private final String lastName;
/** Direct scalar mapping of a primitive {@code boolean} source field - see {@link org.tests.dtomapping.model.Contact#isActive()}. */
private final boolean active;
private final CustomerRefDto customer;
/** {@code @DtoRef} id-only shortcut - {@code s.getCustomer().getId()}, no nested mapper. */
@DtoRef
private final Long customerId;
/**
* {@code @DtoPath} multi-hop getter chain - {@code s.getCustomer().getBillingAddress().getCity()},
* null-guarded at each hop. Deliberately a 2-hop path through {@code customer} (rather than
* repeating the {@code billingAddress.line1} example already used elsewhere) so its fetch path
* ({@code customer.billingAddress}) doesn't collide with the plain {@code customer} nested-DTO
* fetch on this same class.
*/
@DtoPath("customer.billingAddress.city")
private final String customerCity;
public ContactDto(long id, String firstName, String lastName, boolean active, CustomerRefDto customer, Long customerId, String customerCity) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
this.active = active;
this.customer = customer;
this.customerId = customerId;
this.customerCity = customerCity;
}
public long getId() {
return id;
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
public boolean isActive() {
return active;
}
public CustomerRefDto getCustomer() {
return customer;
}
public Long getCustomerId() {
return customerId;
}
public String getCustomerCity() {
return customerCity;
}
}
@@ -0,0 +1,45 @@
package org.tests.dtomapping;
/**
* Stand-in for a DTO that can't be annotated directly (e.g. generated from an OpenAPI spec,
* regenerated on every build) - carries no {@code @DtoPath}/{@code @DtoRef}/{@code @DtoConvert}
* annotations of its own at all. {@link ContactMixinDtoMixin} overlays them instead (requirement
* "Section E: Custom property conversion" / {@code @DtoMixin} in
* docs/dto-mapping-requirements.md).
*/
public class ContactMixinDto {
private final long id;
private final String firstName;
private final boolean active;
private final String secretCode;
private final CustomerRefDto owner;
public ContactMixinDto(long id, String firstName, boolean active, String secretCode, CustomerRefDto owner) {
this.id = id;
this.firstName = firstName;
this.active = active;
this.secretCode = secretCode;
this.owner = owner;
}
public long getId() {
return id;
}
public String getFirstName() {
return firstName;
}
public boolean isActive() {
return active;
}
public String getSecretCode() {
return secretCode;
}
public CustomerRefDto getOwner() {
return owner;
}
}
@@ -0,0 +1,31 @@
package org.tests.dtomapping;
import io.ebean.annotation.DtoConvert;
import io.ebean.annotation.DtoMixin;
import io.ebean.annotation.DtoPath;
/**
* Overlays {@code @DtoPath}/{@code @DtoConvert} onto {@link ContactMixinDto}, which carries no
* annotations of its own - mirrors avaje-jsonb's {@code @Json.MixIn} mechanism. Method names
* match {@link ContactMixinDto}'s field names ({@code active}, {@code secretCode}, {@code owner});
* the querybean generator matches each mixin method to the corresponding target property by name
* and applies whichever annotations are present as if declared on the target field itself.
*/
@DtoMixin(ContactMixinDto.class)
interface ContactMixinDtoMixin {
@DtoPath("status")
@DtoConvert(value = ContactConversions.class, method = "toActive")
boolean active();
@DtoConvert(value = SecretCipher.class, method = "decode")
String secretCode();
/**
* A single-hop {@code @DtoPath} rename onto a nested (registered {@code @DtoMapping}) type -
* {@code owner} is renamed from {@code Contact#getCustomer()}, resolving to
* {@code CustomerRefDto} via its own generated mapper rather than a raw scalar getter call.
*/
@DtoPath("customer")
CustomerRefDto owner();
}
@@ -0,0 +1,38 @@
package org.tests.dtomapping;
import io.ebean.annotation.DtoRef;
/**
* Flat DTO for {@link org.tests.dtomapping.model.ContactStats} - a group-by aggregation result
* (contact count + summed engagement score per customer), not a graph fetch.
* <p>
* {@code customerId} uses {@code @DtoRef} - the generated fetch spec adds {@code customer} to the
* root {@code select(...)}, which reads the FK column directly off the base table (no join) and
* is exactly the property this aggregation query is grouped by. This doubles as the grouping key.
*/
public class ContactStatsDto {
@DtoRef
private final Long customerId;
private final Long contactCount;
private final Integer engagementScore;
public ContactStatsDto(Long customerId, Long contactCount, Integer engagementScore) {
this.customerId = customerId;
this.contactCount = contactCount;
this.engagementScore = engagementScore;
}
public Long getCustomerId() {
return customerId;
}
public Long getContactCount() {
return contactCount;
}
public Integer getEngagementScore() {
return engagementScore;
}
}
@@ -0,0 +1,25 @@
package org.tests.dtomapping;
/**
* Plain DTO with no framework attachment - mapping target for {@code ContactSummary}, the
* worked example of a {@code @Formula2}-backed computed property flowing into a DTO via a
* dedicated {@code @View}-mapped read entity rather than a bespoke ad-hoc-SQL-on-DTO feature.
*/
public class ContactSummaryDto {
private final Long id;
private final String fullName;
public ContactSummaryDto(Long id, String fullName) {
this.id = id;
this.fullName = fullName;
}
public Long getId() {
return id;
}
public String getFullName() {
return fullName;
}
}
@@ -0,0 +1,39 @@
package org.tests.dtomapping;
import java.util.List;
/**
* Plain DTO with no framework attachment - target of the nested DTO graph mapping spike for
* issue #2540. Nests {@link AddressDto} for the {@code billingAddress} ToOne relationship and
* {@link ContactDto} for the {@code contacts} ToMany relationship.
*/
public class CustomerDto {
private final Long id;
private final String name;
private final AddressDto billingAddress;
private final List<ContactDto> contacts;
public CustomerDto(Long id, String name, AddressDto billingAddress, List<ContactDto> contacts) {
this.id = id;
this.name = name;
this.billingAddress = billingAddress;
this.contacts = contacts;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public AddressDto getBillingAddress() {
return billingAddress;
}
public List<ContactDto> getContacts() {
return contacts;
}
}
@@ -0,0 +1,28 @@
package org.tests.dtomapping;
/**
* Shallow reference DTO for {@code Customer} - deliberately does <b>not</b> include a
* {@code contacts} field (or any other back-reference), so that {@link ContactDto#getCustomer()}
* can point back to its owning customer without re-introducing the {@code Customer -> Contact ->
* Customer} cycle. This is the hand-written analogue of the {@code @DtoRef} escape hatch
* described in docs/dto-mapping-design.md, just modelled as a distinct shallow DTO type here
* rather than an id-only field.
*/
public class CustomerRefDto {
private final Long id;
private final String name;
public CustomerRefDto(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
@@ -0,0 +1,14 @@
package org.tests.dtomapping;
/**
* Simple, deterministic (non-cryptographic) {@link SecretCipher} used purely to prove instance
* dispatch actually goes through the resolved instance (rather than being coincidentally
* static-callable) - reverses the encoded string.
*/
public final class ReverseSecretCipher implements SecretCipher {
@Override
public String decode(String encoded) {
return new StringBuilder(encoded).reverse().toString();
}
}
@@ -0,0 +1,12 @@
package org.tests.dtomapping;
/**
* Stand-in for a conversion needing a real dependency (e.g. {@code AES256Cipher}) - the
* {@code @DtoConvert} instance-dispatch case (requirement r14): an instance is resolved via
* {@code DtoConverterManager.get(SecretCipher.class)}, which must be registered (see
* {@link ContactConversionDbConfigProvider}) before the {@code Database} is built.
*/
public interface SecretCipher {
String decode(String encoded);
}
@@ -0,0 +1,47 @@
package org.tests.dtomapping;
import io.ebean.DB;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Address;
import org.tests.dtomapping.model.Customer;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Isolated test for the generated {@code AddressDtoMapper} - validates it can be used standalone,
* independent of {@code CustomerDtoMapper}, confirming the per-type mapper composition approach.
* The mapper itself is generated by {@code querybean-generator} from the {@code @DtoMapping}
* registered in {@code package-info.java} - see docs/dto-mapping-design.md (requirement
* dto-codegen-mapper).
*/
class TestAddressDtoMapping {
private final AddressDtoMapper mapper = new AddressDtoMapper();
@Test
void mapAddress_expectFieldsCopied() {
Address address = new Address("12 Test Street", "Auckland");
address.save();
Customer customer = new Customer("Acme");
customer.setBillingAddress(address);
customer.save();
Customer found = DB.find(Customer.class)
.setUnmodifiable(true)
.fetch("billingAddress", "id,line1,city")
.where().idEq(customer.getId())
.findOne();
AddressDto dto = mapper.map(found.getBillingAddress());
assertThat(dto.getId()).isEqualTo(address.getId());
assertThat(dto.getLine1()).isEqualTo(address.getLine1());
assertThat(dto.getCity()).isEqualTo(address.getCity());
}
@Test
void mapAddress_whenNull_expectNull() {
assertThat(mapper.map(null)).isNull();
}
}
@@ -0,0 +1,75 @@
package org.tests.dtomapping;
import io.ebean.DB;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Address;
import org.tests.dtomapping.model.Contact;
import org.tests.dtomapping.model.Customer;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Validates the generated {@code ContactDtoMapper} standalone, and specifically the identity
* de-duplication behaviour of {@code io.ebean.DtoMapContext} (requirements r1/r3): several
* {@link Contact}s sharing the same parent {@link Customer} must map their {@code customer}
* back-reference to the exact same {@link CustomerRefDto} instance rather than each producing an
* equal-but-distinct copy.
* <p>
* {@link CustomerRefDto} exists (rather than reusing {@link CustomerDto}) so this back-reference
* doesn't reintroduce the {@code Customer -> Contact -> Customer} cycle.
*/
class TestContactDtoGraphMapping {
private final ContactDtoMapper mapper = new ContactDtoMapper();
@Test
void mapContacts_sharingSameCustomer_expectIdenticalCustomerRefInstance() {
Address address = new Address("1 High Street", "Wellington");
address.save();
Customer customer = new Customer("Acme");
customer.setBillingAddress(address);
customer.save();
new Contact("Jane", "Doe", customer).save();
new Contact("John", "Doe", customer).save();
Customer found = DB.find(Customer.class)
.setUnmodifiable(true)
.select("id,name")
.fetch("contacts", "id,firstName,lastName,active,customer")
.fetch("contacts.customer", "id,name")
.fetch("contacts.customer.billingAddress", "id,city")
.where().idEq(customer.getId())
.findOne();
List<Contact> contacts = found.getContacts();
assertThat(contacts.size()).isGreaterThan(1);
List<ContactDto> dtos = mapper.mapList(contacts);
assertThat(dtos).hasSameSizeAs(contacts);
for (int i = 0; i < dtos.size(); i++) {
assertThat(dtos.get(i).getFirstName()).isEqualTo(contacts.get(i).getFirstName());
// direct boolean scalar mapping - source's isActive() (not a guessed getActive())
assertThat(dtos.get(i).isActive()).isTrue();
assertThat(dtos.get(i).getCustomer().getId()).isEqualTo(customer.getId());
assertThat(dtos.get(i).getCustomer().getName()).isEqualTo(customer.getName());
// @DtoRef (id-only) and @DtoPath (multi-hop, through the customer association) properties
assertThat(dtos.get(i).getCustomerId()).isEqualTo(customer.getId());
assertThat(dtos.get(i).getCustomerCity()).isEqualTo("Wellington");
}
// identity de-dup: every contact's customer back-reference is the exact same DTO instance
CustomerRefDto first = dtos.get(0).getCustomer();
for (ContactDto dto : dtos) {
assertThat(dto.getCustomer()).isSameAs(first);
}
}
@Test
void mapContact_whenSourceNull_expectNullDto() {
assertThat(mapper.map(null)).isNull();
}
}
@@ -0,0 +1,78 @@
package org.tests.dtomapping;
import io.ebean.DB;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Contact;
import org.tests.dtomapping.model.ContactStats;
import org.tests.dtomapping.model.Customer;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Worked example: {@code @Sum}/{@code @Aggregation} group-by formulas modelled as a second
* {@code @Entity @View} read entity over the same {@code contact} table, mapped to a flat DTO.
* See docs/dto-mapping-design.md, "Aggregate/group-by computed properties".
*/
class TestContactStatsDtoMapping {
@Test
void mapContactStats_expectCountAndSumGroupedByCustomer() {
Customer customerA = new Customer("Quill Quarry");
customerA.save();
contact(customerA, "Ann", "Anders", 10);
contact(customerA, "Bert", "Baxter", 20);
contact(customerA, "Cleo", "Cross", 30);
Customer customerB = new Customer("Rill Ridge");
customerB.save();
contact(customerB, "Dom", "Dune", 5);
contact(customerB, "Eve", "Ellis", 15);
ContactStatsDto statsA = DB.find(ContactStats.class)
.where().eq("customer.id", customerA.getId())
.mapTo(ContactStatsDto.class)
.findOne();
assertThat(statsA).isNotNull();
assertThat(statsA.getCustomerId()).isEqualTo(customerA.getId());
assertThat(statsA.getContactCount()).isEqualTo(3);
assertThat(statsA.getEngagementScore()).isEqualTo(60);
ContactStatsDto statsB = DB.find(ContactStats.class)
.where().eq("customer.id", customerB.getId())
.mapTo(ContactStatsDto.class)
.findOne();
assertThat(statsB).isNotNull();
assertThat(statsB.getCustomerId()).isEqualTo(customerB.getId());
assertThat(statsB.getContactCount()).isEqualTo(2);
assertThat(statsB.getEngagementScore()).isEqualTo(20);
}
@Test
void mapContactStats_findList_expectOneRowPerCustomer() {
Customer customer = new Customer("Sable Springs");
customer.save();
contact(customer, "Fay", "Finch", 7);
contact(customer, "Gus", "Gray", 13);
List<ContactStatsDto> list = DB.find(ContactStats.class)
.where().eq("customer.id", customer.getId())
.mapTo(ContactStatsDto.class)
.findList();
assertThat(list).hasSize(1);
ContactStatsDto dto = list.get(0);
assertThat(dto.getCustomerId()).isEqualTo(customer.getId());
assertThat(dto.getContactCount()).isEqualTo(2);
assertThat(dto.getEngagementScore()).isEqualTo(20);
}
private void contact(Customer customer, String firstName, String lastName, int engagementScore) {
Contact contact = new Contact(firstName, lastName, customer);
contact.setEngagementScore(engagementScore);
contact.save();
}
}
@@ -0,0 +1,44 @@
package org.tests.dtomapping;
import io.ebean.DB;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Contact;
import org.tests.dtomapping.model.ContactSummary;
import org.tests.dtomapping.model.Customer;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Worked example: a Blaze-Persistence-style computed DTO property ({@code fullName}) achieved
* via a {@code @View}-mapped read entity ({@link ContactSummary}, pointed at the same table as
* {@link Contact}) carrying a {@code @Formula2} computed property, mapped into a plain DTO
* ({@link ContactSummaryDto}) with the existing {@code @DtoMapping} machinery - no new
* ad-hoc-SQL-on-DTO annotation needed. See docs/dto-mapping-design.md, "Ad-hoc computed/formula
* properties: model as {@code @Entity @View}/{@code @Sql}, not ad-hoc SQL-on-DTO".
*/
class TestContactSummaryDtoMapping {
private final ContactSummaryDtoMapper mapper = new ContactSummaryDtoMapper();
@Test
void mapContactSummary_expectComputedFullName() {
Customer customer = new Customer("Acme");
customer.save();
new Contact("Zenith", "Zolton", customer).save();
ContactSummary summary = DB.find(ContactSummary.class)
.setUnmodifiable(true)
.where().eq("firstName", "Zenith").eq("lastName", "Zolton")
.findOne();
ContactSummaryDto dto = mapper.map(summary);
assertThat(dto.getId()).isEqualTo(summary.getId());
assertThat(dto.getFullName()).isEqualTo("Zenith Zolton");
}
@Test
void mapContactSummary_whenNull_expectNull() {
assertThat(mapper.map(null)).isNull();
}
}
@@ -0,0 +1,97 @@
package org.tests.dtomapping;
import io.ebean.DB;
import io.ebean.LazyInitialisationException;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Address;
import org.tests.dtomapping.model.Contact;
import org.tests.dtomapping.model.Customer;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* End-to-end validation of the generated nested (ToOne + ToMany) DTO graph mapping for issue
* #2540 - the key milestone for requirement dto-codegen-mapper: {@code CustomerDtoMapper} (and
* its nested {@code AddressDtoMapper}/{@code ContactDtoMapper}/{@code CustomerRefDtoMapper}) are
* entirely generated by {@code querybean-generator} from the {@code @DtoMapping} declarations in
* {@code package-info.java} - no hand-written mapper code exists in this module.
*/
class TestCustomerDtoGraphMapping {
private final CustomerDtoMapper mapper = new CustomerDtoMapper();
@Test
void mapCustomerWithBillingAddress_whenFetched_expectPopulatedDto() {
Address address = new Address("12 Test Street", "Auckland");
address.save();
Customer customer = new Customer("Acme");
customer.setBillingAddress(address);
customer.save();
new Contact("Jane", "Doe", customer).save();
new Contact("John", "Doe", customer).save();
List<Customer> customers = DB.find(Customer.class)
.setUnmodifiable(true)
.select("id,name")
.fetch("billingAddress", "id,line1,city")
.fetch("contacts", "id,firstName,lastName,active,customer")
.fetch("contacts.customer", "id,name")
.fetch("contacts.customer.billingAddress", "id,city")
.where().idEq(customer.getId())
.findList();
List<CustomerDto> dtos = mapper.mapList(customers);
assertThat(dtos).hasSameSizeAs(customers);
for (int i = 0; i < dtos.size(); i++) {
Customer source = customers.get(i);
CustomerDto dto = dtos.get(i);
assertThat(dto.getId()).isEqualTo(source.getId());
assertThat(dto.getName()).isEqualTo(source.getName());
assertThat(dto.getBillingAddress().getId()).isEqualTo(source.getBillingAddress().getId());
assertThat(dto.getBillingAddress().getLine1()).isEqualTo(source.getBillingAddress().getLine1());
assertThat(dto.getBillingAddress().getCity()).isEqualTo(source.getBillingAddress().getCity());
assertThat(dto.getContacts()).hasSameSizeAs(source.getContacts());
for (int c = 0; c < dto.getContacts().size(); c++) {
assertThat(dto.getContacts().get(c).getFirstName()).isEqualTo(source.getContacts().get(c).getFirstName());
// the ToMany contact's back-reference resolves to the very same CustomerRefDto instance
// as every sibling contact under this customer - identity de-dup (requirement r3/r1)
assertThat(dto.getContacts().get(c).getCustomer().getId()).isEqualTo(dto.getId());
}
assertThat(dto.getContacts().get(0).getCustomer()).isSameAs(dto.getContacts().get(1).getCustomer());
}
}
@Test
void mapCustomer_whenBillingAddressNotFetched_expectFailFastNotSilentNullOrLazyLoad() {
Customer customer = new Customer("Acme");
customer.save();
// deliberately does NOT fetch billingAddress - mismatch between DTO shape and query
Customer found = DB.find(Customer.class)
.setUnmodifiable(true)
.select("id,name")
.where().idEq(customer.getId())
.findOne();
// The generated mapper has no special-case handling for this - it just calls
// getBillingAddress(), which fails fast because the unmodifiable+disableLazyLoading graph
// refuses to silently lazy load or return null for an unloaded property. This is the
// fail-fast guarantee (requirement r7) coming "for free" out of the mapTo pipeline design.
assertThatThrownBy(() -> mapper.map(found))
.isInstanceOf(LazyInitialisationException.class)
.hasMessageContaining("Property not loaded: billingAddress");
}
@Test
void mapCustomer_whenSourceNull_expectNullDto() {
assertThat(mapper.map(null)).isNull();
}
}
@@ -0,0 +1,48 @@
package org.tests.dtomapping;
import io.ebean.DB;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Contact;
import org.tests.dtomapping.model.Customer;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Validates builder-based target construction (requirement r18, "Section G" in
* docs/dto-mapping-requirements.md) - {@link ContactBuilderDto} is registered with
* {@code @DtoMapping(builder = ALWAYS)}, forcing the generated {@code ContactBuilderDtoMapper} to
* construct the target via {@code ContactBuilderDto.builder()....build()} rather than a
* positional constructor.
*/
class TestDtoBuilderConstruction {
private final ContactBuilderDtoMapper mapper = new ContactBuilderDtoMapper();
@Test
void mapTo_expectBuilderConstructedDto() {
Customer customer = new Customer("Acme");
customer.save();
Contact contact = new Contact("Jane", "Doe", customer);
contact.setStatus((short) 1);
contact.setSecretCode("shh");
contact.save();
ContactBuilderDto dto = DB.find(Contact.class)
.where().idEq(contact.getId())
.mapTo(ContactBuilderDto.class)
.findOne();
assertThat(dto).isNotNull();
assertThat(dto.getId()).isEqualTo(contact.getId());
assertThat(dto.getFirstName()).isEqualTo("Jane");
assertThat(dto.getLastName()).isEqualTo("Doe");
assertThat(dto.getStatus()).isEqualTo((short) 1);
assertThat(dto.getSecretCode()).isEqualTo("shh");
assertThat(dto.getCustomerId()).isEqualTo(customer.getId());
}
@Test
void map_whenSourceNull_expectNullDto() {
assertThat(mapper.map(null)).isNull();
}
}
@@ -0,0 +1,78 @@
package org.tests.dtomapping;
import io.ebean.DB;
import io.ebean.DtoConverterManager;
import jakarta.persistence.PersistenceException;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Contact;
import org.tests.dtomapping.model.Customer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Validates {@code @DtoConvert} (requirements r13/r14, "Section E: Custom property conversion"
* in docs/dto-mapping-requirements.md) - both static dispatch (no registration,
* {@link ContactConversions#toActive}) and instance dispatch (resolved via
* {@link DtoConverterManager}, {@link SecretCipher#decode}).
* <p>
* {@link SecretCipher} is registered by {@link ContactConversionDbConfigProvider} (a
* {@code DatabaseConfigProvider}, discovered via ServiceLoader before the {@code Database} is
* built) rather than in a test {@code @BeforeAll} - {@code EbeanDtoMapperRegister}'s mapper
* fields (including {@code ContactConversionDtoMapper}'s {@code SecretCipher} field) are all
* constructed eagerly during {@code Database} startup, which can be triggered by whichever test
* class in this module happens to run first.
*/
class TestDtoConvert {
@Test
void mapTo_expectStaticAndInstanceConversionsApplied() {
Customer customer = new Customer("Acme");
customer.save();
Contact contact = new Contact("Jane", "Doe", customer);
contact.setStatus((short) 1);
contact.setSecretCode("terces");
contact.save();
ContactConversionDto dto = DB.find(Contact.class)
.where().idEq(contact.getId())
.mapTo(ContactConversionDto.class)
.findOne();
assertThat(dto).isNotNull();
assertThat(dto.getFirstName()).isEqualTo("Jane");
// static dispatch - Short(1) -> true, no registration needed
assertThat(dto.isActive()).isTrue();
// instance dispatch - resolved via DtoConverterManager, ReverseSecretCipher reverses the string
assertThat(dto.getSecretCode()).isEqualTo("secret");
}
@Test
void mapTo_whenStatusZero_expectInactive() {
Customer customer = new Customer("Beta");
customer.save();
Contact contact = new Contact("John", "Smith", customer);
contact.setStatus((short) 0);
contact.setSecretCode("abc");
contact.save();
ContactConversionDto dto = DB.find(Contact.class)
.where().idEq(contact.getId())
.mapTo(ContactConversionDto.class)
.findOne();
assertThat(dto.isActive()).isFalse();
}
@Test
void get_whenTypeNotRegistered_expectPersistenceException() {
assertThatThrownBy(() -> DtoConverterManager.get(UnregisteredConverter.class))
.isInstanceOf(PersistenceException.class)
.hasMessageContaining("No " + UnregisteredConverter.class.getName() + " registered")
.hasMessageContaining("DtoConverterManager.put(UnregisteredConverter.class, ...)");
}
/** Deliberately never registered with {@link DtoConverterManager} - used to prove {@link DtoConverterManager#get} fails fast. */
private interface UnregisteredConverter {
}
}
@@ -0,0 +1,61 @@
package org.tests.dtomapping;
import io.ebean.DtoMapper;
import io.ebean.DtoMapperManager;
import io.ebean.DtoConverterManager;
import jakarta.persistence.PersistenceException;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Customer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* {@link DtoMapperManager} has no dependency on {@code Database} - its constructor only does
* {@code ServiceLoader} discovery of generated {@code DtoMapperRegister}s - so it can be
* constructed standalone, independent of (or before) any {@code Database}.
* <p>
* A standalone construction like this bypasses the {@code DatabaseConfigProvider} hook (that
* hook is specifically about {@code Database} startup ordering) - so any {@code @DtoConvert}
* instance-dispatch converter this module's mappers need (here, {@link SecretCipher}) must
* already be registered via {@link DtoConverterManager#put} by the application itself before
* constructing its own {@code DtoMapperManager}, exactly as it would before building a
* {@code Database}.
*/
class TestDtoMapperManager {
private final DtoMapperManager manager = manager();
private static DtoMapperManager manager() {
DtoConverterManager.put(SecretCipher.class, new ReverseSecretCipher());
return new DtoMapperManager();
}
@Test
void get_byMapperType_returnsTheGeneratedMapperInstance() {
CustomerDtoMapper mapper = manager.get(CustomerDtoMapper.class);
assertThat(mapper).isNotNull();
// repeated lookups return the exact same cached instance
assertThat(manager.get(CustomerDtoMapper.class)).isSameAs(mapper);
}
@Test
void get_andMapperFor_resolveToTheSameUnderlyingMapperInstance() {
CustomerDtoMapper byType = manager.get(CustomerDtoMapper.class);
DtoMapper<Customer, CustomerDto> byPair = manager.mapperFor(Customer.class, CustomerDto.class);
assertThat(byType).isSameAs(byPair);
}
@Test
void get_whenMapperTypeNotRegistered_expectPersistenceException() {
assertThatThrownBy(() -> manager.get(UnregisteredMapper.class))
.isInstanceOf(PersistenceException.class)
.hasMessageContaining("No DtoMapper of type " + UnregisteredMapper.class.getName() + " registered");
}
/** Deliberately never generated - used to prove {@link DtoMapperManager#get} fails fast. */
private interface UnregisteredMapper {
}
}
@@ -0,0 +1,82 @@
package org.tests.dtomapping;
import io.ebean.Database;
import io.ebean.DatabaseBuilder;
import io.ebean.DtoConverterManager;
import io.ebean.DtoMapperManager;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Address;
import org.tests.dtomapping.model.Contact;
import org.tests.dtomapping.model.Customer;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Validates that {@link DtoMapperManager} can be constructed independently of any {@code
* Database} (its constructor only does {@code ServiceLoader} discovery of generated {@code
* DtoMapperRegister}s) and, once registered via {@code
* DatabaseBuilder.putServiceObject(DtoMapperManager.class, ...)} before {@code build()}, is the
* instance actually used internally by {@code query.mapTo(...)} - so the very same instance is
* also directly usable elsewhere, e.g. wired up for constructor injection into application
* services (replacing hand-written mapper wiring like {@code DriverMapper}/{@code DriverService}
* in the motivating central-access example).
*/
class TestDtoMapperManagerSharing {
@Test
void putServiceObject_sharesSameManagerInstance_betweenMapToAndDirectInjection() {
// a standalone construction bypasses the DatabaseConfigProvider hook, so this module's
// @DtoConvert instance-dispatch converter must already be registered - see TestDtoMapperManager
DtoConverterManager.put(SecretCipher.class, new ReverseSecretCipher());
DtoMapperManager sharedManager = new DtoMapperManager();
Database db = buildSecondaryDatabase(sharedManager);
try {
Customer customer = new Customer("Acme");
db.save(customer);
// query.mapTo(...) against this secondary Database resolves via the supplied manager
CustomerDto dto = db.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(CustomerDto.class)
.findOne();
assertThat(dto).isNotNull();
assertThat(dto.getName()).isEqualTo("Acme");
// the exact same manager instance we passed into putServiceObject resolves the same
// generated mapper by its own concrete type - directly usable for e.g. constructor
// injection into application services, independent of query.mapTo()
CustomerDtoMapper mapper = sharedManager.get(CustomerDtoMapper.class);
assertThat(mapper.map(customer).getName()).isEqualTo("Acme");
} finally {
db.shutdown();
}
}
private static Database buildSecondaryDatabase(DtoMapperManager sharedManager) {
DatabaseBuilder config = Database.builder();
config.setName("dtoMapperManagerSharing");
config.setRegister(false);
config.setDefaultServer(false);
config.setDdlGenerate(true);
config.setDdlRun(true);
config.setDdlExtra(false);
Properties properties = new Properties();
properties.setProperty("datasource.dtoMapperManagerSharing.username", "sa");
properties.setProperty("datasource.dtoMapperManagerSharing.password", "");
properties.setProperty("datasource.dtoMapperManagerSharing.databaseUrl", "jdbc:h2:mem:dtoMapperManagerSharing");
properties.setProperty("datasource.dtoMapperManagerSharing.databaseDriver", "org.h2.Driver");
config.loadFromProperties(properties);
config.addClass(Customer.class);
config.addClass(Contact.class);
config.addClass(Address.class);
config.putServiceObject(DtoMapperManager.class, sharedManager);
return config.build();
}
}
@@ -0,0 +1,53 @@
package org.tests.dtomapping;
import io.ebean.DtoMapper;
import io.ebean.config.DtoMapperRegister;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Address;
import org.tests.dtomapping.model.Contact;
import org.tests.dtomapping.model.Customer;
import java.util.ServiceLoader;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Validates the generated {@code EbeanDtoMapperRegister} is discoverable via
* {@code META-INF/services/io.ebean.config.DtoMapperRegister} and correctly dispatches on the
* (source, target) pair - see docs/dto-mapping-design.md (requirement dto-codegen-mapper).
*/
class TestDtoMapperRegister {
private final DtoMapperRegister register = ServiceLoader.load(DtoMapperRegister.class)
.findFirst()
.orElseThrow(() -> new IllegalStateException("No DtoMapperRegister found via ServiceLoader"));
@Test
void mapperFor_whenSourceAndTargetMatch_expectMapper() {
DtoMapper<Customer, CustomerDto> mapper = register.mapperFor(Customer.class, CustomerDto.class);
assertThat(mapper).isInstanceOf(CustomerDtoMapper.class);
}
@Test
void mapperFor_whenDifferentTargetForSameSource_expectDifferentMapper() {
DtoMapper<Customer, CustomerRefDto> refMapper = register.mapperFor(Customer.class, CustomerRefDto.class);
assertThat(refMapper).isInstanceOf(CustomerRefDtoMapper.class);
}
@Test
void mapperFor_whenSourceDoesNotMatchTarget_expectNull() {
// CustomerDto only maps from Customer, not Contact - so this pair is unregistered.
DtoMapper<Contact, CustomerDto> mapper = register.mapperFor(Contact.class, CustomerDto.class);
assertThat(mapper).isNull();
}
@Test
void mapperFor_whenUnregisteredPair_expectNull() {
DtoMapper<Address, CustomerDto> mapper = register.mapperFor(Address.class, CustomerDto.class);
assertThat(mapper).isNull();
}
}
@@ -0,0 +1,72 @@
package org.tests.dtomapping;
import io.ebean.DB;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Address;
import org.tests.dtomapping.model.Contact;
import org.tests.dtomapping.model.Customer;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Validates named mapper variants (requirement r19, "Section G" in
* docs/dto-mapping-requirements.md) - {@code CustomerDto} is registered twice: the base mapping,
* plus {@code @DtoMapping(name = "noContacts", exclude = "contacts")}. Both share the very same
* generated {@code CustomerDtoMapper} class; the variant is exposed as its {@code noContacts()}
* accessor and selected via the {@code query.mapTo(Class, DtoMapper)} overload (requirement
* r19's API addition) rather than a string-based lookup.
*/
class TestDtoMapperVariants {
private final CustomerDtoMapper mapper = new CustomerDtoMapper();
@Test
void mapTo_withNoContactsVariant_expectContactsExcludedButAddressPopulated() {
Address address = new Address("12 Test Street", "Auckland");
address.save();
Customer customer = new Customer("Acme");
customer.setBillingAddress(address);
customer.save();
new Contact("Jane", "Doe", customer).save();
CustomerDto dto = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(CustomerDto.class, mapper.noContacts())
.findOne();
assertThat(dto).isNotNull();
assertThat(dto.getId()).isEqualTo(customer.getId());
assertThat(dto.getName()).isEqualTo("Acme");
// billingAddress is NOT excluded by this variant - still populated
assertThat(dto.getBillingAddress()).isNotNull();
assertThat(dto.getBillingAddress().getCity()).isEqualTo("Auckland");
// contacts IS excluded by this variant - empty, not null, and no fetch/join issued for it
assertThat(dto.getContacts()).isEmpty();
}
@Test
void mapTo_baseMapping_expectContactsStillPopulated() {
Customer customer = new Customer("Acme");
customer.save();
new Contact("Jane", "Doe", customer).save();
new Contact("John", "Doe", customer).save();
CustomerDto dto = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(CustomerDto.class)
.findOne();
assertThat(dto.getContacts()).hasSize(2);
}
@Test
void noContacts_expectOwnFetchGroupOmitsContactsFetch() {
assertThat(mapper.noContacts().fetchGroup()).isNotNull();
assertThat(mapper.fetchGroup()).isNotSameAs(mapper.noContacts().fetchGroup());
}
@Test
void noContacts_whenSourceNull_expectNullDto() {
assertThat(mapper.noContacts().map(null)).isNull();
}
}
@@ -0,0 +1,59 @@
package org.tests.dtomapping;
import io.ebean.DB;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Contact;
import org.tests.dtomapping.model.Customer;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Validates {@code @DtoMixin} - overlaying {@code @DtoPath}/{@code @DtoConvert} annotations from
* {@link ContactMixinDtoMixin} onto {@link ContactMixinDto}, which carries no annotations of its
* own at all (a stand-in for a DTO generated elsewhere, e.g. from an OpenAPI spec).
*/
class TestDtoMixin {
@Test
void mapTo_expectMixinAnnotationsApplied() {
Customer customer = new Customer("Acme");
customer.save();
Contact contact = new Contact("Jane", "Doe", customer);
contact.setStatus((short) 1);
contact.setSecretCode("terces");
contact.save();
ContactMixinDto dto = DB.find(Contact.class)
.where().idEq(contact.getId())
.mapTo(ContactMixinDto.class)
.findOne();
assertThat(dto).isNotNull();
assertThat(dto.getFirstName()).isEqualTo("Jane");
// @DtoPath("status") + static @DtoConvert, both declared on the mixin method
assertThat(dto.isActive()).isTrue();
// instance @DtoConvert declared on the mixin method, resolved via DtoConverterManager
assertThat(dto.getSecretCode()).isEqualTo("secret");
// @DtoPath("customer") rename on the mixin, resolving to the nested CustomerRefDto mapper
// rather than a raw (type-mismatched) scalar getter call
assertThat(dto.getOwner()).isNotNull();
assertThat(dto.getOwner().getName()).isEqualTo("Acme");
}
@Test
void mapTo_whenStatusZero_expectInactive() {
Customer customer = new Customer("Beta");
customer.save();
Contact contact = new Contact("John", "Smith", customer);
contact.setStatus((short) 0);
contact.setSecretCode("abc");
contact.save();
ContactMixinDto dto = DB.find(Contact.class)
.where().idEq(contact.getId())
.mapTo(ContactMixinDto.class)
.findOne();
assertThat(dto.isActive()).isFalse();
}
}
@@ -0,0 +1,81 @@
package org.tests.dtomapping;
import io.ebean.DtoMapContext;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Address;
import org.tests.dtomapping.model.Contact;
import org.tests.dtomapping.model.Customer;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Every generated {@code XxxDtoMapper} is a plain public class - a public no-arg constructor
* (or an explicit constructor taking its nested-mapper/converter dependencies) and a public
* {@code map(...)}/{@code mapList(...)} - so it can be called directly, without going through
* {@code query.mapTo()} at all. This is useful both as a normal API (map an entity graph you
* already have on hand, from wherever it came from) and for fast, isolated unit tests of the
* mapping logic itself - construct plain entities, call the mapper, assert on the DTO, with no
* query execution and no {@code DtoMapperManager}/{@code ServiceLoader} registry involved.
* <p>
* See also {@link TestCustomerDtoGraphMapping}, which does the same thing against a manually run
* (non-{@code mapTo}) query.
*/
class TestMapperManualUsage {
@Test
void map_directlyOnEntitiesJustCreated_withoutQueryOrMapTo() {
Address address = new Address("1 Queen Street", "Auckland");
address.save();
Customer customer = new Customer("Acme");
customer.setBillingAddress(address);
customer.save();
Contact contact = new Contact("Jane", "Doe", customer);
contact.save();
customer.getContacts().add(contact);
// the very same in-memory objects just saved - no DB.find()/mapTo() query at all
CustomerDto dto = new CustomerDtoMapper().map(customer);
assertThat(dto.getName()).isEqualTo("Acme");
assertThat(dto.getBillingAddress().getCity()).isEqualTo("Auckland");
assertThat(dto.getContacts()).hasSize(1);
assertThat(dto.getContacts().get(0).getFirstName()).isEqualTo("Jane");
}
@Test
void mapList_sharedContext_identityDedupWithoutMapTo() {
Customer customer = new Customer("Acme");
customer.save();
Contact jane = new Contact("Jane", "Doe", customer);
jane.save();
Contact john = new Contact("John", "Doe", customer);
john.save();
DtoMapContext context = new DtoMapContext();
List<ContactDto> dtos = new ContactDtoMapper().mapList(List.of(jane, john), context);
// both contacts share the same Customer instance - mapped via the same shared context - so
// the nested CustomerRefDto instance is de-duplicated (same reference, not just equal)
assertThat(dtos.get(0).getCustomer()).isSameAs(dtos.get(1).getCustomer());
}
@Test
void map_withTestDoubleConverter_bypassingDtoConverterManager() {
// the explicit constructor lets a test pass its own SecretCipher directly - no need to
// register anything with DtoConverterManager for this kind of isolated unit test
SecretCipher upperCasingTestCipher = String::toUpperCase;
Customer customer = new Customer("Acme");
customer.save();
Contact contact = new Contact("Jane", "Doe", customer);
contact.setStatus((short) 1);
contact.setSecretCode("shh");
contact.save();
ContactConversionDto dto = new ContactConversionDtoMapper(upperCasingTestCipher).map(contact);
assertThat(dto.isActive()).isTrue();
assertThat(dto.getSecretCode()).isEqualTo("SHH");
}
}
@@ -0,0 +1,264 @@
package org.tests.dtomapping;
import io.ebean.DB;
import io.ebean.LazyInitialisationException;
import io.ebean.PagedList;
import io.ebean.Transaction;
import io.ebean.test.LoggedSql;
import jakarta.persistence.PersistenceException;
import org.junit.jupiter.api.Test;
import org.tests.dtomapping.model.Address;
import org.tests.dtomapping.model.Contact;
import org.tests.dtomapping.model.Customer;
import org.tests.dtomapping.model.query.QCustomer;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* End-to-end validation of {@code query.mapTo(Dto.class)} - the full-scope runtime wiring for
* issue #2540. Unlike {@link TestCustomerDtoGraphMapping} (which calls the generated mapper
* directly against a manually fetched entity graph), these tests exercise the entire pipeline:
* resolving the generated {@code DtoMapper} via {@code ServiceLoader}, automatically applying its
* {@code fetchGroup()} (derived from the DTO's declared shape - no manual {@code .select()}/
* {@code .fetch()} needed), forcing {@code setUnmodifiable(true)}, then mapping the result.
*/
class TestQueryMapTo {
@Test
void mapTo_findList_expectAutoFetchSpecAppliedAndPopulatedDtoGraph() {
Address address = new Address("12 Test Street", "Auckland");
address.save();
Customer customer = new Customer("Acme");
customer.setBillingAddress(address);
customer.save();
new Contact("Jane", "Doe", customer).save();
new Contact("John", "Doe", customer).save();
// no .select()/.fetch() at all - the fetch spec is derived from CustomerDto's shape
List<CustomerDto> dtos = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(CustomerDto.class)
.findList();
assertThat(dtos).hasSize(1);
CustomerDto dto = dtos.get(0);
assertThat(dto.getId()).isEqualTo(customer.getId());
assertThat(dto.getName()).isEqualTo("Acme");
assertThat(dto.getBillingAddress().getLine1()).isEqualTo("12 Test Street");
assertThat(dto.getBillingAddress().getCity()).isEqualTo("Auckland");
assertThat(dto.getContacts()).hasSize(2);
assertThat(dto.getContacts().get(0).getCustomer()).isSameAs(dto.getContacts().get(1).getCustomer());
// @DtoRef (id-only) and @DtoPath (multi-hop) properties on ContactDto
assertThat(dto.getContacts().get(0).getCustomerId()).isEqualTo(customer.getId());
assertThat(dto.getContacts().get(0).getCustomerCity()).isEqualTo("Auckland");
}
@Test
void mapTo_findOne_expectPopulatedDto() {
Customer customer = new Customer("Beta");
customer.save();
CustomerDto dto = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(CustomerDto.class)
.findOne();
assertThat(dto).isNotNull();
assertThat(dto.getName()).isEqualTo("Beta");
}
@Test
void mapTo_findOneOrEmpty_whenNoMatch_expectEmpty() {
Optional<CustomerDto> dto = new QCustomer()
.id.eq(-1L)
.mapTo(CustomerDto.class)
.findOneOrEmpty();
assertThat(dto).isEmpty();
}
@Test
void mapTo_whenPairNotRegistered_expectPersistenceException() {
assertThatThrownBy(() -> DB.find(Contact.class).mapTo(CustomerDto.class).findList())
.isInstanceOf(PersistenceException.class)
.hasMessageContaining("No DtoMapper registered");
}
@Test
void mapTo_findPagedList_expectPagedMappedDtos() {
new Customer("PageA").save();
new Customer("PageB").save();
new Customer("PageC").save();
PagedList<CustomerDto> paged = DB.find(Customer.class)
.where().startsWith("name", "Page")
.orderBy().asc("name")
.setFirstRow(0)
.setMaxRows(2)
.mapTo(CustomerDto.class)
.findPagedList();
assertThat(paged.getTotalCount()).isEqualTo(3);
assertThat(paged.hasNext()).isTrue();
assertThat(paged.hasPrev()).isFalse();
List<CustomerDto> page1 = paged.getList();
assertThat(page1).extracting(CustomerDto::getName).containsExactly("PageA", "PageB");
// getList() is cached - same instance on repeated calls
assertThat(paged.getList()).isSameAs(page1);
}
@Test
void mapTo_whenSelectAlreadySet_expectManualFetchSpecPreserved() {
Address address = new Address("1 Manual Street", "Wellington");
address.save();
Customer customer = new Customer("ManualSelect");
customer.setBillingAddress(address);
customer.save();
new Contact("Jane", "Doe", customer).save();
LoggedSql.start();
// manually tuned select() - deliberately narrower than CustomerDto's own fetch spec
// (excludes billingAddress and contacts) - this should be left untouched by mapTo()
var query = DB.find(Customer.class)
.select("name")
.where().idEq(customer.getId())
.mapTo(CustomerDto.class);
// mapper.fetchGroup() was NOT merged over the top of the manual select() - so mapping
// billingAddress (not fetched) fails fast rather than silently lazy loading it
assertThatThrownBy(query::findList)
.isInstanceOf(LazyInitialisationException.class)
.hasMessageContaining("billingAddress");
List<String> sql = LoggedSql.stop();
// only the manually selected properties were fetched - no join to address
assertThat(sql).hasSize(1);
assertThat(sql.get(0)).doesNotContain("o_address");
}
@Test
void mapTo_usingMaster_expectFluentReturnAndNoError() {
Customer customer = new Customer("MasterCo");
customer.save();
// usingMaster() can be called on the MappedQuery itself - e.g. on retry after a read-replica
// failure - without needing to rebuild the underlying query and call mapTo() again
var mapped = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(CustomerDto.class);
assertThat(mapped.usingMaster(false)).isSameAs(mapped);
assertThat(mapped.usingMaster(true)).isSameAs(mapped);
List<CustomerDto> dtos = mapped.findList();
assertThat(dtos).hasSize(1);
assertThat(dtos.get(0).getName()).isEqualTo("MasterCo");
}
@Test
void mapTo_usingTransaction_expectQueryRunsOnExplicitTransaction() {
Customer customer = new Customer("TxnCo");
customer.save();
try (Transaction txn = DB.beginTransaction()) {
List<CustomerDto> dtos = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(CustomerDto.class)
.usingTransaction(txn)
.findList();
assertThat(dtos).hasSize(1);
assertThat(dtos.get(0).getName()).isEqualTo("TxnCo");
txn.commit();
}
}
@Test
void mapTo_usingConnection_expectFluentReturn() {
Customer customer = new Customer("ConnCo");
customer.save();
try (Transaction txn = DB.beginTransaction()) {
var mapped = DB.find(Customer.class)
.where().idEq(customer.getId())
.mapTo(CustomerDto.class);
assertThat(mapped.usingConnection(txn.connection())).isSameAs(mapped);
List<CustomerDto> dtos = mapped.findList();
assertThat(dtos).hasSize(1);
assertThat(dtos.get(0).getName()).isEqualTo("ConnCo");
}
}
@Test
void mapTo_findStream_expectLazilyMappedDtoStream() {
Customer customerA = new Customer("StreamCoA");
customerA.save();
Customer customerB = new Customer("StreamCoB");
customerB.save();
List<String> names;
try (var stream = DB.find(Customer.class)
.where().in("name", "StreamCoA", "StreamCoB")
.orderBy().asc("name")
.mapTo(CustomerDto.class)
.findStream()) {
names = stream.map(CustomerDto::getName).collect(Collectors.toList());
}
assertThat(names).containsExactly("StreamCoA", "StreamCoB");
}
@Test
void mapTo_findStream_expectIdentityDedupSharedAcrossStream() {
Customer customer = new Customer("StreamDedupCo");
customer.save();
new Contact("Jane", "Doe", customer).save();
new Contact("John", "Doe", customer).save();
// both contacts share the same underlying Customer instance - the shared DtoMapContext used
// across the whole findStream() call should still de-duplicate to the same nested DTO
List<ContactDto> dtos;
try (var stream = DB.find(Contact.class)
.where().eq("customer", customer)
.orderBy().asc("firstName")
.mapTo(ContactDto.class)
.findStream()) {
dtos = stream.collect(Collectors.toList());
}
assertThat(dtos).hasSize(2);
assertThat(dtos.get(0).getCustomer()).isSameAs(dtos.get(1).getCustomer());
}
@Test
void mapTo_withFilterMany_expectFilteredContactsInDtoGraph() {
Customer customer = new Customer("FilterManyCo");
customer.save();
new Contact("Jane", "Doe", customer).save();
Contact inactive = new Contact("John", "Doe", customer);
inactive.setActive(false);
inactive.save();
// filterMany() combined with the DTO mapper's auto-applied fetchGroup() - contacts is part
// of CustomerDto's own fetch spec, filterMany() narrows the rows fetched for that relationship
List<CustomerDto> dtos = DB.find(Customer.class)
.filterMany("contacts").eq("active", true)
.where().idEq(customer.getId())
.mapTo(CustomerDto.class)
.findList();
assertThat(dtos).hasSize(1);
CustomerDto dto = dtos.get(0);
assertThat(dto.getContacts()).hasSize(1);
assertThat(dto.getContacts().get(0).getFirstName()).isEqualTo("Jane");
}
}
@@ -0,0 +1,60 @@
/**
* Registers the DTO mapping pairs for issue #2540's nested DTO mapping - triggers
* {@code querybean-generator} to generate a {@code XxxMapper} implementing
* {@code io.ebean.DtoMapper<Source, Target>} for each pair (see docs/dto-mapping-design.md).
* <p>
* {@code Customer -> CustomerRefDto} is a second, shallow mapping for {@code Customer} (in
* addition to the full {@code Customer -> CustomerDto} mapping) - it's what {@link
* org.tests.dtomapping.ContactDto#getCustomer()} maps to, so the back-reference doesn't
* reintroduce the {@code Customer -> Contact -> Customer} cycle that a full nested {@code
* CustomerDto} would create.
* <p>
* {@code ContactSummary -> ContactSummaryDto} is a worked example (see docs/dto-mapping-design.md,
* "Ad-hoc computed/formula properties") of the recommended pattern for a Blaze-Persistence-style
* computed DTO property: {@code ContactSummary} is a {@code @View}-mapped read entity (pointed at
* the plain {@code contact} table, no new DDL) with a {@code @Formula2}-computed {@code fullName}
* property, mapped to a plain DTO with no new ad-hoc-SQL-on-DTO annotation required.
* <p>
* {@code ContactStats -> ContactStatsDto} is a worked example (see docs/dto-mapping-design.md,
* "Aggregate/group-by computed properties") of the same {@code @View} pattern applied to Ebean's
* {@code @Sum}/{@code @Aggregation} group-by formulas - the Blaze-Persistence parallel being an
* {@code @EntityView} with {@code @Mapping("SIZE(...)")}/{@code @Mapping("SUM(...)")} correlated
* mappings.
* <p>
* {@code Contact -> ContactConversionDto} exercises {@code @DtoConvert} (requirements r13/r14,
* "Section E: Custom property conversion" in docs/dto-mapping-requirements.md) - both the
* static-dispatch (no registration) and instance-dispatch (via {@code DtoConverterManager}) cases.
* <p>
* {@code Contact -> ContactMixinDto} exercises {@code @DtoMixin} - {@link
* org.tests.dtomapping.ContactMixinDto} carries no annotations of its own at all;
* {@link org.tests.dtomapping.ContactMixinDtoMixin} overlays the {@code @DtoPath}/
* {@code @DtoConvert} annotations instead.
* <p>
* {@code Customer -> CustomerDto, name = "noContacts", exclude = "contacts"} exercises named
* variants (requirement r19) - a second, differently-shaped mapping sharing the very same
* generated {@code CustomerDtoMapper} class, exposed as its {@code noContacts()} accessor method,
* used via the new {@code query.mapTo(Class, DtoMapper)} overload.
* <p>
* {@code Contact -> ContactBuilderDto} exercises builder-based construction (requirement r18) -
* {@link org.tests.dtomapping.ContactBuilderDto} follows the {@code avaje-recordbuilder}
* convention (a static {@code builder()} factory + fluent setters + {@code build()}), forced via
* {@code builder = ALWAYS}.
*/
@DtoMapping(source = Customer.class, target = CustomerDto.class)
@DtoMapping(source = Customer.class, target = CustomerDto.class, name = "noContacts", exclude = "contacts")
@DtoMapping(source = Address.class, target = AddressDto.class)
@DtoMapping(source = Contact.class, target = ContactDto.class)
@DtoMapping(source = Customer.class, target = CustomerRefDto.class)
@DtoMapping(source = ContactSummary.class, target = ContactSummaryDto.class)
@DtoMapping(source = ContactStats.class, target = ContactStatsDto.class)
@DtoMapping(source = Contact.class, target = ContactConversionDto.class)
@DtoMapping(source = Contact.class, target = ContactMixinDto.class)
@DtoMapping(source = Contact.class, target = ContactBuilderDto.class, builder = DtoMapping.Builder.ALWAYS)
package org.tests.dtomapping;
import io.ebean.annotation.DtoMapping;
import org.tests.dtomapping.model.Address;
import org.tests.dtomapping.model.Contact;
import org.tests.dtomapping.model.ContactStats;
import org.tests.dtomapping.model.ContactSummary;
import org.tests.dtomapping.model.Customer;
@@ -0,0 +1 @@
org.tests.dtomapping.ContactConversionDbConfigProvider

Some files were not shown because too many files have changed in this diff Show More