From 893dc599cd801014da94b15fea147ddbe86613d3 Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Thu, 16 Jul 2026 15:49:15 +1200 Subject: [PATCH 1/3] DtoQuery - add usingMaster, usingTransaction, usingConnection options. --- docs/dto-mapping-design.md | 9 ++- .../src/main/java/io/ebean/MappedQuery.java | 18 ++++++ .../server/querydefn/DefaultMappedQuery.java | 20 +++++++ .../org/tests/dtomapping/TestQueryMapTo.java | 56 +++++++++++++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/docs/dto-mapping-design.md b/docs/dto-mapping-design.md index 2a46e872bb..e6d3ac63c0 100644 --- a/docs/dto-mapping-design.md +++ b/docs/dto-mapping-design.md @@ -577,7 +577,8 @@ the chain. ### mapTo(Dto.class) runtime wiring (implemented) -`query.mapTo(dtoType)` returns a `MappedQuery` (`findList()`/`findOne()`/`findOneOrEmpty()`). +`query.mapTo(dtoType)` returns a `MappedQuery` (`findList()`/`findOne()`/`findOneOrEmpty()`/ +`findPagedList()`/`usingMaster(boolean)`/`usingTransaction(Transaction)`/`usingConnection(Connection)`). 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: @@ -592,6 +593,12 @@ pair via a `DtoMapperManager` (a `ServiceLoader`-backed aggregator over all gene An unregistered `(source, dtoType)` pair throws a `PersistenceException` with a suggested `@DtoMapping` fix, at first use (i.e. `findList()`/`findOne()`), not at `mapTo(dtoType)` call time. +`MappedQuery.usingMaster(boolean)`, `.usingTransaction(Transaction)`, and `.usingConnection(Connection)` +all delegate directly to the underlying entity query, mirroring `Query`/`QueryBuilder`. This lets a +caller retry against the master data source after a read-replica failure by calling +`usingMaster(true)` on the *same* `MappedQuery` instance and re-invoking a find method - there's no +need to rebuild the query and call `.mapTo(...)` again. + ## Still open / to revisit during implementation - Whether `.fetch(...)` calls can still be layered on top of a `mapTo(Dto.class)` query for explicit diff --git a/ebean-api/src/main/java/io/ebean/MappedQuery.java b/ebean-api/src/main/java/io/ebean/MappedQuery.java index 0838837a17..32b23df475 100644 --- a/ebean-api/src/main/java/io/ebean/MappedQuery.java +++ b/ebean-api/src/main/java/io/ebean/MappedQuery.java @@ -3,6 +3,7 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; +import java.sql.Connection; import java.util.List; import java.util.Optional; @@ -48,4 +49,21 @@ public interface MappedQuery { * Execute the query returning an optional mapped DTO. */ Optional findOneOrEmpty(); + + /** + * Ensure the master DataSource is used when useMaster is true. Otherwise, the read only + * data source can be used if defined. + */ + MappedQuery usingMaster(boolean useMaster); + + /** + * Use the explicit transaction to execute the query. + */ + MappedQuery usingTransaction(Transaction transaction); + + /** + * Execute the query using the given connection. + */ + MappedQuery usingConnection(Connection connection); + } 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 index e191955a07..db35d18937 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultMappedQuery.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultMappedQuery.java @@ -3,9 +3,11 @@ import io.ebean.DtoMapper; import io.ebean.MappedQuery; import io.ebean.PagedList; +import io.ebean.Transaction; import io.ebeaninternal.api.SpiEbeanServer; import io.ebeaninternal.api.SpiQuery; +import java.sql.Connection; import java.util.List; import java.util.Optional; @@ -91,4 +93,22 @@ public PagedList findPagedList() { DtoMapper m = mapper(); return new MappedPagedList<>(query.findPagedList(), m); } + + @Override + public MappedQuery usingMaster(boolean useMaster) { + query.usingMaster(useMaster); + return this; + } + + @Override + public MappedQuery usingTransaction(Transaction transaction) { + query.usingTransaction(transaction); + return this; + } + + @Override + public MappedQuery usingConnection(Connection connection) { + query.usingConnection(connection); + return this; + } } 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 index 396742d041..cca99d26b7 100644 --- 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 @@ -3,6 +3,7 @@ import io.ebean.DB; import io.ebean.LazyInitialisationException; import io.ebean.PagedList; +import io.ebean.Transaction; import io.ebean.test.LoggedSql; import jakarta.persistence.PersistenceException; import org.junit.jupiter.api.Test; @@ -141,6 +142,61 @@ void mapTo_whenSelectAlreadySet_expectManualFetchSpecPreserved() { assertThat(sql.get(0)).doesNotContain("o_address"); } + @Test + void mapTo_usingMaster_expectFluentReturnAndNoError() { + Customer customer = new Customer("MasterCo"); + customer.save(); + + // usingMaster() can be called on the MappedQuery itself - e.g. on retry after a read-replica + // failure - without needing to rebuild the underlying query and call mapTo() again + var mapped = DB.find(Customer.class) + .where().idEq(customer.getId()) + .mapTo(CustomerDto.class); + + assertThat(mapped.usingMaster(false)).isSameAs(mapped); + assertThat(mapped.usingMaster(true)).isSameAs(mapped); + + List dtos = mapped.findList(); + assertThat(dtos).hasSize(1); + assertThat(dtos.get(0).getName()).isEqualTo("MasterCo"); + } + + @Test + void mapTo_usingTransaction_expectQueryRunsOnExplicitTransaction() { + Customer customer = new Customer("TxnCo"); + customer.save(); + + try (Transaction txn = DB.beginTransaction()) { + List dtos = DB.find(Customer.class) + .where().idEq(customer.getId()) + .mapTo(CustomerDto.class) + .usingTransaction(txn) + .findList(); + + assertThat(dtos).hasSize(1); + assertThat(dtos.get(0).getName()).isEqualTo("TxnCo"); + txn.commit(); + } + } + + @Test + void mapTo_usingConnection_expectFluentReturn() { + Customer customer = new Customer("ConnCo"); + customer.save(); + + try (Transaction txn = DB.beginTransaction()) { + var mapped = DB.find(Customer.class) + .where().idEq(customer.getId()) + .mapTo(CustomerDto.class); + + assertThat(mapped.usingConnection(txn.connection())).isSameAs(mapped); + + List dtos = mapped.findList(); + assertThat(dtos).hasSize(1); + assertThat(dtos.get(0).getName()).isEqualTo("ConnCo"); + } + } + @Test void mapTo_withFilterMany_expectFilteredContactsInDtoGraph() { Customer customer = new Customer("FilterManyCo"); From cbd67a6c2265f3b8b20e03e2d174f669b43867f3 Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Thu, 16 Jul 2026 16:23:13 +1200 Subject: [PATCH 2/3] DtoQuery - Support DtoPath renaming a ToOne or ToMany --- .../querybean/generator/DtoMappingReader.java | 21 +++++++++++++++++++ .../org/tests/dtomapping/ContactMixinDto.java | 8 ++++++- .../dtomapping/ContactMixinDtoMixin.java | 14 ++++++++++--- .../org/tests/dtomapping/TestDtoMixin.java | 4 ++++ 4 files changed, 43 insertions(+), 4 deletions(-) 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 index a1c36a7c60..4ec005d998 100644 --- a/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMappingReader.java +++ b/querybean-generator/src/main/java/io/ebean/querybean/generator/DtoMappingReader.java @@ -462,6 +462,27 @@ private DtoPropertyMeta resolveProperty(VariableElement field, DtoBeanMeta meta) properties.add(segment); currentType = currentType != null ? getterReturnType(currentType, getter) : null; } + // a single-hop @DtoPath rename (e.g. @DtoPath("eboxStatus") on a field named "status") + // can still target a type with its own registered @DtoMapping - detect that the same way + // the plain (non-@DtoPath) branches below do, rather than always falling back to a raw + // scalar getter call that would fail to compile with a type mismatch against the nested + // DTO type. Multi-hop paths keep the existing scalar/flattening behaviour since fetch spec + // derivation for NESTED_ONE/MANY only supports a single association name. + if (properties.size() == 1) { + TypeMirror fieldType = field.asType(); + TypeMirror listElementType = listElementType(fieldType); + if (listElementType != null) { + DtoBeanMeta nested = lookupByTarget(listElementType); + if (nested != null) { + return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.NESTED_MANY, getters, properties, nested); + } + } else { + DtoBeanMeta nested = lookupByTarget(fieldType); + if (nested != null) { + return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.NESTED_ONE, getters, properties, nested); + } + } + } return new DtoPropertyMeta(name, DtoPropertyMeta.Kind.SCALAR, getters, properties, null, converter); } TypeMirror fieldType = field.asType(); 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 index c62982914f..f44bfc972f 100644 --- 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 @@ -13,12 +13,14 @@ public class ContactMixinDto { private final String firstName; private final boolean active; private final String secretCode; + private final CustomerRefDto owner; - public ContactMixinDto(long id, String firstName, boolean active, String secretCode) { + public ContactMixinDto(long id, String firstName, boolean active, String secretCode, CustomerRefDto owner) { this.id = id; this.firstName = firstName; this.active = active; this.secretCode = secretCode; + this.owner = owner; } public long getId() { @@ -36,4 +38,8 @@ public boolean isActive() { public String getSecretCode() { return secretCode; } + + public CustomerRefDto getOwner() { + return owner; + } } 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 index f38f1f4d0f..f6aa150324 100644 --- 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 @@ -7,9 +7,9 @@ /** * 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. + * match {@link ContactMixinDto}'s field names ({@code active}, {@code secretCode}, {@code owner}); + * the querybean generator matches each mixin method to the corresponding target property by name + * and applies whichever annotations are present as if declared on the target field itself. */ @DtoMixin(ContactMixinDto.class) interface ContactMixinDtoMixin { @@ -20,4 +20,12 @@ interface ContactMixinDtoMixin { @DtoConvert(value = SecretCipher.class, method = "decode") String secretCode(); + + /** + * A single-hop {@code @DtoPath} rename onto a nested (registered {@code @DtoMapping}) type - + * {@code owner} is renamed from {@code Contact#getCustomer()}, resolving to + * {@code CustomerRefDto} via its own generated mapper rather than a raw scalar getter call. + */ + @DtoPath("customer") + CustomerRefDto owner(); } 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 index b4a73f7f8f..292d54b771 100644 --- 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 @@ -34,6 +34,10 @@ void mapTo_expectMixinAnnotationsApplied() { assertThat(dto.isActive()).isTrue(); // instance @DtoConvert declared on the mixin method, resolved via DtoConverterManager assertThat(dto.getSecretCode()).isEqualTo("secret"); + // @DtoPath("customer") rename on the mixin, resolving to the nested CustomerRefDto mapper + // rather than a raw (type-mismatched) scalar getter call + assertThat(dto.getOwner()).isNotNull(); + assertThat(dto.getOwner().getName()).isEqualTo("Acme"); } @Test From adb2e6a5d2286c0d539114b9556bbf721119536c Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Thu, 16 Jul 2026 16:51:27 +1200 Subject: [PATCH 3/3] MappedQuery - Add findStream() support --- docs/dto-mapping-design.md | 11 ++++- .../src/main/java/io/ebean/MappedQuery.java | 23 ++++++++++ .../server/querydefn/DefaultMappedQuery.java | 9 ++++ .../org/tests/dtomapping/TestQueryMapTo.java | 42 +++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/docs/dto-mapping-design.md b/docs/dto-mapping-design.md index e6d3ac63c0..59c33f1156 100644 --- a/docs/dto-mapping-design.md +++ b/docs/dto-mapping-design.md @@ -578,7 +578,8 @@ the chain. ### mapTo(Dto.class) runtime wiring (implemented) `query.mapTo(dtoType)` returns a `MappedQuery` (`findList()`/`findOne()`/`findOneOrEmpty()`/ -`findPagedList()`/`usingMaster(boolean)`/`usingTransaction(Transaction)`/`usingConnection(Connection)`). +`findPagedList()`/`findStream()`/`usingMaster(boolean)`/`usingTransaction(Transaction)`/ +`usingConnection(Connection)`). 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: @@ -599,6 +600,14 @@ caller retry against the master data source after a read-replica failure by call `usingMaster(true)` on the *same* `MappedQuery` instance and re-invoking a find method - there's no need to rebuild the query and call `.mapTo(...)` again. +`MappedQuery.findStream()` mirrors `QueryBuilder#findStream()` - the underlying entity query is +streamed (supporting very large result sets, potentially using multiple persistence contexts +internally) and each entity is mapped to its target DTO lazily as the stream is consumed. One +`DtoMapContext` is shared across the whole stream (not per-element), so identity de-duplication of +nested DTOs (e.g. several `Contact`s sharing the same `Customer`) still holds even when the source +entities are never materialized into one `List` at all. As with the entity-level `findStream()`, +callers must consume it via try-with-resources to ensure the underlying resources are closed. + ## Still open / to revisit during implementation - Whether `.fetch(...)` calls can still be layered on top of a `mapTo(Dto.class)` query for explicit diff --git a/ebean-api/src/main/java/io/ebean/MappedQuery.java b/ebean-api/src/main/java/io/ebean/MappedQuery.java index 32b23df475..e09993cb0a 100644 --- a/ebean-api/src/main/java/io/ebean/MappedQuery.java +++ b/ebean-api/src/main/java/io/ebean/MappedQuery.java @@ -6,6 +6,7 @@ import java.sql.Connection; import java.util.List; import java.util.Optional; +import java.util.stream.Stream; /** * Query that maps an entity graph query result to a nested DTO graph, produced by @@ -39,6 +40,28 @@ public interface MappedQuery { */ PagedList findPagedList(); + /** + * Execute the query returning the result as a Stream of mapped DTOs. + *

