From c969fb20972f9a95419133511723297a84153988 Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Wed, 15 Jul 2026 23:33:37 +1200 Subject: [PATCH 1/3] #2540 Add DTO mapping from Entity beans via generated mapper code A large feature, refer to the docs / guides etc. This is a bit like MapStruct in that it generates mapping code via annotation processing but it ALSO creates an optimised FetchGroup for the destination DTO graph and it also implements a generic interface such that it can be fluently used via query.mapTo(). The value is that it automates the mapping code from entity -> dto plus optimises the query used to support building the DTO graph. Expected to be better to use than MapStruct for ebean orm users. --- docs/LIBRARY.md | 4 +- docs/dto-mapping-design.md | 693 ++++++++++++++++ docs/dto-mapping-requirements.md | 270 ++++++ docs/guides/AGENTS.md | 1 + docs/guides/README.md | 3 + docs/guides/mapping-entity-graphs-to-dtos.md | 785 ++++++++++++++++++ docs/guides/writing-ebean-query-beans.md | 5 + .../java/io/ebean/DtoConverterManager.java | 65 ++ .../src/main/java/io/ebean/DtoMapContext.java | 51 ++ .../src/main/java/io/ebean/DtoMapper.java | 76 ++ .../main/java/io/ebean/DtoMapperManager.java | 125 +++ .../main/java/io/ebean/ExpressionList.java | 23 + .../src/main/java/io/ebean/MappedQuery.java | 51 ++ .../src/main/java/io/ebean/QueryBuilder.java | 26 + .../io/ebean/config/DtoMapperRegister.java | 31 + .../io/ebeaninternal/api/SpiEbeanServer.java | 10 + .../server/core/DefaultServer.java | 7 + .../server/core/InternalConfiguration.java | 18 + .../expression/DefaultExpressionList.java | 16 +- .../server/expression/JunctionExpression.java | 10 + .../server/query/DefaultFetchGroupQuery.java | 10 + .../server/querydefn/DefaultMappedQuery.java | 94 +++ .../server/querydefn/DefaultOrmQuery.java | 10 + .../server/querydefn/MappedPagedList.java | 87 ++ .../java/io/ebean/typequery/QueryBean.java | 10 + .../xtest/internal/api/TDSpiEbeanServer.java | 5 + ...estUnmodifiableFetchStrategyAndPaging.java | 145 ++++ querybean-generator/pom.xml | 36 +- .../ebean/querybean/generator/Constants.java | 12 + .../querybean/generator/DtoBeanMeta.java | 134 +++ .../querybean/generator/DtoBuilderMeta.java | 31 + .../querybean/generator/DtoConverterMeta.java | 70 ++ .../generator/DtoMapperRegisterWriter.java | 274 ++++++ .../querybean/generator/DtoMapperVariant.java | 62 ++ .../querybean/generator/DtoMapperWriter.java | 593 +++++++++++++ .../querybean/generator/DtoMappingReader.java | 693 ++++++++++++++++ .../querybean/generator/DtoPropertyMeta.java | 107 +++ .../generator/ProcessingContext.java | 19 + .../ebean/querybean/generator/Processor.java | 66 +- .../io/ebean/querybean/generator/Split.java | 20 + .../querybean/generator/package-info.java | 11 + .../src/main/java/module-info.java | 2 + .../DtoMapperFetchPathCollisionTest.java | 144 ++++ .../generator/DtoMapperRecordSourceTest.java | 260 ++++++ tests/test-dto-mapping/pom.xml | 103 +++ .../org/tests/dtomapping/model/Address.java | 40 + .../org/tests/dtomapping/model/Contact.java | 107 +++ .../tests/dtomapping/model/ContactStats.java | 54 ++ .../dtomapping/model/ContactSummary.java | 53 ++ .../org/tests/dtomapping/model/Customer.java | 52 ++ .../java/org/tests/dtomapping/AddressDto.java | 29 + .../tests/dtomapping/ContactBuilderDto.java | 103 +++ .../ContactConversionDbConfigProvider.java | 22 + .../dtomapping/ContactConversionDto.java | 47 ++ .../tests/dtomapping/ContactConversions.java | 16 + .../java/org/tests/dtomapping/ContactDto.java | 66 ++ .../org/tests/dtomapping/ContactMixinDto.java | 39 + .../dtomapping/ContactMixinDtoMixin.java | 23 + .../org/tests/dtomapping/ContactStatsDto.java | 38 + .../tests/dtomapping/ContactSummaryDto.java | 25 + .../org/tests/dtomapping/CustomerDto.java | 39 + .../org/tests/dtomapping/CustomerRefDto.java | 28 + .../tests/dtomapping/ReverseSecretCipher.java | 14 + .../org/tests/dtomapping/SecretCipher.java | 12 + .../dtomapping/TestAddressDtoMapping.java | 47 ++ .../TestContactDtoGraphMapping.java | 75 ++ .../TestContactStatsDtoMapping.java | 78 ++ .../TestContactSummaryDtoMapping.java | 44 + .../TestCustomerDtoGraphMapping.java | 97 +++ .../TestDtoBuilderConstruction.java | 48 ++ .../org/tests/dtomapping/TestDtoConvert.java | 78 ++ .../dtomapping/TestDtoMapperManager.java | 61 ++ .../TestDtoMapperManagerSharing.java | 82 ++ .../dtomapping/TestDtoMapperRegister.java | 53 ++ .../dtomapping/TestDtoMapperVariants.java | 72 ++ .../org/tests/dtomapping/TestDtoMixin.java | 55 ++ .../dtomapping/TestMapperManualUsage.java | 81 ++ .../org/tests/dtomapping/TestQueryMapTo.java | 166 ++++ .../org/tests/dtomapping/package-info.java | 60 ++ .../io.ebean.config.DatabaseConfigProvider | 1 + .../src/test/resources/application-test.yaml | 5 + .../src/test/resources/logback-test.xml | 13 + 82 files changed, 7169 insertions(+), 22 deletions(-) create mode 100644 docs/dto-mapping-design.md create mode 100644 docs/dto-mapping-requirements.md create mode 100644 docs/guides/mapping-entity-graphs-to-dtos.md create mode 100644 ebean-api/src/main/java/io/ebean/DtoConverterManager.java create mode 100644 ebean-api/src/main/java/io/ebean/DtoMapContext.java create mode 100644 ebean-api/src/main/java/io/ebean/DtoMapper.java create mode 100644 ebean-api/src/main/java/io/ebean/DtoMapperManager.java create mode 100644 ebean-api/src/main/java/io/ebean/MappedQuery.java create mode 100644 ebean-api/src/main/java/io/ebean/config/DtoMapperRegister.java create mode 100644 ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultMappedQuery.java create mode 100644 ebean-core/src/main/java/io/ebeaninternal/server/querydefn/MappedPagedList.java create mode 100644 ebean-test/src/test/java/org/tests/query/TestUnmodifiableFetchStrategyAndPaging.java create mode 100644 querybean-generator/src/main/java/io/ebean/querybean/generator/DtoBeanMeta.java create mode 100644 querybean-generator/src/main/java/io/ebean/querybean/generator/DtoBuilderMeta.java create mode 100644 querybean-generator/src/main/java/io/ebean/querybean/generator/DtoConverterMeta.java create mode 100644 querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperRegisterWriter.java create mode 100644 querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperVariant.java create mode 100644 querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperWriter.java create mode 100644 querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMappingReader.java create mode 100644 querybean-generator/src/main/java/io/ebean/querybean/generator/DtoPropertyMeta.java create mode 100644 querybean-generator/src/main/java/io/ebean/querybean/generator/package-info.java create mode 100644 querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperFetchPathCollisionTest.java create mode 100644 querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperRecordSourceTest.java create mode 100644 tests/test-dto-mapping/pom.xml create mode 100644 tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Address.java create mode 100644 tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Contact.java create mode 100644 tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/ContactStats.java create mode 100644 tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/ContactSummary.java create mode 100644 tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Customer.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/AddressDto.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactBuilderDto.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactConversionDbConfigProvider.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactConversionDto.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactConversions.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactDto.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactMixinDto.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactMixinDtoMixin.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactStatsDto.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactSummaryDto.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/CustomerDto.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/CustomerRefDto.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ReverseSecretCipher.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/SecretCipher.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestAddressDtoMapping.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestContactDtoGraphMapping.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestContactStatsDtoMapping.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestContactSummaryDtoMapping.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestCustomerDtoGraphMapping.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoBuilderConstruction.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoConvert.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperManager.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperManagerSharing.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperRegister.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperVariants.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMixin.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestMapperManualUsage.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestQueryMapTo.java create mode 100644 tests/test-dto-mapping/src/test/java/org/tests/dtomapping/package-info.java create mode 100644 tests/test-dto-mapping/src/test/resources/META-INF/services/io.ebean.config.DatabaseConfigProvider create mode 100644 tests/test-dto-mapping/src/test/resources/application-test.yaml create mode 100644 tests/test-dto-mapping/src/test/resources/logback-test.xml diff --git a/docs/LIBRARY.md b/docs/LIBRARY.md index fbe4796ba9..672c8cd345 100644 --- a/docs/LIBRARY.md +++ b/docs/LIBRARY.md @@ -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) | diff --git a/docs/dto-mapping-design.md b/docs/dto-mapping-design.md new file mode 100644 index 0000000000..2a46e872bb --- /dev/null +++ b/docs/dto-mapping-design.md @@ -0,0 +1,693 @@ +# 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 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 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 = )` 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` 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` in the spike, expected to move to +`io.ebean` as a public type once solidified): + +```java +public interface DtoMapper { + TARGET map(SOURCE source); + default List mapList(List source) { ... } +} +``` + +A parent mapper composes nested mappers via **constructor injection**, not a static singleton: + +```java +public final class CustomerDtoMapper implements DtoMapper { + private final DtoMapper addressMapper; + + public CustomerDtoMapper() { + this(new AddressDtoMapper()); + } + + public CustomerDtoMapper(DtoMapper 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 contacts` ToMany, where each `Contact` has a `customer` back-reference, surfaced +two things worth recording. + +### The `DtoMapper` interface threads a shared context + +`DtoMapper` was extended so that mapping is always done against a `DtoMapContext`: + +```java +public interface DtoMapper { + TARGET map(SOURCE source, DtoMapContext context); + + default TARGET map(SOURCE source) { + return map(source, new DtoMapContext()); + } + + default List mapList(List source, DtoMapContext context) { ... } + + default List mapList(List 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` 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, Map> mappedByType = new HashMap<>(); + + public T computeIfAbsent(Class targetType, S source, Function mappingFunction) { + Map 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 + DtoMapper mapperFor(Class sourceType, Class targetType) { + if (sourceType == Customer.class && targetType == CustomerDto.class) { + return (DtoMapper) new CustomerDtoMapper(); + } + if (sourceType == Contact.class && targetType == ContactDto.class) { + return (DtoMapper) 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>` 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` (`findList()`/`findOne()`/`findOneOrEmpty()`). +On first use it resolves the generated `DtoMapper` 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. + +## 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 diff --git a/docs/dto-mapping-requirements.md b/docs/dto-mapping-requirements.md new file mode 100644 index 0000000000..5c65c805b4 --- /dev/null +++ b/docs/dto-mapping-requirements.md @@ -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` (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 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 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)` 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`-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 dtoType, DtoMapper 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/ diff --git a/docs/guides/AGENTS.md b/docs/guides/AGENTS.md index 87bc06cf63..36854d4d1c 100644 --- a/docs/guides/AGENTS.md +++ b/docs/guides/AGENTS.md @@ -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 diff --git a/docs/guides/README.md b/docs/guides/README.md index e81be38d57..9ba37e0642 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.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 diff --git a/docs/guides/mapping-entity-graphs-to-dtos.md b/docs/guides/mapping-entity-graphs-to-dtos.md new file mode 100644 index 0000000000..0f0d5d59b8 --- /dev/null +++ b/docs/guides/mapping-entity-graphs-to-dtos.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`/`Set` (`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 dto = new QCustomer() + .id.eq(customerId) + .mapTo(CustomerDto.class) + .findOneOrEmpty(); +``` + +```java +List 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 contacts; // nested ToMany + + public CustomerDto(Long id, String name, AddressDto billingAddress, List 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 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` for each pair — no new +Maven/Gradle setup beyond what query beans already require. + +### 3. Query with `mapTo(...)` + +```java +List 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 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 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 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 billingAddressMapper, + DtoMapper 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 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`'s `map(...)`/`mapList(...)`: + +```java +CustomerDtoMapper mapper = new CustomerDtoMapper(); +CustomerDto dto = mapper.map(customer); // any Customer you already have on hand +List 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` 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 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 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 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)). diff --git a/docs/guides/writing-ebean-query-beans.md b/docs/guides/writing-ebean-query-beans.md index 49cdc23758..62bc6297c0 100644 --- a/docs/guides/writing-ebean-query-beans.md +++ b/docs/guides/writing-ebean-query-beans.md @@ -462,6 +462,11 @@ List 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 diff --git a/ebean-api/src/main/java/io/ebean/DtoConverterManager.java b/ebean-api/src/main/java/io/ebean/DtoConverterManager.java new file mode 100644 index 0000000000..03fcf3b5c8 --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/DtoConverterManager.java @@ -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. + *

