Row Mapping
When you fetch results with a typed terminal operation such as single(), list(), flow(), or sequence(), Kuery Client converts each row into the specified type. There are two mapping strategies, chosen automatically based on the return type.
Data classes
Typically, you will map rows to a data class. Each row is mapped with Spring's DataClassRowMapper (spring-r2dbc / spring-jdbc, respectively): the constructor of the target class is invoked, and each constructor parameter is populated from the column with the matching name. A snake_case column name matches a camelCase parameter name.
data class User(
val userId: Int, // <- user_id column
val username: String, // <- username column
val email: String?, // <- email column (nullable)
)
val users: List<User> = kueryClient
.sql { +"SELECT user_id, username, email FROM users" }
.list()Declare a property as nullable when the column can be NULL. If a NULL value ends up in a non-nullable parameter, an exception is thrown at runtime.
Simple types
If the return type is a simple value type, select one column and its value is converted directly to the target type. JDBC rejects a scalar query with multiple columns; R2DBC reads the first column. Selecting exactly one column is therefore required for portable behavior. Whether a type is "simple" is determined by Spring's BeanUtils.isSimpleProperty, which covers primitives and their wrappers, String, enums, Number types, date/time types, UUID, and other JDK value types.
val count: Long = kueryClient
.sql { +"SELECT COUNT(*) FROM users" }
.single()
val usernames: List<String> = kueryClient
.sql { +"SELECT username FROM users" }
.list()SQL NULL in multi-row results
When fetching a simple scalar type with list() / flow() / sequence(), a SQL NULL is kept as a null element even though this cannot be expressed in the List<T> / Flow<T> type. Filter or handle nulls yourself if the column is nullable.
Enums
An enum column is read by its name by default:
enum class UserStatus { ACTIVE, INACTIVE }
data class User(
val userId: Int,
val status: UserStatus, // 'ACTIVE' / 'INACTIVE' in the status column
)Enums are also written by name when used as bind parameters. See Binding Parameters.
If you want a different representation (for example, a numeric code), register custom converters. See Type Conversion.
Custom types
Conversion always happens per column, through Spring's ConversionService. Any @ReadingConverter you register participates in it — typically when a column value is mapped into a data class property of your custom type. See Type Conversion.
Custom types cannot be the return type itself
The mapping strategy is chosen solely by whether the return type is a simple value type, regardless of registered converters. So a custom type as the return type (e.g. single<StringWrapper>()) does not go through your @ReadingConverter — it goes down the constructor-mapping path and fails unless the column names happen to match. Receive custom types as properties of a data class instead.
Kotlin value classes
Kotlin value classes are supported in both positions on the fetch side:
- As the return type itself (e.g.
single<UserName>()): like a simple type, select one column; its value is converted to the underlying type and boxed into the value class. - As a data class property: the column matched by parameter name (same
snake_case/camelCaserules as above) is converted to the underlying type and boxed.
@JvmInline
value class UserName(val value: String)
data class User(
val userId: Int,
val username: UserName, // <- username column
)
val names: List<UserName> = kueryClient
.sql { +"SELECT username FROM users" }
.list()Boxing goes through the primary constructor, so init validation runs — an invalid database value fails with the same exception the constructor would throw. Value classes wrapping enums (or other value classes) convert recursively. A registered @ReadingConverter targeting the value class takes precedence over automatic boxing; see Type Conversion. Value classes also work as mutable (var) body properties, not just constructor parameters.
Generic value classes (e.g. value class Wrapped<T>(val value: T)) cannot be boxed automatically — the underlying type is a type parameter — and are rejected with an error, whether used as the return type itself or as a data class property. Register a @ReadingConverter for them instead. As a scalar with such a converter, a SQL NULL is kept as a null element (the underlying type is unknown, so the NULL cannot be taken inside the value class).
Nullable columns
How a SQL NULL maps depends on whether the value class's underlying type is nullable:
- Non-null underlying (
value class UserName(val value: String)): aNULLcannot be held inside the value class. As a data class property, declare it nullable (UserName?) soNULLmaps tonull; a non-nullable property fails. As a scalar (list<UserName>()), aNULLis kept as anullelement (the same note as simple types above). - Nullable underlying (
value class OptionalUserName(val value: String?)): aNULLis taken into the value class asOptionalUserName(null), mirroring how such a value is bound on the write side (so it round-trips). A nullable property (OptionalUserName?) instead mapsNULLto the outernull: both the property and the underlying could hold theNULL, so it is ambiguous, and the outernullwins.
For a nullable underlying, a scalar fetch always produces OptionalUserName(null) (never a null element): the element type is non-null (list<T : Any>), so element nullability cannot be requested. Use a data class with an OptionalUserName? property if you need the outer null.
Value classes are also supported as bind parameters. See Binding Parameters.
Raw maps
If you don't need typed mapping, singleMap() / listMap() / flowMap() / sequenceMap() return each row as a Map<String, Any?> keyed by column name.
val rows: List<Map<String, Any?>> = kueryClient
.sql { +"SELECT * FROM users" }
.listMap()Alias duplicate column labels in joins. JDBC and R2DBC resolve duplicate map keys differently; see Raw maps and column labels.