+ * Mirrors {@link QueryBuilder#findStream()} - the underlying entity graph query is streamed + * (supporting very large queries iterating any number of results, potentially using multiple + * persistence contexts internally) and each entity is mapped to its target DTO lazily as the + * stream is consumed, sharing one {@link DtoMapContext} across the whole stream so that + * repeated references to the same source entity still de-duplicate to the same DTO instance. + *

{@code
+   *
+   *  // use try with resources to ensure Stream is closed
+   *
+   *  try (Stream stream = query.mapTo(CustomerDto.class).findStream()) {
+   *    stream
+   *    .map(...)
+   *    .collect(...);
+   *  }
+   *
+   * }
+ */ + Stream findStream(); + /** * Execute the query returning a single mapped DTO, or {@code null} if there is no matching row. */ 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 index db35d18937..45978970a2 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultMappedQuery.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/DefaultMappedQuery.java @@ -1,5 +1,6 @@ package io.ebeaninternal.server.querydefn; +import io.ebean.DtoMapContext; import io.ebean.DtoMapper; import io.ebean.MappedQuery; import io.ebean.PagedList; @@ -10,6 +11,7 @@ import java.sql.Connection; import java.util.List; import java.util.Optional; +import java.util.stream.Stream; /** * Default implementation of {@link MappedQuery} backing {@code query.mapTo(dtoType)}. @@ -94,6 +96,13 @@ public PagedList findPagedList() { return new MappedPagedList<>(query.findPagedList(), m); } + @Override + public Stream findStream() { + DtoMapper m = mapper(); + DtoMapContext context = new DtoMapContext(); + return query.findStream().map(source -> m.map(source, context)); + } + @Override public MappedQuery usingMaster(boolean useMaster) { query.usingMaster(useMaster); 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 index cca99d26b7..5cc0b54ccc 100644 --- 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 @@ -14,6 +14,7 @@ import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -197,6 +198,47 @@ void mapTo_usingConnection_expectFluentReturn() { } } + @Test + void mapTo_findStream_expectLazilyMappedDtoStream() { + Customer customerA = new Customer("StreamCoA"); + customerA.save(); + Customer customerB = new Customer("StreamCoB"); + customerB.save(); + + List names; + try (var stream = DB.find(Customer.class) + .where().in("name", "StreamCoA", "StreamCoB") + .orderBy().asc("name") + .mapTo(CustomerDto.class) + .findStream()) { + names = stream.map(CustomerDto::getName).collect(Collectors.toList()); + } + + assertThat(names).containsExactly("StreamCoA", "StreamCoB"); + } + + @Test + void mapTo_findStream_expectIdentityDedupSharedAcrossStream() { + Customer customer = new Customer("StreamDedupCo"); + customer.save(); + new Contact("Jane", "Doe", customer).save(); + new Contact("John", "Doe", customer).save(); + + // both contacts share the same underlying Customer instance - the shared DtoMapContext used + // across the whole findStream() call should still de-duplicate to the same nested DTO + List dtos; + try (var stream = DB.find(Contact.class) + .where().eq("customer", customer) + .orderBy().asc("firstName") + .mapTo(ContactDto.class) + .findStream()) { + dtos = stream.collect(Collectors.toList()); + } + + assertThat(dtos).hasSize(2); + assertThat(dtos.get(0).getCustomer()).isSameAs(dtos.get(1).getCustomer()); + } + @Test void mapTo_withFilterMany_expectFilteredContactsInDtoGraph() { Customer customer = new Customer("FilterManyCo");