Typed Tuple
When a query selects several expressions, its default result is Tuple2,
Tuple3, and so on. That is convenient for a local query, but a reusable report
usually needs a meaningful row type instead of positional _1, _2 properties.
@TypedTuple generates two related APIs from one row declaration:
-
A mapper that can be passed to
selectand materializes the declared row type directly. -
A named table facade that exposes the same row when the query is used as a base query.
Declaration
Declare an ordinary immutable class and annotate it with @TypedTuple:
- Java
- Kotlin
@TypedTuple
@lombok.Data
public class StoreStatistics {
private final UUID storeId;
private final long bookCount;
private final BigDecimal avgPrice;
}
@TypedTuple
data class StoreStatistics(
val storeId: UUID,
val bookCount: Long,
val avgPrice: BigDecimal?
)
The annotation processor generates StoreStatisticsMapper. It preserves the
declared property order and checks the selection type of every property at
compile time.
Java records are supported too:
@TypedTuple
public record StoreStatistics(
UUID storeId,
long bookCount,
BigDecimal avgPrice
) {}
Select Projection
Use the generated mapper instead of selecting several expressions separately:
- Java
- Kotlin
BookTable book = BookTable.$;
List<StoreStatistics> rows = sqlClient
.createQuery(book)
.where(book.storeId().isNotNull())
.groupBy(book.storeId())
.select(
StoreStatisticsMapper
.storeId(book.storeId())
.bookCount(Expression.rowCount())
.avgPrice(book.price().avgAsDecimal())
)
.execute();
val rows: List<StoreStatistics> = sqlClient
.createQuery(Book::class) {
where(table.storeId.isNotNull())
groupBy(table.storeId)
select(
StoreStatisticsMapper
.storeId(table.storeId.asNonNull())
.bookCount(rowCount())
.avgPrice(avgAsDecimal(table.price))
)
}
.execute()
The SQL projection is unchanged, but each JDBC row is now materialized as
StoreStatistics; no application-level conversion from Tuple3 is required.
The same projection can also be used by stream and by DML returning APIs
that accept a projection.
Base Query and CTE
The same mapper can be selected by createBaseQuery. In this case code
generation also provides StoreStatisticsTable, whose properties are DSL
expressions rather than materialized values:
- Java
- Kotlin
BookTable book = BookTable.$;
StoreStatisticsTable statistics = sqlClient
.createBaseQuery(book)
.where(book.storeId().isNotNull())
.groupBy(book.storeId())
.select(
StoreStatisticsMapper
.storeId(book.storeId())
.bookCount(Expression.rowCount())
.avgPrice(book.price().avgAsDecimal())
)
.asCteBaseTable();
List<Tuple2<UUID, BigDecimal>> rows = sqlClient
.createQuery(statistics)
.where(statistics.getBookCount().gt(2L))
.select(statistics.getStoreId(), statistics.getAvgPrice())
.execute();
val statistics = sqlClient
.createBaseQuery(Book::class) {
where(table.storeId.isNotNull())
groupBy(table.storeId)
select(
StoreStatisticsMapper
.storeId(table.storeId.asNonNull())
.bookCount(rowCount())
.avgPrice(avgAsDecimal(table.price))
)
}
.asCteBaseTable()
val rows = sqlClient.createQuery(statistics) {
where(table.bookCount gt 2L)
select(table.storeId, table.avgPrice)
}.execute()
Use asBaseTable() for a derived table and asCteBaseTable() for a CTE. The
generated named facade is preserved when the base query is used in weak joins,
unions, or recursive CTEs. Kotlin additionally generates
StoreStatisticsTable.Nullable, which is used when an outer weak join makes
all columns of the joined table nullable.
Named typed tables are not limited to nine logical properties. A property can represent either a scalar expression or an entity table; an entity table may expand to several physical SQL columns and still occupies one typed-tuple property.
Passing an Entity Table Through
When most columns come from one entity and only a few columns are calculated, do not repeat all entity properties in the typed tuple. Declare one entity-valued property instead:
- Java
- Kotlin
@TypedTuple
@lombok.Data
public class BookWithMetrics {
private final Book book;
private final long authorCount;
}
@TypedTuple
data class BookWithMetrics(
val book: Book,
val authorCount: Long
)
Pass the table itself to that mapper property, just like addSelect(table) or
selections.add(table) in the positional API:
- Java
- Kotlin
BookTable book = BookTable.$;
AuthorTableEx author = AuthorTableEx.$;
BookWithMetricsTable report = sqlClient
.createBaseQuery(book)
.select(
BookWithMetricsMapper
.book(book)
.authorCount(
sqlClient.createSubQuery(author)
.where(author.books().id().eq(book.id()))
.selectCount()
)
)
.asCteBaseTable();
sqlClient.createQuery(report)
.select(report.getBook().name(), report.getAuthorCount())
.execute();
val report = sqlClient
.createBaseQuery(Book::class) {
select(
BookWithMetricsMapper
.book(table)
.authorCount(
subQuery(Author::class) {
where(table.books.id eq parentTable.id)
selectCount()
}
)
)
}
.asCteBaseTable()
sqlClient.createQuery(report) {
select(table.book.name, table.authorCount)
}.execute()
The generated book accessor is a BookTable in Java and a
KNonNullTable<Book> in Kotlin. Reverse projection propagation still applies:
the inner query exports only the Book columns required by the outer query,
along with the calculated columns that are actually used. Selecting book
itself, or applying a fetcher to it in the outer query, propagates that requested
entity shape in the same way as a positional table selection.
A typed tuple can always be used as a normal query result projection. To use it as a base table, however, every selected property must be representable by a SQL base table. Fetcher selections and output DTO selections are therefore not allowed inside the typed base-query projection.
All branches of a union or recursive CTE must use the same generated typed projection.
The positional BaseTable1 ... BaseTable9 API remains available for small,
local base queries.