A talk given at Scalapeño 2014 and JavaOne 2014 (video links to follow).
Data validation is a common enough problem that numerous attempts have been made to solve it elegantly. The de-facto solution in Java (JSR 303) has a number of shortcomings and fails to leverage the powerful Scala type system. The release of Scala 2.10.x introduced a couple of experimental metaprogramming features, namely reflection and macros. In this talk I'll introduce macros by way of a practical example: implementing a full-blown data validation engine, utilizing def macros and a Scala DSL to enable elegant validator definition syntax and call-site.
2. I Have a Dream
• Definition:
case class Person(
firstName: String,
lastName: String
)
implicit val personValidator =
validator[Person] { p ⇒
p.firstName is notEmpty
p.lastName is notEmpty
}
3. I Have a Dream
• Usage:
validate(Person("Wernher", "von Braun”))
== Success
validate(Person("", "No First Name”))
== Failure(Set(RuleViolation(
value = "",
constraint = "must not be empty",
description = "firstName"
)))
6. The Accord API
• Validation can succeed or fail
• A failure comprises one or more violations
sealed trait Result
case object Success extends Result
case class Failure(violations: Set[Violation])
extends Result
• The validator typeclass:
trait Validator[-T] extends (T ⇒ Result)
7. Why Macros?
• Quick refresher:
implicit val personValidator =
validator[Person] { p ⇒
p.firstName is notEmpty
p.lastName is notEmpty
}
Implicit “and”
Automatic description
generation
8. Full Disclosure
Macros are experimental
Macros are hard
I will gloss over a lot of details
… and simplify a lot of things
9. Abstract Syntax Trees
• An intermediate representation of code
– Structure (semantics)
– Metadata (e.g. types) – optional!
• Provided by the reflection API
• Alas, mutable
– Until Dotty comes along
12. Abstract Syntax Trees
def method(param: String) = param.toUpperCase
ValDef(
Modifiers(PARAM),
newTermName("param"),
Select(
Ident(scala.Predef),
newTypeName("String")
),
EmptyTree // Value
)
13. Abstract Syntax Trees
def method(param: String) = param.toUpperCase
DefDef(
Modifiers(),
newTermName("method"),
List(), // Type parameters
List( // Parameter lists
List(parameter)
),
TypeTree(), // Return type
implementation
)
14. Def Macro 101
• Looks and acts like a normal function
def radix(s: String, base: Int): Long
val result = radix("2710", 16)
// result == 10000L
• Two fundamental differences:
– Invoked at compile time instead of runtime
– Operates on ASTs instead of values
18. Overview
implicit val personValidator =
validator[Person] { p ⇒
p.firstName is notEmpty
p.lastName is notEmpty
}
• The validator macro:
Macro Application
Validation Rules
– Rewrites each rule by addition a description
– Aggregates rules with an and combinator
23. Search for Rule
• A rule is an expression of type Validator[_]
• We search by:
– Recursively pattern matching over an AST
– On match, apply a function on the subtree
– Encoded as a partial function from Tree to R
24. Search for Rule
def collectFromPattern[R]
(tree: Tree)
(pattern: PartialFunction[Tree, R]): List[R] = {
var found: Vector[R] = Vector.empty
new Traverser {
override def traverse(subtree: Tree) {
if (pattern isDefinedAt subtree)
found = found :+ pattern(subtree)
else
super.traverse(subtree)
}
}.traverse(tree)
found.toList
}
25. Search for Rule
• Putting it together:
case class Rule(ouv: Tree, validation: Tree)
def processRule(subtree: Tree): Rule = ???
def findRules(body: Tree): Seq[Rule] = {
val validatorType = typeOf[Validator[_]]
collectFromPattern(body) {
case subtree if subtree.tpe <:< validatorType ⇒
processRule(subtree)
}
}
27. Process Rule
• The user writes:
p.firstName is notEmpty
• The compiler emits:
Type: Validator[_]
Contextualizer(p.firstName).is(notEmpty)
Object Under Validation
(OUV)
Validation
28. Process Rule
Contextualizer(p.firstName).is(notEmpty)
• This is effectively an Apply AST node
• The left-hand side is the OUV
• The right-hand side is the validation
– But we can use the entire expression!
• Contextualizer is our entry point
37. Generate Description
Contextualizer(p.firstName).is(notEmpty)
• Consider the object under validation
• In this example, it is a field accessor
• The function prototype is the entry point
Select
Ident(“p”)
firstName
validator[Person] { p ⇒
...
}
38. Generate Description
• How to get at the prototype?
• The macro signature includes the rule block:
def apply[T : c.WeakTypeTag]
(c: Context)
(v: c.Expr[T ⇒ Unit]):
c.Expr[Validator[T]]
• To extract the prototype:
val Function(prototype :: Nil, body) =
v.tree // prototype: ValDef
39. Generate Description
• Putting it all together:
def describeRule(rule: ValidationRule) = {
val para = prototype.name
val Select(Ident(`para`), description) =
rule.ouv
description.toString
}
41. Rewrite Rule
• We’re constructing a Validator[Person]
• A rule is itself a Validator[T]. For example:
Contextualizer(p.firstName).is(notEmpty)
• We need to:
– Lift the rule to validate the enclosing type
– Apply the description to the result
42. Quasiquotes
• Provide an easy way to construct ASTs:
Apply(
Select(
Ident(newTermName"x"),
newTermName("$plus")
),
List(
Ident(newTermName("y"))
)
)
q"x + y"
43. Quasiquotes
• Quasiquotes also let you splice trees:
def greeting(whom: c.Expr[String]) =
q"Hello "$whom"!"
• And can be used in pattern matching:
val q"$x + $y" = tree
47. Epilogue
• The finishing touch: and combinator
def apply[T : c.WeakTypeTag]
(c: Context)
(v: c.Expr[T ⇒ Unit]): c.Expr[Validator[T]] = {
val Function(prototype :: Nil, body) = v.tree
// ... all the stuff we just discussed
val rules = findRules(body) map rewriteRule
val result =
q"new com.wix.accord.combinators.And(..$rules)"
c.Expr[Validator[T]](result)
}