Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Scala Macros: Making a Map out of fields of a class in Scala

Let's say that I have a lot of similar data classes. Here's an example class User which is defined as follows:

case class User (name: String, age: Int, posts: List[String]) {
  val numPosts: Int = posts.length

  ...

  def foo = "bar"

  ...
}

I am interested in automatically creating a method (at compile time) that returns a Map in a way that each field name is mapped to its value when it is called in runtime. For the example above, let's say that my method is called toMap:

val myUser = User("Foo", 25, List("Lorem", "Ipsum"))

myUser.toMap

should return

Map("name" -> "Foo", "age" -> 25, "posts" -> List("Lorem", "Ipsum"), "numPosts" -> 2)

How would you do this with macros?

Here's what I have done: First, I created a Model class as a superclass for all of my data classes and implemented the method in there like this:

abstract class Model {
  def toMap[T]: Map[String, Any] = macro toMap_impl[T]
}

class User(...) extends Model {
  ...
}

Then I defined a macro implementation in a separate Macros object:

object Macros {
  import scala.language.experimental.macros
  import scala.reflect.macros.Context
  def getMap_impl[T: c.WeakTypeTag](c: Context): c.Expr[Map[String, Any]] = {
    import c.universe._

    val tpe = weakTypeOf[T]

    // Filter members that start with "value", which are val fields
    val members = tpe.members.toList.filter(m => !m.isMethod && m.toString.startsWith("value"))

    // Create ("fieldName", field) tuples to construct a map from field names to fields themselves
    val tuples =
      for {
        m <- members
        val fieldString = Literal(Constant(m.toString.replace("value ", "")))
        val field = Ident(m)
      } yield (fieldString, field)

    val mappings = tuples.toMap

    /* Parse the string version of the map [i.e. Map("posts" -> (posts), "age" -> (age), "name" -> (name))] to get the AST
     * for the map, which is generated as:
     * 
     * Apply(Ident(newTermName("Map")), 
     *   List(
     *     Apply(Select(Literal(Constant("posts")), newTermName("$minus$greater")), List(Ident(newTermName("posts")))), 
     *     Apply(Select(Literal(Constant("age")), newTermName("$minus$greater")), List(Ident(newTermName("age")))), 
     *     Apply(Select(Literal(Constant("name")), newTermName("$minus$greater")), List(Ident(newTermName("name"))))
     *   )
     * )
     * 
     * which is equivalent to Map("posts".$minus$greater(posts), "age".$minus$greater(age), "name".$minus$greater(name)) 
     */
    c.Expr[Map[String, Any]](c.parse(mappings.toString))
  }
}

Yet I get this error from sbt when I try to compile it:

[error] /Users/emre/workspace/DynamoReflection/core/src/main/scala/dynamo/Main.scala:9: not found: value posts
[error]     foo.getMap[User]
[error]               ^

Macros.scala is being compiled first. Here is the snippet from my Build.scala:

lazy val root: Project = Project(
    "root",
    file("core"),
    settings = buildSettings
  ) aggregate(macros, core)

  lazy val macros: Project = Project(
    "macros",
    file("macros"),
    settings = buildSettings ++ Seq(
      libraryDependencies <+= (scalaVersion)("org.scala-lang" % "scala-reflect" % _))
  )

  lazy val core: Project = Project(
    "core",
    file("core"),
    settings = buildSettings
  ) dependsOn(macros)

What am I doing wrong? I think that the compiler tries to evaluate the field identifiers too when it creates the expression, but I don't know how to return them properly in the expression. Could you show me how to do that?

Thanks very much in advance.

like image 794
Emre Avatar asked Jun 20 '13 20:06

Emre


3 Answers

Note that this can be done much more elegantly without the toString / c.parse business:

import scala.language.experimental.macros

abstract class Model {
  def toMap[T]: Map[String, Any] = macro Macros.toMap_impl[T]
}

object Macros {
  import scala.reflect.macros.Context

  def toMap_impl[T: c.WeakTypeTag](c: Context) = {
    import c.universe._

    val mapApply = Select(reify(Map).tree, newTermName("apply"))

    val pairs = weakTypeOf[T].declarations.collect {
      case m: MethodSymbol if m.isCaseAccessor =>
        val name = c.literal(m.name.decoded)
        val value = c.Expr(Select(c.resetAllAttrs(c.prefix.tree), m.name))
        reify(name.splice -> value.splice).tree
    }

    c.Expr[Map[String, Any]](Apply(mapApply, pairs.toList))
  }
}

Note also that you need the c.resetAllAttrs bit if you want to be able to write the following:

User("a", 1, Nil).toMap[User]

Without it you'll get a confusing ClassCastException in this situation.

By the way, here's a trick that I've used to avoid the extra type parameter in e.g. user.toMap[User] when writing macros like this:

import scala.language.experimental.macros

trait Model

object Model {
  implicit class Mappable[M <: Model](val model: M) extends AnyVal {
    def asMap: Map[String, Any] = macro Macros.asMap_impl[M]
  }

  private object Macros {
    import scala.reflect.macros.Context

    def asMap_impl[T: c.WeakTypeTag](c: Context) = {
      import c.universe._

      val mapApply = Select(reify(Map).tree, newTermName("apply"))
      val model = Select(c.prefix.tree, newTermName("model"))

      val pairs = weakTypeOf[T].declarations.collect {
        case m: MethodSymbol if m.isCaseAccessor =>
          val name = c.literal(m.name.decoded)
          val value = c.Expr(Select(model, m.name))
          reify(name.splice -> value.splice).tree
      }

      c.Expr[Map[String, Any]](Apply(mapApply, pairs.toList))
    }
  }
}

Now we can write the following:

scala> println(User("a", 1, Nil).asMap)
Map(name -> a, age -> 1, posts -> List())

And don't need to specify that we're talking about a User.

like image 123
Travis Brown Avatar answered Nov 15 '22 14:11

Travis Brown


There is an excellent blog post on map to/from case class conversion using macros.

like image 12
lisak Avatar answered Nov 15 '22 15:11

lisak


Starting Scala 2.13, case classes (which are an implementation of Product) are now provided with a productElementNames method which returns an iterator over their field's names.

By zipping field names with field values obtained with productIterator one can obtained a Map out of whatever case class:

// val user = User("Foo", 25, List("Lorem", "Ipsum"))
(user.productElementNames zip user.productIterator).toMap
// Map[String, Any] = Map("name" -> "Foo", "age" -> 25, "posts" -> List("Lorem", "Ipsum"))
like image 3
Xavier Guihot Avatar answered Nov 15 '22 15:11

Xavier Guihot