Compare commits

...
8 changed files with 127 additions and 5 deletions
+10 -1
View File
@@ -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:
@@ -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
@@ -6,6 +6,7 @@ import org.jspecify.annotations.Nullable;
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<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.
*/
@@ -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 io.ebeaninternal.api.SpiQuery;
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 final class DefaultMappedQuery<T, D> implements MappedQuery<D> {
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);
@@ -462,6 +462,27 @@ class DtoMappingReader {
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();
@@ -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 class ContactMixinDto {
public String getSecretCode() {
return secretCode;
}
public CustomerRefDto getOwner() {
return owner;
}
}
@@ -7,9 +7,9 @@ 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.
* 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();
}
@@ -34,6 +34,10 @@ class TestDtoMixin {
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
@@ -14,6 +14,7 @@ import org.tests.dtomapping.model.query.QCustomer;
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 @@ class TestQueryMapTo {
}
}
@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");