+ * 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 + * instance 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, before building the {@code Database}: + *

{@code
+ * AES256Cipher cipher = ...; // already DI-constructed
+ * DtoConverterManager.put(DriverConversions.class, new DriverConversionsImpl(cipher));
+ *
+ * Database db = DatabaseFactory.create(...); // generated mappers resolve converters from here
+ * }
+ *

+ * 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, 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 void put(Class 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 get(Class 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; + } +} diff --git a/ebean-api/src/main/java/io/ebean/DtoMapContext.java b/ebean-api/src/main/java/io/ebean/DtoMapContext.java new file mode 100644 index 0000000000..21c6772ba7 --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/DtoMapContext.java @@ -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). + *

+ * Keyed by source object identity (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. + *

+ * The identity map is partitioned per target DTO type. 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} 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. + *

+ * 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, Map> 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 T computeIfAbsent(Class targetType, S source, Function mappingFunction) { + Map 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; + } +} diff --git a/ebean-api/src/main/java/io/ebean/DtoMapper.java b/ebean-api/src/main/java/io/ebean/DtoMapper.java new file mode 100644 index 0000000000..fe89edc601 --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/DtoMapper.java @@ -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. + *

+ * 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. + *

+ * 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 + * same 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". + *

+ * 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 the source entity (or embeddable) type + * @param the target DTO type + */ +public interface DtoMapper { + + /** + * 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 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 mapList(List source, DtoMapContext context) { + List 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 mapList(List source) { + return mapList(source, new DtoMapContext()); + } +} diff --git a/ebean-api/src/main/java/io/ebean/DtoMapperManager.java b/ebean-api/src/main/java/io/ebean/DtoMapperManager.java new file mode 100644 index 0000000000..c232e7cfc8 --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/DtoMapperManager.java @@ -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. + *

+ * 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. + *

+ * 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 registers; + private final ConcurrentHashMap> pairCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap, Object> typeCache = new ConcurrentHashMap<>(); + + public DtoMapperManager() { + this.registers = load(); + } + + private static List load() { + List 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 DtoMapper mapperFor(Class sourceType, Class dtoType) { + return (DtoMapper) 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 get(Class 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(); + } + } +} diff --git a/ebean-api/src/main/java/io/ebean/ExpressionList.java b/ebean-api/src/main/java/io/ebean/ExpressionList.java index f97b05a144..7432969068 100644 --- a/ebean-api/src/main/java/io/ebean/ExpressionList.java +++ b/ebean-api/src/main/java/io/ebean/ExpressionList.java @@ -103,6 +103,29 @@ public interface ExpressionList { */ DtoQuery asDto(Class 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)}. + *

