Skip to content

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.

kotlin
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.

kotlin
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:

kotlin
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 / camelCase rules as above) is converted to the underlying type and boxed.
kotlin
@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)): a NULL cannot be held inside the value class. As a data class property, declare it nullable (UserName?) so NULL maps to null; a non-nullable property fails. As a scalar (list<UserName>()), a NULL is kept as a null element (the same note as simple types above).
  • Nullable underlying (value class OptionalUserName(val value: String?)): a NULL is taken into the value class as OptionalUserName(null), mirroring how such a value is bound on the write side (so it round-trips). A nullable property (OptionalUserName?) instead maps NULL to the outer null: both the property and the underlying could hold the NULL, so it is ambiguous, and the outer null wins.

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.

kotlin
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.