mirror of
https://github.com/ebean-orm/ebean.git
synced 2026-09-24 19:30:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
805e309fb0 | ||
|
|
adb2e6a5d2 | ||
|
|
cbd67a6c22 | ||
|
|
30314f163d | ||
|
|
893dc599cd | ||
|
|
b065b030bc | ||
|
|
837ba91126 | ||
|
|
e18c664c98 | ||
|
|
636bb28df3 | ||
|
|
84a311e521 |
+3
-1
@@ -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) |
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
|
||||
|
||||
@@ -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)).
|
||||
@@ -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 -> 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 -> 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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+13
-3
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+274
@@ -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;
|
||||
}
|
||||
|
||||
+144
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+252
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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();
|
||||
}
|
||||
}
|
||||
+75
@@ -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();
|
||||
}
|
||||
}
|
||||
+78
@@ -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();
|
||||
}
|
||||
}
|
||||
+44
@@ -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();
|
||||
}
|
||||
}
|
||||
+97
@@ -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();
|
||||
}
|
||||
}
|
||||
+48
@@ -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 {
|
||||
}
|
||||
}
|
||||
+82
@@ -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;
|
||||
+1
@@ -0,0 +1 @@
|
||||
org.tests.dtomapping.ContactConversionDbConfigProvider
|
||||
@@ -0,0 +1,5 @@
|
||||
ebean:
|
||||
test:
|
||||
platform: h2
|
||||
ddlMode: dropCreate
|
||||
dbName: dtomapping
|
||||
@@ -0,0 +1,13 @@
|
||||
<configuration scan="true" scanPeriod="10 seconds">
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
|
||||
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</root>
|
||||
|
||||
<logger name="io.ebean" level="INFO"/>
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user