+ * 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. + */ + MappedQuery mapTo(Class 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 + */ + MappedQuery mapTo(Class dtoType, DtoMapper mapper); + /** * Return the underlying query as an UpdateQuery. *

diff --git a/ebean-api/src/main/java/io/ebean/MappedQuery.java b/ebean-api/src/main/java/io/ebean/MappedQuery.java new file mode 100644 index 0000000000..0838837a17 --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/MappedQuery.java @@ -0,0 +1,51 @@ +package io.ebean; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.Optional; + +/** + * Query that maps an entity graph query result to a nested DTO graph, produced by + * {@code query.mapTo(SomeDto.class)}. + *

+ * 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 the target DTO type + */ +@NullMarked +public interface MappedQuery { + + /** + * Execute the query returning the mapped DTO list. + */ + List findList(); + + /** + * Execute the query returning a paged list of mapped DTOs. + *

+ * 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 findPagedList(); + + /** + * 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 findOneOrEmpty(); +} diff --git a/ebean-api/src/main/java/io/ebean/QueryBuilder.java b/ebean-api/src/main/java/io/ebean/QueryBuilder.java index fa15d5f5aa..f7f3d3a494 100644 --- a/ebean-api/src/main/java/io/ebean/QueryBuilder.java +++ b/ebean-api/src/main/java/io/ebean/QueryBuilder.java @@ -77,6 +77,32 @@ public interface QueryBuilder, T> extends Que */ DtoQuery asDto(Class 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)}. + *

+ * 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. + */ + MappedQuery mapTo(Class 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())}. + *

