There are use cases where it is useful to create a copy of an object which is an instance of a case class of a set of case classes, which have a specific value in common.
For example let's consider the following case classes:
case class Foo(id: Option[Int])
case class Bar(arg0: String, id: Option[Int])
case class Baz(arg0: Int, id: Option[Int], arg2: String)
Then copy
can be called on each of these case class instances:
val newId = Some(1)
Foo(None).copy(id = newId)
Bar("bar", None).copy(id = newId)
Baz(42, None, "baz").copy(id = newId)
As described here and here there is no simple way to abstract this like this:
type Copyable[T] = { def copy(id: Option[Int]): T }
// THIS DOES *NOT* WORK FOR CASE CLASSES
def withId[T <: Copyable[T]](obj: T, newId: Option[Int]): T =
obj.copy(id = newId)
So I created a scala macro, which does this job (almost):
import scala.reflect.macros.Context
object Entity {
import scala.language.experimental.macros
import scala.reflect.macros.Context
def withId[T](entity: T, id: Option[Int]): T = macro withIdImpl[T]
def withIdImpl[T: c.WeakTypeTag](c: Context)(entity: c.Expr[T], id: c.Expr[Option[Int]]): c.Expr[T] = {
import c.universe._
val currentType = entity.actualType
// reflection helpers
def equals(that: Name, name: String) = that.encoded == name || that.decoded == name
def hasName(name: String)(implicit method: MethodSymbol) = equals(method.name, name)
def hasReturnType(`type`: Type)(implicit method: MethodSymbol) = method.typeSignature match {
case MethodType(_, returnType) => `type` == returnType
}
def hasParameter(name: String, `type`: Type)(implicit method: MethodSymbol) = method.typeSignature match {
case MethodType(params, _) => params.exists { param =>
equals(param.name, name) && param.typeSignature == `type`
}
}
// finding method entity.copy(id: Option[Int])
currentType.members.find { symbol =>
symbol.isMethod && {
implicit val method = symbol.asMethod
hasName("copy") && hasReturnType(currentType) && hasParameter("id", typeOf[Option[Int]])
}
} match {
case Some(symbol) => {
val method = symbol.asMethod
val param = reify((
c.Expr[String](Literal(Constant("id"))).splice,
id.splice)).tree
c.Expr(
Apply(
Select(
reify(entity.splice).tree,
newTermName("copy")),
List( /*id.tree*/ )))
}
case None => c.abort(c.enclosingPosition, currentType + " needs method 'copy(..., id: Option[Int], ...): " + currentType + "'")
}
}
}
The last argument of Apply
(see bottom of above code block) is a List of parameters (here: parameters of method 'copy'). How can the given id
of type c.Expr[Option[Int]]
be passed as named parameter to the copy method with the help of the new macro API?
In particular the following macro expression
c.Expr(
Apply(
Select(
reify(entity.splice).tree,
newTermName("copy")),
List(/*?id?*/)))
should result in
entity.copy(id = id)
so that the following holds
case class Test(s: String, id: Option[Int] = None)
// has to be compiled by its own
object Test extends App {
assert( Entity.withId(Test("scala rulz"), Some(1)) == Test("scala rulz", Some(1)))
}
The missing part is denoted by the placeholder /*?id?*/
.
Here's an implementation that's also a little more generic:
import scala.language.experimental.macros
object WithIdExample {
import scala.reflect.macros.Context
def withId[T, I](entity: T, id: I): T = macro withIdImpl[T, I]
def withIdImpl[T: c.WeakTypeTag, I: c.WeakTypeTag](c: Context)(
entity: c.Expr[T], id: c.Expr[I]
): c.Expr[T] = {
import c.universe._
val tree = reify(entity.splice).tree
val copy = entity.actualType.member(newTermName("copy"))
val params = copy match {
case s: MethodSymbol if (s.paramss.nonEmpty) => s.paramss.head
case _ => c.abort(c.enclosingPosition, "No eligible copy method!")
}
c.Expr[T](Apply(
Select(tree, copy),
params.map {
case p if p.name.decoded == "id" => reify(id.splice).tree
case p => Select(tree, p.name)
}
))
}
}
It'll work on any case class with a member named id
, no matter what its type is:
scala> case class Bar(arg0: String, id: Option[Int])
defined class Bar
scala> case class Foo(x: Double, y: String, id: Int)
defined class Foo
scala> WithIdExample.withId(Bar("bar", None), Some(2))
res0: Bar = Bar(bar,Some(2))
scala> WithIdExample.withId(Foo(0.0, "foo", 1), 2)
res1: Foo = Foo(0.0,foo,2)
If the case class doesn't have an id
member, withId
will compile—it just won't do anything. If you want a compile error in that case, you can add an extra condition to the match on copy
.
Edit: As Eugene Burmako just pointed out on Twitter, you can write this a little more naturally using AssignOrNamedArg
at the end:
c.Expr[T](Apply(
Select(tree, copy),
AssignOrNamedArg(Ident("id"), reify(id.splice).tree) :: Nil
))
This version won't compile if the case class doesn't have an id
member, but that's more likely to be the desired behavior anyway.
This is the solution of Travis where all parts are put together:
import scala.language.experimental.macros
object WithIdExample {
import scala.reflect.macros.Context
def withId[T, I](entity: T, id: I): T = macro withIdImpl[T, I]
def withIdImpl[T: c.WeakTypeTag, I: c.WeakTypeTag](c: Context)(
entity: c.Expr[T], id: c.Expr[I]
): c.Expr[T] = {
import c.universe._
val tree = reify(entity.splice).tree
val copy = entity.actualType.member(newTermName("copy"))
copy match {
case s: MethodSymbol if (s.paramss.flatten.map(_.name).contains(
newTermName("id")
)) => c.Expr[T](
Apply(
Select(tree, copy),
AssignOrNamedArg(Ident("id"), reify(id.splice).tree) :: Nil))
case _ => c.abort(c.enclosingPosition, "No eligible copy method!")
}
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With