Custom Type Encoding
Using custom encoders and decoders for domain-specific types
When using param(...) to insert values into queries, ExoQuery relies on kotlinx.serialization to encode your data. This works well for standard types and @Serializable classes, but what if you have a custom domain type like PersonId(val value: Int) that you want to use directly in queries?
This is where custom encodings come in. Custom encodings allow you to bypass the kotlinx.serialization stack entirely and directly program how your custom types are written to a PreparedStatement (for encoding) or read from a ResultSet (for decoding). Once you've registered a custom encoder/decoder with your database controller, you can use paramCtx(...) to insert your custom type into queriesβthe controller will use your custom encoder to handle the value directly.
In short:
param(...)β uses kotlinx.serialization (standard types,@Serializableclasses)paramCtx(...)β uses your custom encoders registered on the controller (domain-specific types)
JDBC Custom Encoding
For JDBC, you can add custom encoders and decoders to your database controller:
import io.exoquery.controller.JdbcControllers
import io.exoquery.controller.jdbc.JdbcEncoderAny
import io.exoquery.controller.jdbc.JdbcDecoderAny
import java.sql.Types
// Define a custom type
data class PersonId(val value: Int)
// Create a controller with custom encoding
val myDatabase = object : JdbcControllers.Postgres(dataSource) {
override val additionalDecoders =
super.additionalDecoders + JdbcDecoderAny { ctx, i -> PersonId(ctx.row.getInt(i)) }
override val additionalEncoders =
super.additionalEncoders + JdbcEncoderAny(Types.INTEGER) { ctx, v: PersonId, i ->
ctx.stmt.setInt(i, v.value)
}
}
R2DBC Custom Encoding
For R2DBC, you can configure custom encoders and decoders through the encoding config.
Use the contramap and map functions to create custom encoders/decoders based on existing ones:
import io.exoquery.controller.r2dbc.R2dbcControllers
import io.exoquery.controller.r2dbc.R2dbcEncodingConfig
import io.exoquery.controller.r2dbc.R2dbcBasicEncoding
// Define a custom type
data class PersonId(val value: Int)
// Create a controller with custom encoding
val controller = R2dbcControllers.Postgres(
encodingConfig = R2dbcEncodingConfig.Default(
encoders = setOf(
// Use contramap to transform PersonId -> Int before encoding
R2dbcBasicEncoding.IntEncoder.contramap { id: PersonId -> id.value }
),
decoders = setOf(
// Use map to transform Int -> PersonId after decoding
R2dbcBasicEncoding.IntDecoder.map { PersonId(it) }
)
),
connectionFactory = connectionFactory
)
The contramap function creates a new encoder that first transforms your custom type into the base type (e.g., PersonId β Int), then uses the base encoder. The map function creates a new decoder that first decodes to the base type, then transforms it into your custom type.
Using Custom Types in Queries
Once you've configured custom encoding, you can use your custom types in queries with paramCtx:
val personId = PersonId(123)
val q = sql {
Table<Person>().filter { p -> p.id == paramCtx(personId) }
}
q.buildFor.Postgres().runOn(myDatabase)
//> SELECT p.id, p.name, p.age FROM Person p WHERE p.id = ?
Remember that for custom types used in entity classes, you'll need to mark the field with @Contextual:
@Serializable
data class Person(
@Contextual val id: PersonId,
val name: String,
val age: Int
)