+ * 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 + */ + MappedQuery mapTo(Class dtoType, DtoMapper mapper); + /** * Convert the query to a UpdateQuery. *

diff --git a/ebean-api/src/main/java/io/ebean/config/DtoMapperRegister.java b/ebean-api/src/main/java/io/ebean/config/DtoMapperRegister.java new file mode 100644 index 0000000000..96781757fc --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/config/DtoMapperRegister.java @@ -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. + *

+ * 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. + */ + DtoMapper mapperFor(Class sourceType, Class targetType); + + /** + * Return the mapper instance of the given concrete generated mapper type, or {@code null} if + * this register has no mapper of that type. + *

+ * 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 mapperOfType(Class mapperType); +} diff --git a/ebean-core/src/main/java/io/ebeaninternal/api/SpiEbeanServer.java b/ebean-core/src/main/java/io/ebeaninternal/api/SpiEbeanServer.java index 3a7cddc2e8..c97c5a9cb1 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/api/SpiEbeanServer.java +++ b/ebean-core/src/main/java/io/ebeaninternal/api/SpiEbeanServer.java @@ -294,6 +294,16 @@ public interface SpiEbeanServer extends SpiServer, BeanCollectionLoader { */ DtoQuery findDto(Class 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. + */ + DtoMapper dtoMapper(Class sourceType, Class dtoType); + /** * Execute the underlying ORM query returning as a JDBC ResultSet to map to DTO beans. */ diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/core/DefaultServer.java b/ebean-core/src/main/java/io/ebeaninternal/server/core/DefaultServer.java index 2de54f6c86..dd5f13b9ca 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/core/DefaultServer.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/core/DefaultServer.java @@ -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 DtoQuery findDto(Class dtoType, SpiQuery ormQuery) { return new DefaultDtoQuery<>(this, descriptor, ormQuery); } + @Override + public DtoMapper dtoMapper(Class sourceType, Class dtoType) { + return dtoMapperManager.mapperFor(sourceType, dtoType); + } + @Override public SpiResultSet findResultSet(SpiQuery ormQuery) { SpiOrmQueryRequest request = createQueryRequest(ormQuery.type(), ormQuery); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/core/InternalConfiguration.java b/ebean-core/src/main/java/io/ebeaninternal/server/core/InternalConfiguration.java index 79b13ad6e7..aebafaa2d2 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/core/InternalConfiguration.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/core/InternalConfiguration.java @@ -2,6 +2,7 @@ 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 asOfTableMapping = beanDescriptorManager.deploy(xmlMap.xmlDeployment()); @@ -153,6 +156,17 @@ private S service(Class cls) { } } + /** + * 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 readExternalMapping() { final XmapService xmapService = service(XmapService.class); if (xmapService == null) { @@ -520,6 +534,10 @@ DtoBeanManager getDtoBeanManager() { return dtoBeanManager; } + DtoMapperManager getDtoMapperManager() { + return dtoMapperManager; + } + SpiLogManager getLogManager() { return logManager; } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/expression/DefaultExpressionList.java b/ebean-core/src/main/java/io/ebeaninternal/server/expression/DefaultExpressionList.java index b1d3a0fa5e..9f156dccad 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/expression/DefaultExpressionList.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/expression/DefaultExpressionList.java @@ -254,17 +254,27 @@ public Query query() { @Override public Query asOf(Timestamp asOf) { - return query.asOf(asOf); + return query().asOf(asOf); } @Override public Query asDraft() { - return query.asDraft(); + return query().asDraft(); } @Override public DtoQuery asDto(Class dtoClass) { - return query.asDto(dtoClass); + return query().asDto(dtoClass); + } + + @Override + public MappedQuery mapTo(Class dtoType) { + return query().mapTo(dtoType); + } + + @Override + public MappedQuery mapTo(Class dtoType, DtoMapper mapper) { + return query().mapTo(dtoType, mapper); } @Override diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/expression/JunctionExpression.java b/ebean-core/src/main/java/io/ebeaninternal/server/expression/JunctionExpression.java index b53d24728e..c00cd13ffa 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/expression/JunctionExpression.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/expression/JunctionExpression.java @@ -367,6 +367,16 @@ public DtoQuery asDto(Class dtoClass) { return exprList.asDto(dtoClass); } + @Override + public MappedQuery mapTo(Class dtoType) { + return exprList.mapTo(dtoType); + } + + @Override + public MappedQuery mapTo(Class dtoType, DtoMapper mapper) { + return exprList.mapTo(dtoType, mapper); + } + @Override public UpdateQuery asUpdate() { return exprList.asUpdate(); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultFetchGroupQuery.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultFetchGroupQuery.java index fceb42158f..7c3019bda2 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultFetchGroupQuery.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultFetchGroupQuery.java @@ -149,6 +149,16 @@ public DtoQuery asDto(Class dtoClass) { throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup"); } + @Override + public MappedQuery mapTo(Class dtoType) { + throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup"); + } + + @Override + public MappedQuery mapTo(Class dtoType, DtoMapper mapper) { + throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup"); + } + @Override public UpdateQuery asUpdate() { throw new RuntimeException("EB102: Only select() and fetch() clause is allowed on FetchGroup"); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultMappedQuery.java b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultMappedQuery.java new file mode 100644 index 0000000000..e191955a07 --- /dev/null +++ b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultMappedQuery.java @@ -0,0 +1,94 @@ +package io.ebeaninternal.server.querydefn; + +import io.ebean.DtoMapper; +import io.ebean.MappedQuery; +import io.ebean.PagedList; +import io.ebeaninternal.api.SpiEbeanServer; +import io.ebeaninternal.api.SpiQuery; + +import java.util.List; +import java.util.Optional; + +/** + * Default implementation of {@link MappedQuery} backing {@code query.mapTo(dtoType)}. + *

+ * 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 implements MappedQuery { + + private final SpiEbeanServer server; + private final SpiQuery query; + private final Class dtoType; + private DtoMapper mapper; + private boolean applied; + + public DefaultMappedQuery(SpiEbeanServer server, SpiQuery query, Class 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 query, Class dtoType, DtoMapper 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 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 findList() { + DtoMapper m = mapper(); + return m.mapList(query.findList()); + } + + @Override + public D findOne() { + DtoMapper m = mapper(); + return m.map(query.findOne()); + } + + @Override + public Optional findOneOrEmpty() { + return Optional.ofNullable(findOne()); + } + + @Override + public PagedList findPagedList() { + DtoMapper m = mapper(); + return new MappedPagedList<>(query.findPagedList(), m); + } +} diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultOrmQuery.java b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultOrmQuery.java index 560fe71b10..ebf016b823 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultOrmQuery.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultOrmQuery.java @@ -184,6 +184,16 @@ public final DtoQuery asDto(Class dtoClass) { return server.findDto(dtoClass, this); } + @Override + public final MappedQuery mapTo(Class dtoType) { + return new DefaultMappedQuery<>(server, this, dtoType); + } + + @Override + public final MappedQuery mapTo(Class dtoType, DtoMapper mapper) { + return new DefaultMappedQuery<>(server, this, dtoType, mapper); + } + @Override public final UpdateQuery asUpdate() { return new DefaultUpdateQuery<>(this); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/MappedPagedList.java b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/MappedPagedList.java new file mode 100644 index 0000000000..42814e8d99 --- /dev/null +++ b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/MappedPagedList.java @@ -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()}. + *

+ * 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 implements PagedList { + + private final ReentrantLock lock = new ReentrantLock(); + private final PagedList delegate; + private final DtoMapper mapper; + private List mappedList; + + public MappedPagedList(PagedList delegate, DtoMapper mapper) { + this.delegate = delegate; + this.mapper = mapper; + } + + @Override + public void loadCount() { + delegate.loadCount(); + } + + @Override + public Future getFutureCount() { + return delegate.getFutureCount(); + } + + @Override + public List 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); + } +} diff --git a/ebean-querybean/src/main/java/io/ebean/typequery/QueryBean.java b/ebean-querybean/src/main/java/io/ebean/typequery/QueryBean.java index 44d1210c72..6110f3eda3 100644 --- a/ebean-querybean/src/main/java/io/ebean/typequery/QueryBean.java +++ b/ebean-querybean/src/main/java/io/ebean/typequery/QueryBean.java @@ -435,6 +435,16 @@ public final DtoQuery asDto(Class dtoClass) { return query.asDto(dtoClass); } + @Override + public final MappedQuery mapTo(Class dtoType) { + return query.mapTo(dtoType); + } + + @Override + public final MappedQuery mapTo(Class dtoType, DtoMapper mapper) { + return query.mapTo(dtoType, mapper); + } + @Override public final R setId(Object id) { query.setId(id); diff --git a/ebean-test/src/test/java/io/ebean/xtest/internal/api/TDSpiEbeanServer.java b/ebean-test/src/test/java/io/ebean/xtest/internal/api/TDSpiEbeanServer.java index 4efae93c7a..fd44da75ee 100644 --- a/ebean-test/src/test/java/io/ebean/xtest/internal/api/TDSpiEbeanServer.java +++ b/ebean-test/src/test/java/io/ebean/xtest/internal/api/TDSpiEbeanServer.java @@ -454,6 +454,11 @@ public DtoQuery findDto(Class dtoType, SpiQuery ormQuery) { return null; } + @Override + public DtoMapper dtoMapper(Class sourceType, Class dtoType) { + return null; + } + @Override public SpiResultSet findResultSet(SpiQuery ormQuery) { return null; diff --git a/ebean-test/src/test/java/org/tests/query/TestUnmodifiableFetchStrategyAndPaging.java b/ebean-test/src/test/java/org/tests/query/TestUnmodifiableFetchStrategyAndPaging.java new file mode 100644 index 0000000000..065f3ddd4f --- /dev/null +++ b/ebean-test/src/test/java/org/tests/query/TestUnmodifiableFetchStrategyAndPaging.java @@ -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. + *

+ * 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 customers = DB.find(Customer.class) + .setUnmodifiable(true) + .select("id,name") + .fetchQuery("contacts", "firstName,lastName") + .orderBy("id") + .findList(); + + List 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 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 orders = DB.find(Order.class) + .setUnmodifiable(true) + .select("status") + .fetchLazy("customer", "name") + .findList(); + + List 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 customers = DB.find(Customer.class) + .setUnmodifiable(true) + .select("id,name") + .setPaging(paging) + .findList(); + List 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 pagedList = DB.find(Customer.class) + .setUnmodifiable(true) + .select("id,name") + .orderBy("id") + .setFirstRow(0) + .setMaxRows(2) + .findPagedList(); + + List customers = pagedList.getList(); + assertThat(customers).hasSizeLessThanOrEqualTo(2); + assertThat(pagedList.getTotalCount()).isGreaterThanOrEqualTo(customers.size()); + + for (Customer customer : customers) { + assertThat(DB.beanState(customer).isUnmodifiable()).isTrue(); + } + } +} diff --git a/querybean-generator/pom.xml b/querybean-generator/pom.xml index 31cc2652c4..c785c19839 100644 --- a/querybean-generator/pom.xml +++ b/querybean-generator/pom.xml @@ -11,6 +11,33 @@ querybean-generator Generates Ebean Query beans for type safe ORM query building and execution + + 2.0 + + + + + io.avaje + avaje-prisms + ${avaje.prisms.version} + true + + + + io.ebean + ebean-annotation + ${ebean-annotation.version} + true + + + + io.ebean + jakarta-persistence-api + ${ebean-persistence-api.version} + test + + + @@ -19,8 +46,13 @@ 11 11 - - -proc:none + + + io.avaje + avaje-prisms + ${avaje.prisms.version} + + diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/Constants.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/Constants.java index 2062303069..ee0318ea00 100644 --- a/querybean-generator/src/main/java/io/ebean/querybean/generator/Constants.java +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/Constants.java @@ -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"; diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoBeanMeta.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoBeanMeta.java new file mode 100644 index 0000000000..049e526bc7 --- /dev/null +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoBeanMeta.java @@ -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}. + */ +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 properties = new ArrayList<>(); + private final List 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 properties() { + return properties; + } + + void addProperty(DtoPropertyMeta property) { + properties.add(property); + } + + List 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 converterDeps() { + List 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(); + } +} diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoBuilderMeta.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoBuilderMeta.java new file mode 100644 index 0000000000..536715d61b --- /dev/null +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoBuilderMeta.java @@ -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 = ...)}. + *

