amnaredo / test

0 stars 0 forks source link

Support for pickling value classes nicely #168

Open amnaredo opened 2 years ago

amnaredo commented 2 years ago

I have a large domain made up mostly of value classes as members of large domain objects. For example:

case class TestField(testField: String) extends AnyVal
case class LargeDomainObject(testField: TestField, ...)

When pickling using the default writer it comes out looking something like:

"LargeDomainObject" { "TestField": { "testField": "value" } }

On a large domain object this looks pretty gross as value classes are pickled as nested objects. To make things a little nicer for consumers of my api, I wanted to unwrap the value class so pickling looks like:

"LargeDomainObject" { "testField": "value" }

So I took a crack at writing a macro that generates a Writer and Reader for annotated value classes (code at the bottom). I use it like so:

@PickleAnyVal case class TestField(testField: String) extends AnyVal

It works well in practice for me. I'm wondering the following:

Anyway, the macro I use for the above (first one I've written so pretty ugly and also domain specific as I don't account for any types I don't use or even try to do it in a general fashion):

class PickleAnyVal() extends StaticAnnotation {
  def macroTransform(annottees: Any*): Any = macro PickleAnyValImpl.impl
}

object PickleAnyValImpl {
  def impl(c: whitebox.Context)(annottees: c.Expr[Any]*): c.Expr[Any] = {
    import c.universe._

    def writerLine(param: ValDef) = param match {
      case q"$_ val $name: Date" => q"writeJs(v.getTime)"
      case q"$_ val $name: BigDecimal" => q"writeJs(v.underlying.toPlainString)"
      case q"$_ val $name: $_" => q"writeJs(v)"
    }

    def readerLine(param: ValDef) = param match {
      case q"$_ val $name: Date" => q"new Date(t.value.asInstanceOf[String].toLong)"
      case q"$_ val $name: String" => q"t.value.asInstanceOf[String]"
      case q"$_ val $name: Int" => q"t.value.asInstanceOf[Double].toInt"
      case q"$_ val $name: Float" => q"t.value.asInstanceOf[Double].toFloat"
      case q"$_ val $name: Boolean" => q"t.value.asInstanceOf[Boolean]"
      case q"$_ val $name: BigDecimal" => q"BigDecimal(t.value.asInstanceOf[String])"
    }

    def extractAnyVal(classDecl: ClassDef) = classDecl match {
      case q"case class $anyVal($param) extends AnyVal { ..$body }" => (anyVal, param, body)
    }

    def writePickler(classDecl: ClassDef) = {
      val (anyVal, param, body) = extractAnyVal(classDecl)
      val anyValParam = param.asInstanceOf[ValDef]
      val anyValType = anyVal.asInstanceOf[TypeName]
      val anyValTerm = anyValType.toTermName
      val TermName(anyValName) = anyValTerm
      val writerTerm = TermName(s"${anyValName}2Writer")
      val readerTerm = TermName(s"${anyValName}2Reader")

      val wLine = writerLine(anyValParam)
      val rLine = readerLine(anyValParam)

      c.Expr[Any](
        q"""
          case class $anyVal($param) extends AnyVal { ..$body }

          object $anyValTerm {
            import upickle.Js
            import upickle.default._

            implicit val $writerTerm = Writer[$anyValType] {
              case $anyValTerm(v) => ..$wLine
            }
            implicit val $readerTerm = Reader[$anyValType] {
              case t: Js.Value => $anyValTerm(..$rLine)
            }
          }
        """
      )
    }

    annottees.map(_.tree).toList match {
      case (classDecl: ClassDef) :: (objDecl: ModuleDef) :: Nil => c.abort(c.enclosingPosition, "Only supporting value classes with no explicitly defined companion object")
      case (classDecl: ClassDef) :: Nil => writePickler(classDecl)
      case _ => c.abort(c.enclosingPosition, "Invalid annottee")
    }
  }
}

Also one for pickling enums (probably could be combined with the one above):

class PickleEnum() extends StaticAnnotation {
  def macroTransform(annottees: Any*): Any = macro PickleEnumImpl.impl
}

object PickleEnumImpl {
  def impl(c: whitebox.Context)(annottees: c.Expr[Any]*): c.Expr[Any] = {
    import c.universe._

    def extractEnum(enumDecl: ModuleDef) = enumDecl match {
      case q"object $enum extends Enumeration { ..$body }" => (enum, body)
    }

    def writePickler(enumDecl: ModuleDef) = {
      val (enum, body) = extractEnum(enumDecl)
      val enumTerm = enum.asInstanceOf[TermName]
      val enumType = enumTerm.toTypeName
      val TermName(enumName) = enumTerm
      val writerTerm = TermName(s"${enumName}2Writer")
      val readerTerm = TermName(s"${enumName}2Reader")

      c.Expr[Any](
        q"""
          object $enum extends Enumeration {
            ..$body

            import upickle.Js
            import upickle.default._

            implicit val $writerTerm = Writer[$enumType] {
              case t: $enumType => Js.Str(t.toString)
              case _ => Js.Null
            }
            implicit val $readerTerm = Reader[$enumType] {
              case Js.Str(s) => ${enumTerm}.withName(s)
              case Js.Null => null
            }
          }
        """
      )
    }

    annottees.map(_.tree).toList match {
      case (classDecl: ClassDef) :: Nil => c.abort(c.enclosingPosition, "Only supporting objects that extends Enumeration (no classes)")
      case (enumDecl: ModuleDef) :: Nil => writePickler(enumDecl)
      case _ => c.abort(c.enclosingPosition, "Invalid annottee")
    }
  }
}

ID: 147 Original Author: matthewpflueger

amnaredo commented 2 years ago

I don't think you really need such a complicated macro to do what you want, Custom Picklers works just fine for this sort of thing. If you want you could probably reduce the boilerplate down to 1 line of code with a helper function.

haoyi-test@ case class TestField(testField: String) extends AnyVal
defined class TestField
haoyi-test@ upickle.default.write(TestField("foo"))
res5: String = """
{"testField":"foo"}
"""
haoyi-test@ upickle.default.write(Seq(TestField("foo")))
res6: String = """
[{"testField":"foo"}]
"""

haoyi-test@ import upickle.Js
import upickle.Js
haoyi-test@ {
            case class TestField(testField: String) extends AnyVal
            object TestField{
              implicit val writer = upickle.default.Writer[TestField]{
                case t => Js.Str(t.testField)
              }
              implicit val reader = upickle.default.Reader[TestField]{
                case Js.Str(str) => TestField(str)
              }
            }
            }
defined class TestField
defined object TestField
haoyi-test@ upickle.default.write(Seq(TestField("foo")))
res9: String = """
["foo"]
"""

Sure, that's exactly what your macro does, but it doesn't really need to be a macro... it could be a one line implicit

implicit val readWriter = matthewpflueger.unboxedReadWriter[TestField](TestField.apply, TestField.unapply)

that provides all that that nasty annotation macro adds. Something similar could be dreamed up with for the use case of scala.Enumeration I'm guessing, but haven't looked closely.

To answer your questions...

Original Author: lihaoyi

amnaredo commented 2 years ago

Hey, thanks for taking the time to write out such a thorough response. I really appreciate the insight!

Original Author: matthewpflueger