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");