+ * 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); + } +} diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoConverterMeta.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoConverterMeta.java new file mode 100644 index 0000000000..e7bc29b684 --- /dev/null +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoConverterMeta.java @@ -0,0 +1,70 @@ +package io.ebean.querybean.generator; + +/** + * Metadata for a single {@code @DtoConvert(value = ConverterType.class, method = "name")} + * property conversion. + *

+ * Dispatch strategy is decided purely by whether the referenced method is {@code static}: + *

    + *
  • {@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.
  • + *
  • 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.
  • + *
+ */ +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(); + } +} diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperRegisterWriter.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperRegisterWriter.java new file mode 100644 index 0000000000..b7d9b580a2 --- /dev/null +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperRegisterWriter.java @@ -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. + *

+ * 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 metas; + private final List sortedMetas; + private final String factoryPackage; + private final String factoryFullName; + private Append writer; + + DtoMapperRegisterWriter(ProcessingContext ctx, List 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 sortByDependency(List metas) { + List sorted = new ArrayList<>(metas.size()); + Set visited = new HashSet<>(); + for (DtoBeanMeta meta : metas) { + visitForSort(meta, visited, sorted); + } + return sorted; + } + + private static void visitForSort(DtoBeanMeta meta, Set visited, List 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 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 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 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 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 deps = nestedMetas(meta); + List 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 nestedMetas(DtoBeanMeta meta) { + List 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 DtoMapper mapperFor(Class sourceType, Class 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) %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 mapperOfType(Class 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()); + } + } +} diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperVariant.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperVariant.java new file mode 100644 index 0000000000..07ba1d7e5c --- /dev/null +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperVariant.java @@ -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. + *

