Nested DTO Mapping
July 16, 2026 · View on GitHub
Design spike for the accepted requirements in 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:
asDto(Dto.class)(existing) — aDtoQuery, executed directly against a flat SQLResultSet. One row -> one DTO, via constructor/setter matching. No nested ToOne/ToMany support, no entity graph involved.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
// Existing flat DtoQuery pipeline — unchanged
new QUser().valid.eq(true)
.select(firstName, lastName)
.asDto(UserInfo.class)
.findList();
// 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 theselect(...)/.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-pathsourceflattening — e.g.@Mapping(target = "line1", source = "billingAddress.line1"). MapStruct auto-generates a null-safe chain of getter calls for a dottedsource;@DtoPathdoes 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@EntityViewattribute, 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@DtoPathis likewise placed on the DTO field. The difference from Blaze:@DtoPathis 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. -
@DtoRefhas 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@DtoRefadds 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.idpath) to the generated fetch spec's rootselect(...)- reading the FK column directly with no join, and skipped entirely if that same association is already fully fetched by aNESTED_ONE/NESTED_MANYproperty elsewhere on the same DTO (seeDtoMapperWriter.fetchGroupChainCalls()'scase REFbranch) - 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
REFproperties 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@ManyToOnewith 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 soREFalways contributes its association name to the rootselect(...)(deduped against any existingNESTED_ONE/NESTED_MANYfetch of the same path).
Bug found and fixed (validation phase, testing against central-access): primitive-typed field +
nullable intermediate hop = unboxing NullPointerException. A multi-hop @DtoPath (or @DtoRef,
which is always 2-hop) null-guards each intermediate getter with a ternary, e.g.
(source.getOrganisation() == null ? null : source.getOrganisation().getId()). That ternary's static
type is always the boxed wrapper (Long), since one branch is the null literal - fine when the DTO
field is itself a reference type (Long organisationId), but when the DTO field is a primitive
(long organisationId), passing that boxed expression to the constructor auto-unboxes it, throwing an
unhelpful NullPointerException at runtime whenever the relation really is null. This compiled clean
and only failed at runtime with real (nullable) production data - exactly the kind of gap a hand-written
mapper would defensively guard against (e.g. cEbox.getOrganisation() == null ? 0 : ...getId()) but
generated code didn't.
Fixed in the generator: when a multi-hop SCALAR/REF property's DTO field type is primitive, the
whole null-guarded chain is now wrapped in a small runtime helper (io.ebean.DtoMapperSupport) that
resolves it safely:
- Default (
@DtoPathwith nofailOnNull, or any@DtoRef): silently defaults to the primitive's zero-equivalent value (0/false/etc.) - matches the old hand-written-mapper convention. @DtoPath(failOnNull = true): throws a clearIllegalStateExceptionnaming the offending property path instead, for callers who'd rather fail fast than silently mask a null they don't expect.
@DtoRef has no failOnNull attribute (it has no other attributes at all) - it always uses the
default (silent zero) behaviour. See PrimitiveNullPathDto/PrimitiveNullPathFailOnNullDto /
TestPrimitiveNullPath for regression coverage.
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:
- 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'sname()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 asContact) 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. - Mapping that entity into a plain DTO using the existing, already-implemented
@DtoMappingmachinery - 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. - This read entity benefits from the same
setUnmodifiable(true)/InterceptReadOnlymemory efficiency above when used purely asmapTo(...)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:
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 usesMethodHandlesviaLookups.getLookup(), but there is noreflect-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.javasource forQ*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):
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:
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:
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
CustomerDtoMappermaps aCustomer-> fullCustomerDto. - The nested
ContactDtoMapper, mappingcontact.getCustomer()(the sameCustomerinstance, by identity), maps it -> shallowCustomerRefDto(the@DtoRef-style escape hatch that avoids theCustomer -> Contact -> Customercycle).
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:
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 Contacts 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:
// 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
TestCustomerDtoGraphMappingextended to covercontactsToMany mapping and to assert that siblingContactDtos under the same customer share the identicalCustomerRefDtoinstance.TestContactDtoGraphMapping(new) - standaloneContactDtoMappertest 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:
@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: addedavaje-prisms(optional, plusannotationProcessorPathsentry) andebean-annotation(optional) dependencies; removed the previous-proc:nonecompiler arg (which would have suppressedavaje-prisms' own processor from running to generate the prism source) - annotation processing is now scoped to exactlyavaje-prismsvia the explicitannotationProcessorPathslist, so no other processor is auto-discovered.module-info.java: addedrequires static io.avaje.prism;andrequires static io.ebean.annotation;(static= compile-time only, matching theoptionalMaven scope).- New
package-info.javadeclaring@GeneratePrism(DtoPath.class)and@GeneratePrism(DtoRef.class), generatingDtoPathPrism/DtoRefPrismintotarget/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
sourceentity andtargetDTO as a pair:
@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.:
<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
DtoMapperRegisters, analogous to DtoBeanManager), then:
- applies
mapper.fetchGroup()to the query viaquery.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 withLazyInitialisationExceptionrather than silently lazy loading or returningnull; - 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 Contacts 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 amapTo(Dto.class)query for explicit overrides. Currently the mapper'sfetchGroup()is the only source of the fetch spec - any.select()/.fetch()calls made before.mapTo(...)are overwritten by it. -
Whether
@DtoPath/@DtoRefneed additional attributes beyond a bare path/marker (e.g. an explicit target type on@DtoReffor disambiguation) once real DTOs with more complex shapes are codegen'd. -
Behavior when a DTO property has no matching entity property and no
@DtoPath/@Formula2override (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/@Sqlalternative. -
Fetch-path collision between a
NESTED_ONE/NESTED_MANYproperty and a@DtoPathproperty - found and fixed:DtoMapperWriter.fetchGroupChainCalls()builds one.fetch(path, ...)chain-call per distinct fetch path, but the underlyingOrmQueryDetail.fetch(...)unconditionally overwrites (rather than merges) any existing entry for the same path key. If a DTO declared aNESTED_ONE/NESTED_MANYproperty AND a@DtoPathproperty whose fetch-path prefix is the exact same path (e.g. a nestedAddressDto billingAddressalongside@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, sharedFetchGroup, 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:DtoMapperWriternow detects the collision and raises a clearctx.logError(...)(annotation-processorERRORdiagnostic, 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@DtoPaththat reaches a different, non-colliding path (asContactDto.customerCityalready does deliberately, per its own comment, using a 3-segment path). Verified empirically by compiling a small reproduction with a colliding@DtoPathand confirming the expected error fires; a permanent regression test (DtoMapperFetchPathCollisionTestinquerybean-generator) now runs this same repro directly throughjavax.tools.JavaCompilerwith theProcessorregistered, 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 opaqueStringat 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 typedProjections.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. RecommendmapTo()whenever compile-time-checked DTO projection matters, and treatasDto()/findDto()as the flexible, runtime-checked escape hatch for raw/dynamic SQL. Seedto-mapping-requirements.mdrequirement 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 (seedto-mapping-requirements.mdsection E), as built:@DtoConvert(value = ConverterType.class, method = "name")on a DTO property (combinable with@DtoPath); the generator dispatches on whether the referenced method isstatic- static means a direct inlined static call (no registration, covers common reusable coercions), instance means dispatch via a newDtoConverterManager.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.ebeanpackage) 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 theDatabase. 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 onEbeanDtoMapperRegister, and equally on each mapper's own no-arg constructor, which resolves the same way viaDtoConverterManager.get(...)for standalone/test construction), not lazily on first use -DtoMapperRegister'smapperFor(...)signature andDtoMapperManagerare otherwise completely unchanged, as originally planned.- Two alternatives were explored and rejected first: (a) a
DtoMapContext.service(Class)lookup - wrong lifetime,DtoMapContextis a short-lived per-call identity-cache only; (b) aServiceLoader-discoveredDtoConverterSourceSPI mirroringDtoMapperRegisteritself - can't bridge to an already DI-constructed dependency without reconstructing/duplicating it. @DtoMixin(Target.class)- a companion type overlaying@DtoPath/@DtoConvert/@DtoRefannotations onto a DTO that can't be annotated directly (e.g. OpenAPI-generated). Discovered viaroundEnv.getElementsAnnotatedWith(...)(added toProcessor.getSupportedAnnotationTypes(), since - unlike@DtoPath/@DtoRef/@DtoConvert- a mixin doesn't annotate an already-iterated field of a known@DtoMappingtarget, so it can't be found lazily).DtoMappingReaderresolves 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.MixInmechanism.- Implemented in
ebean-annotation(DtoConvert,DtoMixin),ebean-api(DtoConverterManager), andquerybean-generator(DtoConverterMeta,DtoBeanMeta.converterDeps(),DtoMappingReader/DtoMapperWriter/DtoMapperRegisterWriterchanges). Test coverage:tests/test-dto-mappingTestDtoConvert(static + instance dispatch, fail-fast unregistered-type check) andTestDtoMixin(mixin overlay, including instance-dispatch conversion resolved purely from mixin-declared annotations). The instance-dispatch converter is registered via aDatabaseConfigProvider(ServiceLoader hook run before theDatabaseis built) rather than a test@BeforeAll, sinceEbeanDtoMapperRegister's mapper fields (including any needingDtoConverterManager) are all constructed eagerly duringDatabasestartup, which can be triggered by whichever test class in the module happens to run first.
-
Fixed (validation phase, found via
central-access):@DtoPaththrough a computed/derived getter now fails at compile time, with an explicitrequires()escape hatch.@DtoPathassumes every dotted segment names a real, fetchable Ebean bean property - so a path like@DtoPath("currentMachine.organisationMachine.registrationPlate"), wheregetOrganisationMachine()is a hand-written derived getter (not a real relation/column), used to compile cleanly (the codegen had no way to tell it apart from a real property from source alone) but fail at runtime with aPersistenceException: No property found for [organisationMachine] in expression ..., because the generatedFetchGroupbuilder tried tofetch/selectit as if it were a real Ebean property.- Two genuinely separate sub-problems: (1) detecting that a path segment isn't a real,
fetchable property - solvable at compile time, since a real persistent property always has a
backing field (Ebean requires one to enhance), checked via
javax.lang.model(ElementFilter.fieldsIn(...)over the type + superclass chain, seeDtoMappingReader.hasField(...)); versus (2) knowing what the computed getter needs fetched to execute safely - not solvable at compile time without full static/bytecode analysis of the getter's method body, out of scope. - Resolution: don't attempt to infer (2) automatically. When
DtoMappingReaderdetects a@DtoPathsegment with no backing field, it now fails fast at compile time (ctx.logError(...)) unless the developer explicitly declares the real entity paths that must be fetched via@DtoPath(requires = {...})(dot-notation, same convention as@DtoPath's ownvalue()) - e.g.@DtoPath(value = "primaryContact.lastName", requires = "contacts")wheregetPrimaryContact()picks the first entry out of thecontactscollection. The real prefix before the computed segment (if any) is automatically combined with the declaredrequires()paths, so the developer doesn't need to redundantly repeat it. Declared paths are emitted as bare.fetch(path)calls in the generatedFetchGroup(distinct from the.fetch(path, "props")shape used for ordinary scalar@DtoPathproperties, since there's no specific target property list to narrow to here). - The zero-extra-fetch case is also supported, via an explicit
requires = {}- e.g.@DtoPath(value = "idBadge", requires = {})wheregetIdBadge()derives purely fromid(always fetched regardless). An explicit empty array confirms "nothing extra needed", distinct from omittingrequires()entirely ("not yet considered", still a compile error) -requires()itself can't tell the two cases apart (both read back as an emptyList), soDtoMappingReaderchecks the avaje-prism-generatedDtoPathPrism.values.requires()instead, which returnsnullonly when the member was left at its default (i.e. omitted from source).DtoPropertyMetacorrespondingly carrieshasComputedSegment()as its own boolean flag (set whenever a computed segment was detected at all), independent of whetherrequiredFetchPaths()happens to be empty - an earlier version conflated the two (inferring "has a computed segment" from "has a non-empty requiredFetchPaths list"), which broke exactly this explicit-empty case by falling through to the ordinary scalar.select(...)path and failing at runtime withPersistenceException: Property not found - idBadge(idBadgeisn't a real Ebean property, so it can't be selected). - Implemented in
ebean-annotation(DtoPath.requires()), andquerybean-generator(DtoMappingReadercomputed-segment detection/validation,DtoPropertyMeta.requiredFetchPaths()/hasComputedSegment(),DtoMapperWriter.fetchGroupChainCalls()bare-fetch emission). Test coverage:tests/test-dto-mappingComputedPathDto/TestComputedPath(happy path,requirescorrectly fetches the dependency and the mapped value is correct),ComputedPathNoFetchDto/TestComputedPathNoFetch(explicitrequires = {}, genuinely nothing extra needed), andquerybean-generator'sDtoMapperComputedPathTest(negative case - omittingrequireson a computed segment is a compile-timeERRORdiagnostic, verified via directjavax.tools.JavaCompilercompilation, mirroringDtoMapperFetchPathCollisionTest). - Known gap: the dedup between the computed segment's required fetch paths and existing
pathSelect/nestedAssocPathskeys inDtoMapperWriteris a simplified exact-path-string check (skip emitting a duplicate.fetch(path)), not full collision detection like the existing NESTED_ONE/MANY vs@DtoPathcheck - a barefetch(path)and an existingfetch(path, "specific,props")for the same path string are not merged/reconciled, just left as two separate calls if that edge case arises.
- Two genuinely separate sub-problems: (1) detecting that a path segment isn't a real,
fetchable property - solvable at compile time, since a real persistent property always has a
backing field (Ebean requires one to enhance), checked via
-
Fixed: a single-hop
@DtoPathrename through a computed/derived getter whose return type is itself a registered nested DTO (NESTED_ONE/NESTED_MANY, notSCALAR) bypassed the computed-segment validation above entirely. E.g.@DtoPath("primaryContact")where the DTO field's declared type isContactDto(a type with its own@DtoMapping(source = Contact.class, target = ContactDto.class)) andgetPrimaryContact()is a computed getter with no backing field onCustomer. This resolves to a single-segment path, soDtoMappingReader.resolveProperty()took itsproperties.size() == 1nested-lookup shortcut and returned early - before thecomputedFrom/requires()validation block (added for theSCALARcase above) ever ran. The generatedFetchGroupthen emitted a brokenfetch("primaryContact", contactMapper.fetchGroup())call ("primaryContact"isn't a real Ebean fetch path), failing at runtime rather than compile time - the exact class of bug theSCALARfix was meant to close off entirely.- Resolution: restructured
resolveProperty()so the computed-segment detection/validation block runs before theproperties.size() == 1nested-lookup branch, so bothSCALARandNESTED_ONE/NESTED_MANYpaths share the same detection/validation.DtoPropertyMetagained a matching constructor overload forNESTED_ONE/NESTED_MANYcarryingcomputedSegment/requiredFetchPaths. InDtoMapperWriter.fetchGroupChainCalls(), aNESTED_ONE/NESTED_MANYproperty withhasComputedSegment()true is routed intoextraFetchPaths(the same bare.fetch(path)mechanism as theSCALARcase) instead of emittingfetch(path, mapper.fetchGroup())- since the nested mapper's ownFetchGrouprequirements can't be meaningfully attached under a path name that doesn't exist on the source entity. - Note the nested mapper's own fetch requirements (e.g. if
ContactDtoitself neededcustomer.billingAddress) are not automatically propagated up through a computed segment - only whatever the computed getter itself needs (viarequires()) is fetched. The nested mapper'smap(...)call still works via plain Java method invocation regardless (Ebean transparent lazy loading covers any gap), but relying on that silently reintroduces N+1 queries, so the nested DTO used through a computed segment should ideally be a "leaf" shape needing nothing beyond whatrequires()already declares. - Implemented in
querybean-generator(DtoMappingReader.resolveProperty()restructuring,DtoPropertyMeta's new constructor overload,DtoMapperWriter.fetchGroupChainCalls()). Test coverage:tests/test-dto-mappingContactLeafDto/ComputedNestedDto/TestComputedNestedPath(happy path - generatedFetchGroupis.select("id").fetch("contacts"), no brokenfetch("primaryContact", ...)call, and the mapped value is correct end-to-end), andquerybean-generator'sDtoMapperComputedPathTest#dtoPathThroughComputedGetter_targetingNestedDto_withoutRequires_expectCompileError(negative case, mirroring theSCALARone). TheNESTED_MANYvariant (a computed getter returning aListof a type with its own registered nested DTO mapping) shares the identical code path but had no dedicated regression test until later confirmed viaCustomer .getRecentContacts()/ComputedNestedListDto/TestComputedNestedListPath(coverage only, not a bug fix - passed cleanly first try, confirming the shared code path does work end-to-end for bothNESTED_ONEandNESTED_MANY).
- Resolution: restructured
-
Fixed:
@DtoRefnever checked for a computed/derived association getter at all. Unlike@DtoPath,@DtoRef's association name (derived by stripping theIdsuffix off the field name, e.g.primaryContactId->primaryContact) was never checked againsthasField(...)- so@DtoRefon a computed getter (e.g.getPrimaryContact()picking the first entry out of acontactscollection) compiled cleanly and generated a brokenFetchGroup.select("primaryContact")call ("primaryContact"isn't a real Ebean property), failing at runtime rather than compile time - the same class of bug as the original@DtoPathfix, just entirely unaddressed for@DtoRef's separate code path.- Resolution:
@DtoRefgained its ownrequires()attribute (dot-notation, same convention and explicit-empty semantics as@DtoPath#requires(), using the sameDtoRefPrism.values.requires() == nullomitted-vs-explicit-empty technique).DtoMappingReader's@DtoRefbranch now checkshasField(meta.source(), assocName)and fails fast at compile time (ctx.logError(...)) when the association has no backing field andrequires()wasn't specified.DtoPropertyMeta'sREFproperties now carrycomputedSegment/requiredFetchPathsthrough the existing fields (no new constructor needed - the full constructor already had the right shape).DtoMapperWriter.fetchGroupChainCalls()'sREFcase now checkshasComputedSegment()and routes intoextraFetchPaths(bare.fetch(path)) instead ofrootSelect.add(assoc)when true - the value expression itself (source.getPrimaryContact().getId(), null-guarded) is unaffected, since it's plain Java method invocation regardless of whether the association name is a real Ebean property. - Implemented in
ebean-annotation(DtoRef.requires()), andquerybean-generator(DtoMappingReader's@DtoRefbranch,DtoMapperWriter.fetchGroupChainCalls()'sREFcase). Test coverage:tests/test-dto-mappingComputedRefDto/TestComputedRefPath(happy path - generatedFetchGroupis.select("id").fetch("contacts"), no brokenselect("primaryContact")call, and the mapped id is correct end-to-end), andquerybean-generator'sDtoMapperComputedPathTest#dtoRefThroughComputedGetter_withoutRequires_expectCompileError(negative case, mirroring the@DtoPathones).
- Resolution:
-
Fixed:
requires()path values themselves were never validated against the source type's real property graph.@DtoPath(requires = {...})/@DtoRef(requires = {...})values are handed straight through toFetchGroup.fetch(...)unmodified - a typo (e.g.requires = "contactz"for the realcontactsproperty) compiled cleanly, since only the computed segment itself was checked againsthasField(...), not the developer-declared dependency paths meant to fix it. That silently reintroduced the exact runtimePersistenceExceptionthe wholerequires()escape hatch exists to prevent, just one step removed and harder to spot.- Resolution: added
DtoMappingReader.validateRequiresPath(...), which walks each dot-notation segment of a declaredrequires()value from the source root (meta.source()), checkinghasField(...)at every hop exactly like@DtoPath#value()'s own segments are checked, and unwrapping ajava.util.List-typed intermediate hop to its element type (vialistElementType(TypeMirror)) so a collection segment followed by a further hop resolves correctly - needed a newgetterReturnTypeMirror(...)helper (returning the rawTypeMirrorrather than converting straight toTypeElement, which can't distinguish aListfrom any other declared type) alongside the existinggetterReturnType(...). Called for every entry inpathPrism.requires()/refPrism.requires()right after they're read, for both the@DtoPathand@DtoRefbranches. The already-validated real prefix (segments before the computed one in a@DtoPath#value()) is intentionally not re-validated, since it was already checked while walkingvalue()itself. - Implemented in
querybean-generator(DtoMappingReader.validateRequiresPath(...),getterReturnTypeMirror(...), called from both the@DtoPathand@DtoRefbranches). Test coverage:querybean-generator'sDtoMapperComputedPathTest#dtoPathRequires_withTypoInPathValue_expectCompileError(negative case - a typo'drequires()segment is a compile-timeERRORdiagnostic); existingtests/test-dto-mapping/central-accesssuites (real multi-segmentrequires()values like"currentMachine.organisationMachines") continue to pass unchanged, confirming the validation doesn't false-positive on legitimate paths.
- Resolution: added
-
Fixed: a bare, full
requires()fetch and a sibling property's narrowed@DtoPathfetch of the exact same path silently conflicted, with the narrow one always (incorrectly) winning.DtoMapperWriter.fetchGroupChainCalls()'s dedup logic used to skip emitting a computed segment's barefetch(path)call whenever another property's@DtoPathalready had a narrowedfetch(path, "specific,props")entry for that exact path string - on the assumption the two were interchangeable/redundant. They aren't:FetchGroup's builder (OrmQueryDetail.fetch(...)) keys fetch calls by path in a plainMapand replaces rather than merges same-path entries, so whichever call format was emitted meant the other was silently discarded. Since the narrow entry was always emitted first and the bare one skipped whenever it existed, the narrow selection always won - meaning a computed getter'srequires()declaration could be completely ignored whenever an unrelated sibling@DtoPathhappened to narrow-select the exact same path, leaving whatever extra properties the computed getter actually touches unfetched (a silent lazy load, or a hardLazyInitialisationExceptionoutside a persistence context).- Resolution: reversed the priority -
fetchGroupChainCalls()now skips a narrowedpathSelectentry whenextraFetchPaths(the computed segment'srequires()) declares the exact same path, letting the bare, fullfetch(path)call win instead. This is always safe since a full fetch is a superset of any narrower property selection - the narrow entry's own properties are included within it regardless. The existingnestedAssocPathspriority (aNESTED_ONE/NESTED_MANYproperty's fullfetch(path, mapper.fetchGroup())always wins over a barefetch(path)) was correct already and left unchanged - a nested mapper's ownFetchGroupis strictly richer than either form and must not be replaced by either. - Implemented in
querybean-generator(DtoMapperWriter.fetchGroupChainCalls()). Test coverage:tests/test-dto-mappingFetchCollisionDto/TestFetchCollisionPath, plus a new computed getterCustomer.getBillingSummary()(readsbillingAddress.getLine1(), deliberately a differentAddressproperty to thecitynarrowly selected by a sibling@DtoPathon the same DTO) - confirmed to reproduceLazyInitialisationException: Property not loaded: line1when the fix is reverted, and pass cleanly (correctline1-derived value, generatedFetchGroupis.select("id").fetch("billingAddress")with no narrowed variant at all) with it in place.
- Resolution: reversed the priority -
-
Fixed: two
@DtoMixincompanion types targeting the same DTO class silently conflicted, with the second-processed one winning.DtoMappingReader.collectMixins()keyed a singlemixinsByTargetmap by the target DTO's FQN, andMap.put(...)unconditionally overwrote any existing entry - so if two mixin interfaces (e.g. a legitimate one plus an accidental duplicate, or two independently-added mixins that both happened to target the same generated/unowned DTO) both declared@DtoMixin(SameDto.class), whichever was visited last byroundEnv.getElementsAnnotatedWith(...)silently won, and all of the other mixin's@DtoPath/@DtoRef/@DtoConvertoverlays were discarded with no diagnostic at all.- Resolution:
collectMixins()now checks for an existing registration before storing a new one and raises a compileERRORnaming both the target and the already-registered mixin's qualified name, rather than silently overwriting it. - Implemented in
querybean-generator(DtoMappingReader.collectMixins()). Test coverage: new negative compile-error testDtoMapperComputedPathTest#duplicateDtoMixin_forSameTarget_expectCompileError(two minimal@DtoMixin(FooDto.class)interfaces both declaring abar()method, compiled together, asserting theDuplicate @DtoMixindiagnostic is raised); existingtests/test-dto-mappingTestDtoMixin(single, legitimate mixin usage) continues to pass unchanged.
- Resolution:
-
Fixed:
@DtoRefand@DtoPathboth present on the same field silently conflicted, with@DtoRefalways (invisibly) winning.resolveProperty()checkedrefPrism != nullfirst and returned immediately whenever present, so a field carrying both annotations at once - whether by copy/paste mistake, a half-finished rename from one style to the other, or simple confusion between the two escape hatches - had its@DtoPathcompletely ignored with no diagnostic at all.- Resolution:
resolveProperty()now resolves both prisms upfront and raises a compileERRORnaming the field when both are present, rather than silently picking@DtoRefand discarding@DtoPath. - Implemented in
querybean-generator(DtoMappingReader.resolveProperty()). Test coverage: new negative compile-error testDtoMapperComputedPathTest#dtoRefAndDtoPath_onSameField_expectCompileError(a field carrying both@DtoRefand@DtoPath("bar.id")over a real, non-computed association, isolating the conflict diagnostic from the separate computed-getterrequires()diagnostics).
- Resolution:
-
Fixed:
@DtoConverton aNESTED_ONE/NESTED_MANYproperty was silently ignored.resolveProperty()resolves the property'sDtoConverterMetaunconditionally up front (before it's known whether the property will resolve toSCALAR/REF/NESTED_ONE/NESTED_MANY), but only theSCALAR/REFDtoPropertyMetaconstructors actually accept/store a converter - theNESTED_ONE/NESTED_MANYconstructor calls never took one, so a resolved converter was simply dropped on the floor with no diagnostic. A developer adding@DtoConvertto a nested-DTO field (e.g. hoping to post-process the nested mapper's result) would see it silently do nothing -DtoMapperWriter.propertyValueExpression()'sNESTED_ONE/NESTED_MANYcases call straight intomapperFieldName(property) + ".map(...)"/".mapList(...)"with no converter wrapping at all.- Resolution: added
rejectConverterOnNested(...), called at each of the four call sites that construct aNESTED_ONE/NESTED_MANYDtoPropertyMeta(the single-hop@DtoPath-rename branch's two cases, and the plain non-@DtoPathbranch's two cases) - raises a compileERRORnaming the field whenever a converter was resolved for it, rather than silently discarding it. - Implemented in
querybean-generator(DtoMappingReader.resolveProperty(),rejectConverterOnNested()). Test coverage: new negative compile-error testDtoMapperComputedPathTest#dtoConvertOnNestedOne_expectCompileError(aNESTED_ONEfield carrying@DtoConvertover a legitimately nested, separately-@DtoMapping-registered type); existingtests/test-dto-mappingsuite (no nested property currently combines@DtoConvertwithNESTED_ONE/NESTED_MANY) continues to pass unchanged, confirming no false positives on plain nested properties.
- Resolution: added
-
Fixed:
@DtoConvert(method = ...)resolution ignored parameter arity/overloads. The sharedfindMethod(type, name)helper (also used for the builder'sbuild()lookup and@DtoMixincompanion-method lookup) matches purely by simple name - the firstExecutableElementfound - with no arity or parameter-type check at all. For@DtoConvertspecifically this is a real risk: its documented contract is a method "taking the source property value and returning the converted DTO property value" (i.e. exactly one parameter), but a shared/reusable conversion utility class is a very plausible place to have multiple same-named overloads (e.g.format (Instant)andformat(LocalDate)) -findMethodwould silently bind to whichever oneElementFilter.methodsInhappened to return first, independent of which one the developer actually meant, generating either a confusing arity/type-mismatch compile error in the generated mapper or, if both overloads happened to be call-compatible, silently invoking the wrong one.- Resolution: added a dedicated
findConverterMethod(...)(used only byresolveConverter(), leaving the sharedfindMethod()untouched for the builder/mixin call sites which have their own, different arity expectations) that filters same-named candidates down to those taking exactly one parameter. Zero matches raises a clear "not found ... taking exactly one parameter" error; more than one match (multiple 1-arg overloads sharing the name) raises an "ambiguous - N overloads take exactly one parameter" error, since@DtoConverthas no parameter-type-based way to disambiguate and the developer must rename one of the overloads. - Implemented in
querybean-generator(DtoMappingReader.resolveConverter(),findConverterMethod()). Test coverage: new negative compile-error testsDtoMapperComputedPathTest#dtoConvertMethod_withAmbiguousOverloads_expectCompileError(two same-named 1-arg overloads) and#dtoConvertMethod_withWrongArity_expectCompileError(a same-named 0-arg method, no 1-arg candidate at all); existingtests/test-dto-mappingconverter usage (a single, unambiguous 1-arg method per converter type) continues to resolve and pass unchanged.
- Resolution: added a dedicated
References
- Requirements: 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