Data Handling

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"}]