+ * Exposed on the generated mapper as a same-named accessor method returning its own + * {@code DtoMapper} 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 excludedProperties; + + DtoMapperVariant(String name, Set 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 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} for this variant. */ + String innerClassName() { + return suffix + "Mapper"; + } + + /** Field name holding the (lazily unnecessary - eagerly built) inner mapper instance. */ + String fieldName() { + return name + "View"; + } +} diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperWriter.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperWriter.java new file mode 100644 index 0000000000..ee5dcdde34 --- /dev/null +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMapperWriter.java @@ -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} + * 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 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 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 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 fetchGroupChainCalls(Set excludedFieldNames) { + List activeProperties = new ArrayList<>(); + for (DtoPropertyMeta property : meta.properties()) { + if (!excludedFieldNames.contains(property.dtoFieldName())) { + activeProperties.add(property); + } + } + Set 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 rootSelect = new LinkedHashSet<>(); + Map> pathSelect = new LinkedHashMap<>(); + List 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 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 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 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 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 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 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 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 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 variableProperties() { + Set 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 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 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 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} 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} 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 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 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 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 nestedProperties() { + List 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(); + } +} diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMappingReader.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMappingReader.java new file mode 100644 index 0000000000..a1c36a7c60 --- /dev/null +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMappingReader.java @@ -0,0 +1,693 @@ +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 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 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> variantsByTarget = new LinkedHashMap<>(); + + /** {@code builder()} attribute value of each target's base (unnamed) registration. */ + private final Map builderModeByTarget = new LinkedHashMap<>(); + + private static final class RawVariant { + private final Element declaringElement; + private final TypeElement source; + private final String name; + private final List exclude; + + RawVariant(Element declaringElement, TypeElement source, String name, List 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 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 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 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 allDtoMappingMirrors(Element element) { + List 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 nested = (List) 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 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 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 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 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 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 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 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 getters = new ArrayList<>(); + List 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; + } + 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 prismOn(VariableElement field, DtoBeanMeta meta, java.util.function.Function 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 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 excludeCycles() { + List result = new ArrayList<>(); + java.util.Set 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 path, java.util.Set 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 path, DtoBeanMeta cycleStart, java.util.Set 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()); + } +} diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoPropertyMeta.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoPropertyMeta.java new file mode 100644 index 0000000000..aabc0afb0f --- /dev/null +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoPropertyMeta.java @@ -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 sourceGetterPath; + private final List sourcePropertyPath; + private final DtoBeanMeta nested; + private final DtoConverterMeta converter; + + DtoPropertyMeta(String dtoFieldName, Kind kind, List sourceGetterPath, List sourcePropertyPath, DtoBeanMeta nested) { + this(dtoFieldName, kind, sourceGetterPath, sourcePropertyPath, nested, null); + } + + DtoPropertyMeta(String dtoFieldName, Kind kind, List sourceGetterPath, List 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 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 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(')'); + } +} diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/ProcessingContext.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/ProcessingContext.java index c5612ed4bd..6f25d3c2a0 100644 --- a/querybean-generator/src/main/java/io/ebean/querybean/generator/ProcessingContext.java +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/ProcessingContext.java @@ -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 @@ Set getPrefixEntities() { 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 getDbEntities() { return dbEntities; } diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/Processor.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/Processor.java index 8723e8a37e..435babe143 100644 --- a/querybean-generator/src/main/java/io/ebean/querybean/generator/Processor.java +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/Processor.java @@ -17,8 +17,8 @@ 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 Set getSupportedAnnotationTypes() { 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 boolean process(Set annotations, RoundEnvironment 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 @@ private void processOthers(RoundEnvironment roundEnv, TypeElement otherType) { } } - 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())); } } diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/Split.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/Split.java index 70e8ad5426..5e4b314dcf 100644 --- a/querybean-generator/src/main/java/io/ebean/querybean/generator/Split.java +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/Split.java @@ -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 @@ static Entry> genericsSplit(String signature) { 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 fullNames) { + Map 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; + } + } diff --git a/querybean-generator/src/main/java/io/ebean/querybean/generator/package-info.java b/querybean-generator/src/main/java/io/ebean/querybean/generator/package-info.java new file mode 100644 index 0000000000..d1f60038aa --- /dev/null +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/package-info.java @@ -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; diff --git a/querybean-generator/src/main/java/module-info.java b/querybean-generator/src/main/java/module-info.java index f864e9528e..1583b61024 100644 --- a/querybean-generator/src/main/java/module-info.java +++ b/querybean-generator/src/main/java/module-info.java @@ -4,4 +4,6 @@ requires java.compiler; requires java.sql; + requires static io.avaje.prism; + requires static io.ebean.annotation; } diff --git a/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperFetchPathCollisionTest.java b/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperFetchPathCollisionTest.java new file mode 100644 index 0000000000..645492aec7 --- /dev/null +++ b/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperFetchPathCollisionTest.java @@ -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). + *

+ * 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 diagnostics = new DiagnosticCollector<>(); + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, Locale.getDefault(), null)) { + List sourceFiles; + try (var walk = Files.walk(sourceDir)) { + sourceFiles = walk.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList()); + } + Iterable compilationUnits = + fileManager.getJavaFileObjectsFromPaths(sourceFiles); + + List 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> 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); + } + } +} diff --git a/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperRecordSourceTest.java b/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperRecordSourceTest.java new file mode 100644 index 0000000000..f66f4dae57 --- /dev/null +++ b/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperRecordSourceTest.java @@ -0,0 +1,260 @@ +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.File; +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 java.util.stream.Stream; + +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"); + + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, Locale.getDefault(), null)) { + List sourceFiles; + try (var walk = Files.walk(sourceDir)) { + sourceFiles = walk.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList()); + } + Iterable compilationUnits = + fileManager.getJavaFileObjectsFromPaths(sourceFiles); + + List options = List.of( + "-d", outDir.toString(), + "-s", genSourceDir.toString(), + "-classpath", classpathWithEbeanApiAndQuerybean(), + "-processor", Processor.class.getName()); + + JavaCompiler.CompilationTask task = compiler.getTask( + null, fileManager, diagnostics, options, null, compilationUnits); + boolean success = task.call(); + + List> 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"); + + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, Locale.getDefault(), null)) { + List sourceFiles; + try (var walk = Files.walk(sourceDir)) { + sourceFiles = walk.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList()); + } + Iterable compilationUnits = + fileManager.getJavaFileObjectsFromPaths(sourceFiles); + + List options = List.of( + "-d", outDir.toString(), + "-s", genSourceDir.toString(), + "-classpath", classpathWithEbeanApiAndQuerybean(), + "-processor", Processor.class.getName()); + + JavaCompiler.CompilationTask task = compiler.getTask( + null, fileManager, diagnostics, options, null, compilationUnits); + boolean success = task.call(); + + List> 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.DtoMapper}/{@code FetchGroup}/etc. + * ({@code ebean-api}) and {@code io.ebean.typequery.Generated} ({@code ebean-querybean}) - + * neither is a compile dependency of {@code querybean-generator} itself (it only needs to + * process annotations, not compile against the runtime API it emits references to, and adding + * either as a real dependency here would create a circular reactor module dependency since + * {@code ebean-querybean} depends on {@code querybean-generator}). Locate their already-built + * {@code target/classes} directories on disk (built earlier in the same reactor) and append + * them to the classpath just for this in-process compile, purely to let the generated mapper + * source fully resolve for this assertion - not needed by the generator itself. + */ + private String classpathWithEbeanApiAndQuerybean() { + Path reactorRoot = Path.of("").toAbsolutePath().getParent(); + String extra = Stream.of("ebean-api", "ebean-querybean") + .map(module -> reactorRoot.resolve(module).resolve("target/classes")) + .filter(Files::isDirectory) + .map(Path::toString) + .collect(Collectors.joining(File.pathSeparator)); + String base = System.getProperty("java.class.path"); + return extra.isEmpty() ? base : base + File.pathSeparator + extra; + } + + 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); + } + } +} diff --git a/tests/test-dto-mapping/pom.xml b/tests/test-dto-mapping/pom.xml new file mode 100644 index 0000000000..a31f30b176 --- /dev/null +++ b/tests/test-dto-mapping/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + + tests + io.ebean + 18.2.0 + + + test-dto-mapping + + test dto mapping + + 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. + + + + + io.ebean + ebean-h2 + ${project.version} + + + + ch.qos.logback + logback-classic + 1.3.12 + + + + io.ebean + ebean-test + ${project.version} + test + + + + io.ebean + querybean-generator + ${project.version} + provided + + + + org.assertj + assertj-core + 3.27.7 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.ebean + querybean-generator + ${project.version} + + + + + + + io.ebean + ebean-maven-plugin + ${ebean-maven-plugin.version} + + + main + process-classes + + debug=0 + + + enhance + + + + test + process-test-classes + + debug=0 + + + testEnhance + + + + + + + + diff --git a/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Address.java b/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Address.java new file mode 100644 index 0000000000..880bd4512d --- /dev/null +++ b/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Address.java @@ -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; + } +} diff --git a/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Contact.java b/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Contact.java new file mode 100644 index 0000000000..56685e5205 --- /dev/null +++ b/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Contact.java @@ -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; + } +} diff --git a/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/ContactStats.java b/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/ContactStats.java new file mode 100644 index 0000000000..f559169045 --- /dev/null +++ b/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/ContactStats.java @@ -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). + *

