DEV Community

Cover image for Create a Business Language for a Rails Application
Paweł Świątkowski for AppSignal

Posted on • Originally published at blog.appsignal.com

Create a Business Language for a Rails Application

As web developers, we tend to approach problems with traditional low-risk solutions. When all you have is a hammer, everything looks like a nail. When you need complex input from the user, you use a form and JSON representation (even if, in retrospect, it is not the most efficient solution).

In this post, we'll take a different approach. We'll leverage some tooling to create a business language that extends the functionality of a Rails application.

Let's get started!

Background

A few years ago, my team implemented a feature that enabled users to input a set of complex conditions. Our system presented results in accordance with these conditions.

We took the conservative road and implemented a state-of-the-art form with multiple input widgets, drag and drop, and all the UI sugar. Then the form state was serialized into a JSON object and sent to the backend, where it was unpacked, conditions applied, and results sent back.

It worked, but not without multiple problems:

  • The form was difficult to maintain, and bugs kept creeping in.
  • It was also very complex; most users could only use 10% of its capabilities.
  • Any change to JSON representation had to be implemented in two places: in the form frontend and the backend.

I will now discuss a possible alternative approach that can solve at least some of the problems outlined above and that we had not considered at the time.

A Problem to Solve in a Rails App

First, let me describe the problem we will solve in more detail. This is a very simplified version of the actual requirement I talked about above.

We'll build a backend for a promo mobile app, with a 'Coupons' section. I'm sure you are familiar with this concept from some real-world mobile applications as well.

At any given moment, you'll usually have a small number of coupons available — let's say, 10 to 30. This number is too large to fit on the screen, so to increase the conversion (usage) rate, it is important to personalize the order of the coupons. The ones most likely to pique a user's interest should go first, based on data about the available user.

So, when adding a new coupon to the system, the operator fills in the 'targeting info', i.e., the cohort this should interest. This might be based on science, intuition, or stereotypes. It can be very simplistic ("women aged 18–35") or quite complex ("women aged 50+ interested in health, or men interested in sports or the outdoors, or people with children aged 10+"). In fact, any combination of data assertions we have should be possible with any logical operators.

Using the aforementioned JSON representation, we could represent a complex condition with something like this:

