Multiple / Conflicting Serializers
Using ExoQuery with other kotlinx-serialization formats like JSON
When using ExoQuery with kotlinx-serialization with other formats such as JSON in real-world situations, you may frequently need either different encodings or even entirely different schemas for the same data. For example, you may want to encode a LocalDate using the SQL DATE type, but when sending the data to a REST API you may want to encode the same LocalDate as a String in ISO-8601 format (i.e. using DateTimeFormatter.ISO_LOCAL_DATE).
There are several ways to do this in Kotlinx-serialization, I will discuss two of them.
Using a Contextual Serializer
The simplest way to have a different encoding for the same data in different contexts is to use a contextual serializer.
Define Your Data Class
First, create your data class and mark the field that needs different encodings with @Contextual. This tells kotlinx-serialization that this fieldโs serializer will be provided contextually.
@Serializable
data class Customer(
val id: Int,
val firstName: String,
val lastName: String,
@Contextual val createdAt: LocalDate
)
The corresponding database schema would be:
CREATE TABLE customers (
id INT,
first_name TEXT,
last_name TEXT,
created_at DATE
)
Create a Custom Serializer for JSON
Next, create a serializer that encodes the LocalDate as a String in ISO-8601 format. This serializer will only be used for JSON encoding.
object DateAsIsoSerializer : KSerializer<LocalDate> {
override val descriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDate) =
encoder.encodeString(value.format(DateTimeFormatter.ISO_LOCAL_DATE))
override fun deserialize(decoder: Decoder): LocalDate =
LocalDate.parse(decoder.decodeString(), DateTimeFormatter.ISO_LOCAL_DATE)
}
Database Usage
When working with the database, the LocalDate will be encoded as a SQL DATE type. The Terpal Driver will behave this way by default when a field is marked as @Contextual.
val ctx = JdbcController.Postgres.fromConfig("mydb")
val c = Customer(1, "Alice", "Smith", LocalDate.of(2021, 1, 1))
val q = sql {
insert<Customer> {
set(
firstName to param(c.firstName),
lastName to param(c.lastName),
createdAt to paramCtx(c.createdAt)
)
}
}
q.buildFor.Postgres().runOn(ctx)
//> INSERT INTO customers (first_name, last_name, created_at) VALUES (?, ?, ?)
JSON Encoding
Later, when encoding the data as JSON, make sure to specify the DateAsIsoSerializer in the serializers module. This tells kotlinx-serialization to use your custom serializer for LocalDate fields marked as @Contextual.
val json = Json {
serializersModule = SerializersModule {
contextual(LocalDate::class, DateAsIsoSerializer)
}
}
val jsonCustomer = json.encodeToString(Customer.serializer(), c)
println(jsonCustomer)
//> {"id":1,"firstName":"Alice","lastName":"Smith","createdAt":"2021-01-01"}
Using Row-Surrogate Encoder
When the changes in encoding between the Database and JSON are more complex, you may want to use a row-surrogate encoder.
A row-surrogate encoder will take a data-class and copy it into another data-class (i.e. the surrogate data-class) whose schema is appropriate for the target format. The surrogate data-class needs to also be serializable and know how to create itself from the original data-class.
// Create the "original" data class
@Serializable
data class Customer(
val id: Int,
val firstName: String,
val lastName: String,
@Serializable(with = DateAsIsoSerializer::class) val createdAt: LocalDate
)
// Create the "surrogate" data class
@Serializable
data class CustomerSurrogate(
val id: Int,
val firstName: String,
val lastName: String,
@Contextual val createdAt: LocalDate
) {
fun toCustomer() = Customer(id, firstName, lastName, createdAt)
companion object {
fun fromCustomer(customer: Customer): CustomerSurrogate {
return CustomerSurrogate(customer.id, customer.firstName, customer.lastName, customer.createdAt)
}
}
}
Then create a surrogate serializer which uses the surrogate data-class to encode the original data-class.
object CustomerSurrogateSerializer : KSerializer<Customer> {
override val descriptor = CustomerSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: Customer) =
encoder.encodeSerializableValue(CustomerSurrogate.serializer(), CustomerSurrogate.fromCustomer(value))
override fun deserialize(decoder: Decoder): Customer =
decoder.decodeSerializableValue(CustomerSurrogate.serializer()).toCustomer()
}
Then use the surrogate serializer when reading data from the database.
// You can then use the surrogate class when reading/writing information from the database:
val customers = sql {
Table<Customer>().filter { c -> c.firstName == "Joe" }
}.buildFor.Postgres().runOn(ctx, CustomerSurrogateSerializer)
//> SELECT c.id, c.firstName, c.lastName, c.createdAt FROM customers c WHERE c.firstName = 'Joe'
// ...and use the regular data-class/serializer when encoding/decoding to JSON
println(Json.encodeToString(ListSerializer(Customer.serializer()), customers))
//> [{"id":1,"firstName":"Alice","lastName":"Smith","createdAt":"2021-01-01"}]