diff --git a/docs/dto-mapping-design.md b/docs/dto-mapping-design.md index e6d3ac63c0..09905efb82 100644 --- a/docs/dto-mapping-design.md +++ b/docs/dto-mapping-design.md @@ -578,7 +578,7 @@ the chain. ### mapTo(Dto.class) runtime wiring (implemented) `query.mapTo(dtoType)` returns a `MappedQuery` (`findList()`/`findOne()`/`findOneOrEmpty()`/ -`findPagedList()`/`usingMaster(boolean)`/`usingTransaction(Transaction)`/`usingConnection(Connection)`). +`findStream()`/`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: @@ -599,6 +599,15 @@ 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/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 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");