{
  "operator": "or",
  "conditions": [
    {
      "operator": "and",
      "conditions": [
        { "property": "gender", "operator": "in", "value": ["woman"] },
        { "property": "age", "operator": "gt", "value": 18 },
        { "property": "age", "operator": "lt", "value": 35 }
      ]
    },
    {
      "operator": "and",
      "conditions": [
        { "property": "gender", "operator": "in", "value": ["man"] },
        {
          "property": "interests",
          "operator": "intersect",
          "value": ["sports", "outdoors"]
        }
      ]
    },
    {
      "property": "child_birth_year",
      "operator": "between",
      "value": [2004, 2012]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Even though this is legible, it is hard to follow. And the form supporting it is tricky to understand as well (actually, we haven't even come up with a satisfactory solution for a form that mixes "and" and "or" logical operators).

A Solution: Design a New Business Language

An alternative solution to this problem is to get rid of the form and the JSON representation. Instead, we'll design a whole new, specific business language to apply here and then implement it in a Rails application. As a result, our condition will look like this:

(gender(woman) and age > 18 and age < 35) or (gender(man) and (interest(sports) or interest(outdoors))) or has_child_aged(10, 18)
Enter fullscreen mode Exit fullscreen mode

As this is much terser, it is easier to understand and debug. It could be really hard for a regular user picked from the depths of the Internet to use. However, in cases like this, the actual users can be trained with access to fast customer support.

We could call our business language a domain-specific language (DSL) because this is more or less the term's original meaning. However, in recent years, especially in the Ruby community, the meaning of DSL has somewhat changed. When we talk about DSLs, we usually mean bending the Ruby language to look like something else while it is still actually Ruby. Think about RSpec:

describe "a subject" do
  let(:number) { 15 }
  it { expect(NumberDoubler.call(number)).to eq(30) }
end
Enter fullscreen mode Exit fullscreen mode

Even though we introduce specific words defined as Ruby methods (describe, let, it), this is still Ruby.

It is important to understand that the business language example above is not Ruby. Users won't be able to access the filesystem, the network, or make infinite loops. All the language constructs only allow talking about users and their properties or combining expressions with logical operators.

It is a completely new language, which needs to have a grammar, parser, etc. This is what we are going to do next.

Implement a Business Language in Your Rails App with Parslet

To achieve our goals, we will use a gem called Parslet, for:

Constructing parsers in the PEG (Parsing Expression Grammar)
fashion.

Source: Parslet website

PEG is relatively simple (compared to other techniques) and quite fast, especially for small input.

This post is not going to be a step-by-step Parslet tutorial. The official Parslet documentation does a great job of going through the process. But let's briefly go over the building blocks of language interpreting with Parslet, which consists of three steps.

In step one, we define a parser:

class CouponLang::Parser < Parslet::Parser
  rule(:lparen)     { str('(') >> space? }
  rule(:rparen)     { str(')') >> space? }
  # [...]
end

tree = CouponLang::Parser.new.parse("age > 18 or age < 30")
Enter fullscreen mode Exit fullscreen mode

The result of the parse method is an intermediary tree, which is a hash-like structure representing language tokens.

This tree is fed to a transform:

ConjunctionExpr = Struct.new(:left, :right) do
  def eval(user)
    left.eval(user) && right.eval(user)
  end
end

# [...]

class CouponLang::Transform < Parlet::Transform
  rule(:and => [subtree(:left), subtree(:right)]) { ConjunctionExpr.new(left, right) }
  # [...]
end

tree = CouponLang::Parser.new.parse("age > 18 or age < 30")
ast = CouponLang::Transform.new.apply(tree)
Enter fullscreen mode Exit fullscreen mode

The result of the transform is an abstract syntax tree (AST). The final step is to evaluate the AST, passing a user as a context:

user = User.find(params[:user_id])
coupons = Coupon.active(Time.now)
proritized, others = coupons.partition do |coupon|
  tree = CouponLang::Parser.new.parse(coupon.priority_condition)
  ast = CouponLang::Transform.new.apply(tree)
  ast.eval(user)
end
Enter fullscreen mode Exit fullscreen mode

The last code listing shows how this can be used in a wider context, relevant to our original requirements.

However, there is one important limitation to mention. As you can see, all active coupons are fetched upfront, and we apply the conditions on them one by one. It is safe, because, as stated before, only a handful of coupons are active at the time. However, this makes it impossible to answer the question: "What users would coupon X prioritize?"

If you really need to answer this kind of question, you have to fetch all the users and apply the conditions to them, user by user, in the application layer.

Of course, there are more efficient solutions.

For example, you can write another transform which, instead of evaluating the conditions, translates them into an SQL query which you can then execute.

A Complete Example

I'm not going to lie to you: getting the code parser for the language right might be a tedious task. I spent a few hours getting the example for this article working. For brevity, I show an example that only implements the age and has_children functions for the user params.

This is the parser:

module CouponLang
  class Parser < Parslet::Parser
    rule(:lparen) { str("(") >> space? }
    rule(:rparen) { str(")") >> space? }

    rule(:space) { match('\s').repeat(1) }
    rule(:space?) { space.maybe }
    rule(:sep) { space | any.absent? }

    rule(:or_) { str("or") >> space }
    rule(:and_) { str("and") >> space }

    rule(:gt) { str(">").as(:gt) >> space? }
    rule(:lt) { str("<").as(:lt) >> space? }
    rule(:comparison_op) { gt | lt }
    rule(:comparison) { int.as(:left) >> comparison_op >> int.as(:right) }

    rule(:integer) { match("[0-9]").repeat(1).as(:int) >> space? }
    rule(:string) { match["a-z"].repeat(1).as(:string) >> space? }

    rule(:age_fun) { str("age").as(:age_fun) >> space? }
    rule(:has_children_fun) { str("has_children").as(:has_children_fun) >> sep }

    rule(:int) { age_fun | integer }
    rule(:bool) { comparison | has_children_fun | bool_in_parens }
    rule(:bool_in_parens) { lparen >> expr >> rparen }

    rule(:expr) { infix_expression(bool, [or_, 1, :left], [and_, 1, :left]) { |left, op, right| {op.to_s.strip.to_sym => [left, right]} } }
    root(:expr)
  end
end
Enter fullscreen mode Exit fullscreen mode

And this is the transform:

module CouponLang
  class Transform < Parslet::Transform
    UserAgeFun = Class.new do
      def eval(user) = user.age
    end

    HasChildrenFun = Class.new do
      def eval(user) = user.has_children?
    end

    IntLit = Struct.new(:int) do
      def eval(user) = int.to_i
    end

    InfixOp = Struct.new(:left, :op, :right) do
      def eval(user) = left.eval(user).public_send(op, right.eval(user))
    end

    LogicalOr = Struct.new(:left, :right) do
      def eval(user) = left.eval(user) || right.eval(user)
    end

    LogicalAnd = Struct.new(:left, :right) do
      def eval(user) = left.eval(user) && right.eval(user)
    end

    rule(:age_fun => simple(:_)) { UserAgeFun.new }
    rule(:has_children_fun => simple(:_)) { HasChildrenFun.new }
    rule(:or => [subtree(:left), subtree(:right)]) { LogicalOr.new(left, right) }
    rule(:and => [subtree(:left), subtree(:right)]) { LogicalAnd.new(left, right) }
    rule(:left => subtree(:left), :gt => simple(:_), :right => subtree(:right)) { InfixOp.new(left, :>, right) }
    rule(:left => subtree(:left), :lt => simple(:_), :right => subtree(:right)) { InfixOp.new(left, :<, right) }
    rule(:int => simple(:int)) { IntLit.new(int) }
  end
end
Enter fullscreen mode Exit fullscreen mode

It's important to thoroughly test the language you have created. Otherwise, some unpleasant surprises will be waiting for you when you want to change it in the future. An example of a spec for the parser looks like this:

RSpec.describe CouponLang::Parser do
  it "parses expression with parentheses" do
    expect(described_class.new.parse_with_debug("has_children and (age > 12)")).to eq(and: [{has_children_fun: "has_children"}, {left: {age_fun: "age"}, gt: ">", right: {int: "12"}}])
  end
end
Enter fullscreen mode Exit fullscreen mode

And for the transform:

def parse_and_eval(code, user)
  tree = CouponLang::Parser.new.parse(code)
  ast = CouponLang::Transform.new.apply(tree)
  ast.eval(user)
end

RSpec.describe CouponLang::Transform do
  it "evaluates conditions with parentheses" do
    user_with_children = User.new(birth_year: 1980, child_birth_years: [2008, 2012])
    user = User.new(birth_year: 1981)
    code = "age > 65 or (has_children and age > 40)"

    expect(parse_and_eval(code, user_with_children)).to eq(true)
    expect(parse_and_eval(code, user)).to eq(false)
  end
end
Enter fullscreen mode Exit fullscreen mode

Of course, for a full Rails integration, you must also validate whether a user puts a valid CouponLang code in a new coupon form. Parslet returns nil when it cannot parse the text, so it is as simple as this:

class Coupon < ApplicationRecord
  validate :correct_code

  def correct_code
    CouponLang::Parser.new.parse(code).present?
  end
end
Enter fullscreen mode Exit fullscreen mode

Extra Parsing Tips

We are ready to ship the CouponLang to our internal users, with great flexibility for defining conditions.

The road to arrive here has not exactly been without bumps, so here are a few additional tips:

Start Small

Writing a parser is difficult. Start with the simplest possible language, make a parser for it, write tests, commit, and add another feature.

For the CouponLang, I first started with a language that could only evaluate logical conditions with true and false values (like false and true). Then I added parentheses — (true or false) and true. Only after this was I ready to replace true/false literals with comparisons and functions.

Test Subparsers

I haven't shown it, but Parslet allows you to test only a subset of parsers. With our language above, you could use CouponLang::Parser.new.comparison.parse("11 > 12") to just check if the comparison part is fine. This is very useful for testing and debugging.

Check the Tree is Transform-friendly

Unless you are quite experienced in writing PEGs with Parslet, you may end up with a parser that works, but a tree that's not transform-friendly. As a result, you will have to change it. Be prepared for that.

Be Careful with Backward Compatibility

When changing the parser breaks backward compatibility, chances are that the coupons already saved in the database will stop working. You should consider running every coupon from the production database against the new parser to check whether they will work after the change. It's a one-time
operation, but important to save a lot of headaches.

When You Should Consider Using a Parser

You may think that my coupon example is quite unusual, even though it comes from a real-world problem. It's true that what I show here is not for everyday Rails development. You need a good reason to implement such a powerful (and not very user-friendly) tool as Parslet.

And the reason is simple — it is still easier than trying to do it any other way.

A few examples of when a parser might be worth considering include:

  • For very fine access control to some resources - e.g., when you need to make a document accessible only for "all" managers, members of team alpha or beta that have worked here for at least 3 months, and Jason from HR".
  • When modeling game-like conditions - Want to wield the Epic Battleaxe of Doom? Sure, you just need to be at least level 32, have at least 180 strength, and have finished the quest "Waiting for Ragnarok" or "The Abyss of Destiny".

Remember that you are not limited to languages that return a boolean as a result. You can, for example, create a language that evaluates numbers and thus gives multiple rankings for users. Or restaurants. Or dogs.

If you are brave enough, you could also build a language that behaves much more like a "real" programming language and executes something with variables and conditionals. For example, if you build a system that reacts to certain events (like a notification that a database is down):

if(notification.severity <= 2) {
  user = sample_from_groups('sre)
  if(user.has_phone_number and notification.severity == 1) {
    send_sms_to(user)
  } else {
    send_email_to(user)
  }

  send_slack_notification('alerts, notification.payload)
}
Enter fullscreen mode Exit fullscreen mode

This, however, is much more complicated than the example I showed in this article. You might want to check out this attempt to parse Java source code with Parslet.

Wrapping Up

In this post, we saw how you can leverage tooling to build a programming language that extends your Rails application's functionality. Even if you don't need it in your current project, it's worth knowing that it is possible without too much effort.

We also explored when it's worthwhile to consider using this approach.

If this piqued your curiosity about creating languages, I hope you will have a lot of fun experimenting.

Happy parsing (and interpreting)!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Top comments (0)