tofu-tf / derevo

Multiple instance derivations inside a single macro annotation
https://manatki.org/docs/derevo.html
184 stars 40 forks source link

Circe's KeyDecoder and KeyEncoder derivations? #239

Closed gvolpe closed 3 years ago

gvolpe commented 3 years ago

AFAIU the derivations supported by derevo are the ones supported by circe-derivation, is that right? If so, then I guess it is not possible to derive KeyDecoder and KeyEncoder instances without upstream support?

Anyhow, my only need for now is to support derivation for these typeclasses for newtypes. Here's a simple example:

@derive(decoder, encoder, eqv, show, uuid)
@newtype
case class ItemId(value: UUID)
object ItemId {
  implicit val keyEncoder: KeyEncoder[ItemId] = deriving
  implicit val keyDecoder: KeyDecoder[ItemId] = deriving
}

There are KeyDecoder[UUID] and KeyEncoder[UUID] instances so this works but I wanted to go a bit further and get Derevo to do this for me. This is what I come up with.

import derevo.{ Derivation, NewTypeDerivation }
import io.circe.KeyDecoder
import magnolia.{ CaseClass, Magnolia }

object keyDecoder extends Derivation[KeyDecoder] with NewTypeDerivation[KeyDecoder] {
  type Typeclass[T] = KeyDecoder[T]

  def combine[T](ctx: CaseClass[KeyDecoder, T]): KeyDecoder[T] = new KeyDecoder[T] {
    def apply(key: String): Option[T] =
      ctx.parameters.toList match {
        case (p :: _) => p.typeclass.apply(key).map(_.asInstanceOf[T])
        case _        => None
      }
  }

  def instance[T]: KeyDecoder[T] = macro Magnolia.gen[T]
}

Do you see anything wrong?

I did something similiar for KeyEncoder but this one is weird because I need to return a String and not sure what the empty case should be.

object keyEncoder extends Derivation[KeyEncoder] with NewTypeDerivation[KeyEncoder] {
  type Typeclass[T] = KeyEncoder[T]

  def combine[T](ctx: CaseClass[KeyEncoder, T]): KeyEncoder[T] = new KeyEncoder[T] {
    def apply(key: T): String =
      ctx.parameters.toList match {
        case (p :: _) => p.typeclass.apply(key.asInstanceOf[p.PType])
        case _        => "error"
      }
  }

  def instance[T]: KeyEncoder[T] = macro Magnolia.gen[T]
}

This works but I wonder if there is any downside or something wrong in my implementations?

@derive(decoder, encoder, keyDecoder, keyEncoder, eqv, show, uuid)
@newtype
case class ItemId(value: UUID)

Appreciate your help, I've never used Magnolia before.

Odomontois commented 3 years ago

@gvolpe

I don't think .asInstanceOf[p.Type] is a good idea here We could think of several possibilities

1. Derive instances only for newtypes.

Here you don't need magnolia at all. You can demand newtype-only derivation

object keyEncoder extends Derivation[KeyEncoder] with NewTypeDerivation[KeyEncoder]{
    def instance(implicit x: OnlyNewtypes): Nothing = x.absurd
}

object keyDecoder extends Derivation[KeyDecoder] with NewTypeDerivation[KeyDecoder]{
    def instance(implicit x: OnlyNewtypes): Nothing = x.absurd
}

@implicitNotFound("use keyEncoder and keyDecoder annotations only for newtypes")
abstract final class OnlyNewtypes{
   def absurd = Nothing
}

2. Derive instances for newtypes any single-parameter case classes

Actually I don't know exactly how to compile-time check for single-variabilty, I suppose it may involve shapeless or custom macro But you have to use ctx.construct to create such instances

3. Derive instances for newtypes and case classes of any arity

I suppose we can define some general scheme, such as using some separators in the keys to derive variable

class keyDecoder(sep: String = "::") {
  type Typeclass[T] = KeyDecoder[T]

  def combine[T](ctx: CaseClass[KeyDecoder, T]): KeyDecoder[T] =
    if (ctx.isObject) key => if (key == ctx.typeName.short) Some(ctx.rawConstruct(Seq.empty)) else None
    else { key =>
      val parts = key.split(sep)
      if (parts.length != ctx.parameters.length) None
      else ctx.constructMonadic(p => p.typeclass.apply(parts(p.index)))
    }

  def dispatch[T](ctx: SealedTrait[KeyDecoder, T]): KeyDecoder[T] =
    key => ctx.subtypes.view.flatMap(_.typeclass(key)).headOption

  def instance[T]: KeyDecoder[T] = macro Magnolia.gen[T]
}

object keyDecoder extends keyDecoder("::") with Derivation[KeyDecoder] with NewTypeDerivation[KeyDecoder]

class keyEncoder(sep: String = "::") {
  type Typeclass[T] = KeyEncoder[T]

  def combine[T](ctx: CaseClass[KeyEncoder, T]): KeyEncoder[T] =
    if (ctx.isObject) obj => ctx.typeName.short
    else { cc =>
      ctx.parameters.view.map(p => p.typeclass(p.dereference(cc))).mkString("::")
    }

  def dispatch[T](ctx: SealedTrait[KeyEncoder, T]): KeyEncoder[T] =
    obj => ctx.dispatch(obj)(sub => sub.typeclass(sub.cast(obj)))

  def instance[T]: KeyDecoder[T] = macro Magnolia.gen[T]
}

object keyEncoder extends keyEncoder("::") with Derivation[KeyEncoder] with NewTypeDerivation[KeyEncoder]

This will handle most of basic cases with default separator as well as simple type for creating key encoders with custom separators

gvolpe commented 3 years ago

@Odomontois thanks a lot! Option 3 seems great, got it working but I have no idea how to make the keyEncoder one simpler, any pointers?

Also, I have another one for Http4s' Query Params, and it's probably completely wrong even if it works 😄 If you have any spare time, I would appreciate if you could have a look: https://github.com/gvolpe/pfps-shopping-cart/blob/second-edition/modules/core/src/main/scala/shop/ext/http4s/queryParam.scala

I added a new Typeclass Derivation chapter to the book, which is centered around Derevo, would you be interested in giving it a proof-read once I have something presentable? It would mean a lot 😇

gvolpe commented 3 years ago

Oh you just updated the comment, nice! 😃

Odomontois commented 3 years ago

@gvolpe Sorry, unlike me you react so fast, I've updated my comment with more comprehensive solution

Odomontois commented 3 years ago

@gvolpe Yeah I would gladly pre-read your book!

gvolpe commented 3 years ago

That's great to hear, got an email to contact you? If you don't want to make it public, you can ping me first at hello at gvolpe dot com.

Odomontois commented 3 years ago

@gvolpe odomontois@gmail.com

gvolpe commented 3 years ago

@Odomontois sorry to bother again, I was thinking, do you think these instances could be part of Derevo? It seems something very common to have. Happy to submit a PR with these implementations.

Odomontois commented 3 years ago

@gvolpe that would be great

gvolpe commented 3 years ago

Here we go :) https://github.com/tofu-tf/derevo/pull/257