mirror of
https://github.com/ebean-orm/ebean.git
synced 2026-09-20 19:17:55 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
805e309fb0 | ||
|
|
adb2e6a5d2 | ||
|
|
cbd67a6c22 | ||
|
|
893dc599cd |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user