+ * 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. + *

+ * {@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; + } +} diff --git a/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/ContactSummary.java b/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/ContactSummary.java new file mode 100644 index 0000000000..a46bd27051 --- /dev/null +++ b/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/ContactSummary.java @@ -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. + *

+ * 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; + } +} diff --git a/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Customer.java b/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Customer.java new file mode 100644 index 0000000000..70024f3077 --- /dev/null +++ b/tests/test-dto-mapping/src/main/java/org/tests/dtomapping/model/Customer.java @@ -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 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 getContacts() { + return contacts; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/AddressDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/AddressDto.java new file mode 100644 index 0000000000..74eb4d2a2f --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/AddressDto.java @@ -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; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactBuilderDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactBuilderDto.java new file mode 100644 index 0000000000..7309af9aa5 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactBuilderDto.java @@ -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); + } + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactConversionDbConfigProvider.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactConversionDbConfigProvider.java new file mode 100644 index 0000000000..f9b099e5cd --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactConversionDbConfigProvider.java @@ -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()); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactConversionDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactConversionDto.java new file mode 100644 index 0000000000..fa89e1089d --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactConversionDto.java @@ -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; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactConversions.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactConversions.java new file mode 100644 index 0000000000..b94b9f5c79 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactConversions.java @@ -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; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactDto.java new file mode 100644 index 0000000000..c36b33ddf0 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactDto.java @@ -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; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactMixinDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactMixinDto.java new file mode 100644 index 0000000000..c62982914f --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactMixinDto.java @@ -0,0 +1,39 @@ +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; + + public ContactMixinDto(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; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactMixinDtoMixin.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactMixinDtoMixin.java new file mode 100644 index 0000000000..f38f1f4d0f --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactMixinDtoMixin.java @@ -0,0 +1,23 @@ +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}); 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(); +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactStatsDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactStatsDto.java new file mode 100644 index 0000000000..9e0a01aad3 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactStatsDto.java @@ -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. + *

