Building an external DSL in Scala

It’s a requirement we have run into many times but the general case goes something like: the marketing team want to setup adhoc promotions and don’t want to be beholden to the developers to do so. Similarly the developers would prefer to focus on developing rather than responding to urgent “can you setup this campaign” requests from marketing.

So there is just that pesky problem of how you actually give marketing the ability to create promotions and have the flexibility they want? Our response is to build a DSL and Scala’s StandardTokenParsers makes it a pleasure to do so.

Say for example we want to allow marketing to write english like business rules such as:

total reward budget is 100000 and
(maximum cycles per day is 2 and
maximum cycles per week is 3 and
per customer total reward is 500)

We start with some modeling. We will call each of these lines a “limit” so we start with a marker trait:

trait Limit

We want each of our limits to be represented by a case class so we have the following:

case class TotalRewardBudgetLimit(budget: BigDecimal) extends Limit

case class MaximumCyclesLimit(timePeriod: TimePeriod, value: Int) extends Limit

case class CustomerTotalRewardLimit(amount: BigDecimal) extends Limit

For completeness we model TimePeriod as such:

sealed trait TimePeriod

case class Day extends TimePeriod

case class Week extends TimePeriod

We want to allow our language to have conjunctions, disjunctions and precedence by which we mean the ability to join sentences with and, or and combine them with brackets. So we have these additional limits:

case class ConjunctionLimit(left: Limit, right: Limit) extends Limit

case class DisjunctionLimit(left: Limit, right: Limit) extends Limit

We can then parse our DSL simply by leveraging Scala’s StandardTokenParsers like so:

object LimitDsl extends StandardTokenParsers with PackratParsers {

  lexical.delimiters +=("(", ")", "and", "or")

  lexical.reserved +=("total", "reward", "budget", "customer", "maximum", "cycles", "per", "day", "week", "is", "and", "or")

  private lazy val limit: Parser[Limit] = conjunction | disjunction | totalBudget | perCustomerTotalReward | maximumCyclesPer

  private lazy val conjunction: Parser[ConjunctionLimit] = "(" ~ limit ~ "and" ~ limit ~ rep("and" ~> limit) ~ ")" ^^ { 
    case "(" ~ l ~ "and" ~ r ~ c ~ ")" => c.foldLeft(new ConjunctionLimit(l, r))(new ConjunctionLimit(_, _))
  }

  private lazy val disjunction: Parser[DisjunctionLimit] = "(" ~ limit ~ "or" ~ limit ~ rep("or" ~> limit) ~ ")" ^^ { 
    case "(" ~ l ~ "or" ~ r ~ d ~ ")" => d.foldLeft(new DisjunctionLimit(l, r))(new DisjunctionLimit(_, _))
  }

  private lazy val totalBudget: Parser[TotalRewardBudgetLimit] = "total" ~> "reward" ~> "budget" ~> "is" ~> numericLit ^^ { 
    case a => new TotalRewardBudgetLimit(a.toDouble)
  }

  private lazy val perCustomerTotalReward: Parser[CustomerTotalRewardLimit] = "per" ~> "customer" ~> "total" ~> "reward" ~> "is" ~> numericLit ^^ {
    case a => new CustomerTotalRewardLimit(a.toDouble)
  }

  private lazy val maximumCyclesPer: Parser[MaximumCyclesLimit] = "maximum" ~> "cycles" ~> "per" ~> timePeriod ~ "is" ~ numericLit ^^ { 
    case t ~ "is" ~ a => new MaximumCyclesLimit(t, a.toInt)
  }

  private lazy val timePeriod: Parser[TimePeriod] = ("day" | "week") ^^ {
    case "day" => new Day()
    case "week" => new Week()
  }

  def parse(s: String) = {
    val tokens = new lexical.Scanner(s)
    phrase(limit)(tokens)
  }

}

The use of lazy val rather than def and the mixing in PackratParsers over a significant performance improvement.

We can then parse any string by calling LimitDsl.parse(…) this will return a Parsers.ParseResult that we can pattern match on like so:

LimitDsl.parse(value) match {
  case LimitDsl.Success(l, _) => l
  case LimitDsl.Failure(msg, _) => … failure handling
  case LimitDsl.Error(msg, _) => … error handling
}

If successfully parsed this contains a single limit that we can recurse down to evaluate. Assuming we want a boolean response we can do this something like this:

def evaluate(limit: Limit): Boolean = {
  limit match {
    case l: CustomerTotalRewardLimit => … custom logic
    case l: ConjunctionLimit => evaluate(l.left) && evaluate(l.right)
    case l: DisjunctionLimit => evaluate(l.left) || evaluate(l.right)
    case l: MaximumCyclesLimit => … custom logic
    case l: TotalRewardBudgetLimit => … custom logic
    case _ => throw new IllegalArgumentException("Unhandled limit: " + limit)
  }
}

As for the front end we often start simple and just use a text-box to allow rules to be entered as text. This is quick to implement but isn’t particularly user friendly or overly sexy. Luckily we can get as crazy sexy as we want on the front end as long as we produce our nice plain dsl text as the output.