skunk icon indicating copy to clipboard operation
skunk copied to clipboard

Add support for composite type and record type

Open Swoorup opened this issue 4 years ago • 0 comments

I currently use the following helpers in my own code to support these types. records are simply composite type without predefined structure.

import scala.util.matching.Regex

import cats.data.State
import cats.syntax.all.*
import skunk.codec.all.*
import skunk.data.{Arr, Type}
import skunk.implicits.*
import skunk.syntax.*
import skunk.{Codec, Decoder}

import CompositeTypeCodec.*

enum CompositeType:
  case Record
  case Custom(name: String)

object CompositeTypeCodec:
  private def unparameterize(name: String): String =
    name match
      case s"$_name($n)" => _name
      case _             => name

  def mkComposite[A <: Product](codec: Codec[A], desc: CompositeType = CompositeType.Record): Codec[A] =
    new Codec[A]:
      override def encode(a: A): List[Option[String]] =
        val elem = codec.encode(a).flattenOption.mkString("(", ",", ")")
        List(Some(elem))

      override val sql: State[Int, String] =
        codec.sql.map(a => s"($a)")

      override val types: List[Type] =
        desc match
          case CompositeType.Record =>
            List(Type.record)
          case CompositeType.Custom(name) =>
            // parameterized types must be unparameterized
            val types = codec.types.map(ty => ty.copy(name = unparameterize(ty.name)))
            List(Type(name, types))

      override def decode(offset: Int, s: List[Option[String]]): Either[Decoder.Error, A] =
        s match
          case Some(s"($inner)") :: Nil =>
            val composites = inner.split(",").map(_.some).toList
            codec.decode(offset, composites)
          case None :: Nil =>
            Left(Decoder.Error(offset, 1, s"Unexpected NULL value in non-optional column."))
          case _ =>
            Left(Decoder.Error(offset, 1, s"Expected one input value to decode, got ${s.length}."))

extension [A <: Product](self: Codec[A]) 
  def asArr: Codec[Arr[A]] =
    val ty     = Type(s"_${self.types.head.name}", self.types)
    val encode = (elem: A) => self.encode(elem).head.get
    val decode = (str: String) => self.decode(0, List(Some(str))).left.map(_.message)
    Codec.array(encode, decode, ty)

Usage

case class Point(x: BigDecimal, y: BigDecimal)

val pointArrCodec: Codec[Arr[Point]] = CompositeTypeCodec
  .mkComposite(numeric *: numeric, CompositeType.Custom("point"))
  .imap(Point.apply)(point => point.x -> point.y)
  .asArr

// `create type book_id as(id_part varchar(5), category varchar(5));`
case class BookId(id: String, category: String)

val c_bookId: Codec[BookId] =
  CompositeTypeCodec
    .mkComposite(varchar(5) *: varchar(5), CompositeType.Custom("book_id"))
    .imap(BookId.apply)(bookId => bookId.id -> bookId.category)

EDIT (2022/07/04): ~There is currently an issue if the composite type contains a string though. Probably easy to fix.~ Fixed and tested with varchar(5). Parameterised type need to be unparameterized.

Probably needs more testing with types that can be parameterized, i.e char(5)/char, byte(1), etc.

Swoorup avatar Nov 25 '21 15:11 Swoorup