Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/dto-mapping-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,7 @@ the chain.
### mapTo(Dto.class) runtime wiring (implemented)

`query.mapTo(dtoType)` returns a `MappedQuery<D>` (`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<S, D>` for the query's `(getBeanType(), dtoType)`
pair via a `DtoMapperManager` (a `ServiceLoader`-backed aggregator over all generated
`DtoMapperRegister`s, analogous to `DtoBeanManager`), then:
Expand All @@ -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<D>.findStream()` mirrors `QueryBuilder#findStream()` - the underlying entity query is
streamed (supporting very large result sets, potentially using multiple persistence contexts
internally) and each entity is mapped to its target DTO lazily as the stream is consumed. One
`DtoMapContext` is shared across the whole stream (not per-element), so identity de-duplication of
nested DTOs (e.g. several `Contact`s sharing the same `Customer`) still holds even when the source
entities are never materialized into one `List` at all. As with the entity-level `findStream()`,
callers must consume it via try-with-resources to ensure the underlying resources are closed.


## Still open / to revisit during implementation

- Whether `.fetch(...)` calls can still be layered on top of a `mapTo(Dto.class)` query for explicit
Expand Down
23 changes: 23 additions & 0 deletions ebean-api/src/main/java/io/ebean/MappedQuery.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,6 +40,28 @@ public interface MappedQuery<D> {
*/
PagedList<D> findPagedList();

/**
* Execute the query returning the result as a Stream of mapped DTOs.
* <p>
* Mirrors {@link QueryBuilder#findStream()} - the underlying entity graph query is streamed
* (supporting very large queries iterating any number of results, potentially using multiple
* persistence contexts internally) and each entity is mapped to its target DTO lazily as the
* stream is consumed, sharing one {@link DtoMapContext} across the whole stream so that
* repeated references to the same source entity still de-duplicate to the same DTO instance.
* <pre>{@code
*
* // use try with resources to ensure Stream is closed
*
* try (Stream<CustomerDto> stream = query.mapTo(CustomerDto.class).findStream()) {
* stream
* .map(...)
* .collect(...);
* }
*
* }</pre>
*/
Stream<D> findStream();

/**
* Execute the query returning a single mapped DTO, or {@code null} if there is no matching row.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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)}.
Expand Down Expand Up @@ -94,6 +96,13 @@ public PagedList<D> findPagedList() {
return new MappedPagedList<>(query.findPagedList(), m);
}

@Override
public Stream<D> findStream() {
DtoMapper<T, D> m = mapper();
DtoMapContext context = new DtoMapContext();
return query.findStream().map(source -> m.map(source, context));
}

@Override
public MappedQuery<D> usingMaster(boolean useMaster) {
query.usingMaster(useMaster);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -36,4 +38,8 @@ public boolean isActive() {
public String getSecretCode() {
return secretCode;
}

public CustomerRefDto getOwner() {
return owner;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> names;
try (var stream = DB.find(Customer.class)
.where().in("name", "StreamCoA", "StreamCoB")
.orderBy().asc("name")
.mapTo(CustomerDto.class)
.findStream()) {
names = stream.map(CustomerDto::getName).collect(Collectors.toList());
}

assertThat(names).containsExactly("StreamCoA", "StreamCoB");
}

@Test
void mapTo_findStream_expectIdentityDedupSharedAcrossStream() {
Customer customer = new Customer("StreamDedupCo");
customer.save();
new Contact("Jane", "Doe", customer).save();
new Contact("John", "Doe", customer).save();

// both contacts share the same underlying Customer instance - the shared DtoMapContext used
// across the whole findStream() call should still de-duplicate to the same nested DTO
List<ContactDto> dtos;
try (var stream = DB.find(Contact.class)
.where().eq("customer", customer)
.orderBy().asc("firstName")
.mapTo(ContactDto.class)
.findStream()) {
dtos = stream.collect(Collectors.toList());
}

assertThat(dtos).hasSize(2);
assertThat(dtos.get(0).getCustomer()).isSameAs(dtos.get(1).getCustomer());
}

@Test
void mapTo_withFilterMany_expectFilteredContactsInDtoGraph() {
Customer customer = new Customer("FilterManyCo");
Expand Down
Loading