+ * {@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; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactSummaryDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactSummaryDto.java new file mode 100644 index 0000000000..d033b35bb3 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ContactSummaryDto.java @@ -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; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/CustomerDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/CustomerDto.java new file mode 100644 index 0000000000..14b01044c2 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/CustomerDto.java @@ -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 contacts; + + public CustomerDto(Long id, String name, AddressDto billingAddress, List 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 getContacts() { + return contacts; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/CustomerRefDto.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/CustomerRefDto.java new file mode 100644 index 0000000000..27f015a8d7 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/CustomerRefDto.java @@ -0,0 +1,28 @@ +package org.tests.dtomapping; + +/** + * Shallow reference DTO for {@code Customer} - deliberately does not 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; + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ReverseSecretCipher.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ReverseSecretCipher.java new file mode 100644 index 0000000000..8c4f773436 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/ReverseSecretCipher.java @@ -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(); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/SecretCipher.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/SecretCipher.java new file mode 100644 index 0000000000..5ededbf763 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/SecretCipher.java @@ -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); +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestAddressDtoMapping.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestAddressDtoMapping.java new file mode 100644 index 0000000000..a23d89c8b0 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestAddressDtoMapping.java @@ -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(); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestContactDtoGraphMapping.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestContactDtoGraphMapping.java new file mode 100644 index 0000000000..73c39e85ed --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestContactDtoGraphMapping.java @@ -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. + *

+ * {@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 contacts = found.getContacts(); + assertThat(contacts.size()).isGreaterThan(1); + + List 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(); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestContactStatsDtoMapping.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestContactStatsDtoMapping.java new file mode 100644 index 0000000000..3eb619458f --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestContactStatsDtoMapping.java @@ -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 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(); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestContactSummaryDtoMapping.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestContactSummaryDtoMapping.java new file mode 100644 index 0000000000..945081fc27 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestContactSummaryDtoMapping.java @@ -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(); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestCustomerDtoGraphMapping.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestCustomerDtoGraphMapping.java new file mode 100644 index 0000000000..c871dabe8d --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestCustomerDtoGraphMapping.java @@ -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 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 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(); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoBuilderConstruction.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoBuilderConstruction.java new file mode 100644 index 0000000000..e799c410da --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoBuilderConstruction.java @@ -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(); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoConvert.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoConvert.java new file mode 100644 index 0000000000..c18f112857 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoConvert.java @@ -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}). + *

+ * {@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 { + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperManager.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperManager.java new file mode 100644 index 0000000000..c0ae5a0701 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperManager.java @@ -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}. + *

+ * 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 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 { + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperManagerSharing.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperManagerSharing.java new file mode 100644 index 0000000000..e02ba61764 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperManagerSharing.java @@ -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(); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperRegister.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperRegister.java new file mode 100644 index 0000000000..8f83508321 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperRegister.java @@ -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 mapper = register.mapperFor(Customer.class, CustomerDto.class); + + assertThat(mapper).isInstanceOf(CustomerDtoMapper.class); + } + + @Test + void mapperFor_whenDifferentTargetForSameSource_expectDifferentMapper() { + DtoMapper 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 mapper = register.mapperFor(Contact.class, CustomerDto.class); + + assertThat(mapper).isNull(); + } + + @Test + void mapperFor_whenUnregisteredPair_expectNull() { + DtoMapper mapper = register.mapperFor(Address.class, CustomerDto.class); + + assertThat(mapper).isNull(); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperVariants.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperVariants.java new file mode 100644 index 0000000000..0422c778a9 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMapperVariants.java @@ -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(); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMixin.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMixin.java new file mode 100644 index 0000000000..b4a73f7f8f --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestDtoMixin.java @@ -0,0 +1,55 @@ +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"); + } + + @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(); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestMapperManualUsage.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestMapperManualUsage.java new file mode 100644 index 0000000000..ee1bf87195 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestMapperManualUsage.java @@ -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. + *

+ * 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 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"); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestQueryMapTo.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestQueryMapTo.java new file mode 100644 index 0000000000..396742d041 --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/TestQueryMapTo.java @@ -0,0 +1,166 @@ +package org.tests.dtomapping; + +import io.ebean.DB; +import io.ebean.LazyInitialisationException; +import io.ebean.PagedList; +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 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 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 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 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 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 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_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 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"); + } +} diff --git a/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/package-info.java b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/package-info.java new file mode 100644 index 0000000000..355722835b --- /dev/null +++ b/tests/test-dto-mapping/src/test/java/org/tests/dtomapping/package-info.java @@ -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} for each pair (see docs/dto-mapping-design.md). + *

+ * {@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. + *

+ * {@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. + *

+ * {@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. + *

+ * {@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. + *

+ * {@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. + *

+ * {@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. + *

+ * {@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; diff --git a/tests/test-dto-mapping/src/test/resources/META-INF/services/io.ebean.config.DatabaseConfigProvider b/tests/test-dto-mapping/src/test/resources/META-INF/services/io.ebean.config.DatabaseConfigProvider new file mode 100644 index 0000000000..70818ed5d0 --- /dev/null +++ b/tests/test-dto-mapping/src/test/resources/META-INF/services/io.ebean.config.DatabaseConfigProvider @@ -0,0 +1 @@ +org.tests.dtomapping.ContactConversionDbConfigProvider diff --git a/tests/test-dto-mapping/src/test/resources/application-test.yaml b/tests/test-dto-mapping/src/test/resources/application-test.yaml new file mode 100644 index 0000000000..54f09bb6d8 --- /dev/null +++ b/tests/test-dto-mapping/src/test/resources/application-test.yaml @@ -0,0 +1,5 @@ +ebean: + test: + platform: h2 + ddlMode: dropCreate + dbName: dtomapping diff --git a/tests/test-dto-mapping/src/test/resources/logback-test.xml b/tests/test-dto-mapping/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..6801e405a6 --- /dev/null +++ b/tests/test-dto-mapping/src/test/resources/logback-test.xml @@ -0,0 +1,13 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + From 14ef4c91b7990ddab6ed93cb8587885e36e0a5db Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Thu, 16 Jul 2026 08:17:33 +1200 Subject: [PATCH 2/3] Bump ebean-annotation to 8.6 with the dto annotations --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 0c5e4286b0..77979d0a23 100644 --- a/pom.xml +++ b/pom.xml @@ -45,7 +45,7 @@ 3.2 3.0 - 8.5 + 8.6 2.3 1.2 14.3.0 From 47f0cb15dd7952e93b638202993b7360dca6f2e9 Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Thu, 16 Jul 2026 08:34:45 +1200 Subject: [PATCH 3/3] Adjust the build order to via test dependency on ebean-api --- querybean-generator/pom.xml | 12 +++++++ .../generator/DtoMapperRecordSourceTest.java | 36 ++++++++----------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/querybean-generator/pom.xml b/querybean-generator/pom.xml index c785c19839..b51daad0e8 100644 --- a/querybean-generator/pom.xml +++ b/querybean-generator/pom.xml @@ -36,6 +36,18 @@ ${ebean-persistence-api.version} test + + + + io.ebean + ebean-api + 18.2.0 + test + diff --git a/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperRecordSourceTest.java b/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperRecordSourceTest.java index f66f4dae57..13493dbc81 100644 --- a/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperRecordSourceTest.java +++ b/querybean-generator/src/test/java/io/ebean/querybean/generator/DtoMapperRecordSourceTest.java @@ -8,7 +8,6 @@ import javax.tools.JavaFileObject; import javax.tools.StandardJavaFileManager; import javax.tools.ToolProvider; -import java.io.File; import java.io.IOException; import java.io.UncheckedIOException; import java.io.Writer; @@ -17,7 +16,6 @@ import java.util.List; import java.util.Locale; import java.util.stream.Collectors; -import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -81,6 +79,7 @@ void recordSource_expectBareAccessorsAndPropertyNames() throws IOException { + "package org.tests.recordsrc;\n" + "\n" + "import io.ebean.annotation.DtoMapping;\n"); + writeTypequeryGeneratedStub(sourceDir); DiagnosticCollector diagnostics = new DiagnosticCollector<>(); JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); @@ -95,7 +94,6 @@ void recordSource_expectBareAccessorsAndPropertyNames() throws IOException { List options = List.of( "-d", outDir.toString(), "-s", genSourceDir.toString(), - "-classpath", classpathWithEbeanApiAndQuerybean(), "-processor", Processor.class.getName()); JavaCompiler.CompilationTask task = compiler.getTask( @@ -176,6 +174,7 @@ void plainClassWithBareAccessors_expectBareAccessorsResolved() throws IOExceptio + "package org.tests.baresrc;\n" + "\n" + "import io.ebean.annotation.DtoMapping;\n"); + writeTypequeryGeneratedStub(sourceDir); DiagnosticCollector diagnostics = new DiagnosticCollector<>(); JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); @@ -190,7 +189,6 @@ void plainClassWithBareAccessors_expectBareAccessorsResolved() throws IOExceptio List options = List.of( "-d", outDir.toString(), "-s", genSourceDir.toString(), - "-classpath", classpathWithEbeanApiAndQuerybean(), "-processor", Processor.class.getName()); JavaCompiler.CompilationTask task = compiler.getTask( @@ -223,25 +221,19 @@ void plainClassWithBareAccessors_expectBareAccessorsResolved() throws IOExceptio } /** - * The generated mapper source references {@code io.ebean.DtoMapper}/{@code FetchGroup}/etc. - * ({@code ebean-api}) and {@code io.ebean.typequery.Generated} ({@code ebean-querybean}) - - * neither is a compile dependency of {@code querybean-generator} itself (it only needs to - * process annotations, not compile against the runtime API it emits references to, and adding - * either as a real dependency here would create a circular reactor module dependency since - * {@code ebean-querybean} depends on {@code querybean-generator}). Locate their already-built - * {@code target/classes} directories on disk (built earlier in the same reactor) and append - * them to the classpath just for this in-process compile, purely to let the generated mapper - * source fully resolve for this assertion - not needed by the generator itself. + * 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 String classpathWithEbeanApiAndQuerybean() { - Path reactorRoot = Path.of("").toAbsolutePath().getParent(); - String extra = Stream.of("ebean-api", "ebean-querybean") - .map(module -> reactorRoot.resolve(module).resolve("target/classes")) - .filter(Files::isDirectory) - .map(Path::toString) - .collect(Collectors.joining(File.pathSeparator)); - String base = System.getProperty("java.class.path"); - return extra.isEmpty() ? base : base + File.pathSeparator + extra; + 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) {