Building an External DSL

time to read 7 min | 1261 words

Over in the Alt.net mailing list, Glenn said:

If I was really being verbose in the way a business user would expect to define this I would say

               When customer is preferred and the order exceeds 1000 then apply a .05 discount and apply free shipping.

Now do that in a DSL….

Now, I am sucker for this kind of things, and I can probably use the practice. So I decided to go ahead with this. Just to be clear, this is supposedly a natural language processing, only we are going to cheat and get around that.

First, let us analyze the structure of the statement that we need to handle:

When customer is preferred and the order exceeds 1000 then apply a .05 discount and apply free shipping.

I put strike through all the fluff stuff, the things that doesn't really means anything for the language. Keywords are marked in blue, operators in red.

We have the following keywords:

  • When
  • And
  • Then

And the following operators:

  • Is
  • Exceeds
  • Discount
  • Shipping

Everything else is a variable.

This language follows the following syntax:

When [parameter] [operator] [value] (and [parameter] [operator] [value])+ then [parameter] [value] [operator] (and [parameter] [value] [operator] )

Note that the syntax between the when clause and the then clause is different. The operator is the last word there. This is because on the when we would usually do comparisons, while on the then we will perform actions.

Now, to the actual building of the language. We have the following model:

image

Let us take a look at the parser, it is a stupid, single purpose one, but it works. The main parts are to extract a list of ActionExpressions from the language.

public WhenThenClause SplitToWhenThenClause(string text)
{
	if (text.StartsWith("when", StringComparison.InvariantCultureIgnoreCase) == false)
		throw new InvalidOperationException("statement should start with a when");
	int thenIndex = text.IndexOf("then", StringComparison.InvariantCultureIgnoreCase);
	if (thenIndex == -1)
		throw new InvalidOperationException("statement should have a then");
	WhenThenClause clause = new WhenThenClause();
	string whenClause = text.Substring(4, thenIndex - 4).Trim();
	ParseClause(whenClause,
	            delegate(string[] parts)
	            {
	            	ActionExpression item = new ActionExpression();
	            	item.Left = parts[0];
	            	item.Operator = parts[1];
	            	item.Right = parts[2];
	            	clause.When.Add(item);
	            });
	string thenClause = text.Substring(thenIndex + 4);
	ParseClause(thenClause,
	            delegate(string[] parts)
	            {
	            	ActionExpression item = new ActionExpression();
	            	item.Left = parts[0];
	            	item.Right= parts[1];
	            	item.Operator = parts[2];
	            	clause.Then.Add(item);
	            });

	return clause;
}


private static void ParseClause(string clause,
                                Action<string[]> action)
{
	foreach (string subClauses in
		clause.Split(new string[] { "and" }, StringSplitOptions.RemoveEmptyEntries))
	{
		if (subClauses.Trim().Length == 0)
			continue;

		string[] parts = subClauses.Split(new char[] {' ', '\t'}, StringSplitOptions.RemoveEmptyEntries);
		if (parts.Length != 3)
			throw new InvalidOperationException("A clause should have three parts "+
"[object] [operation] [value], found:
" + subClauses); action(parts); } }

Now we need to look at the DslExecuter. This is the one that will take an ActionExpression and actually execute it. We are using reflection to avoid having to do all the work ourselves, so we have this:

public class DslExecuter
{
	private readonly Hashtable parameters = new Hashtable(StringComparer.InvariantCultureIgnoreCase);

	public void AddParameter(string name, object parameter)
	{
		parameters[name] = parameter;
	}

	public object Invoke(ActionExpression expression)
	{
		object obj = parameters[expression.Left];
		if (obj == null)
			throw new InvalidOperationException("Could not find parameter with name: " + expression.Left);
		MethodInfo method = obj.GetType().GetMethod(expression.Operator, BindingFlags.IgnoreCase 
| BindingFlags.Instance | BindingFlags.Public); if (method == null) throw new InvalidOperationException("Could not find method operator " +
expression.Operator + " on " + expression.Left); ParameterInfo[] methodParams = method.GetParameters(); if(methodParams.Length!=1) throw new InvalidOperationException(expression.Operator + " should accept a single parameter"); object converted; Type paramType = methodParams[0].ParameterType; if(paramType.IsEnum) { converted = Enum.Parse(paramType, expression.Right,true); } else { converted = Convert.ChangeType(expression.Right, paramType); } return method.Invoke(obj, new object[] {converted}); } }

Now that we have those two building blocks, it is very easy actually build the DSL itself. Here is the relevant code:

public void Execute()
{
	bool result = true;
	foreach (ActionExpression expression in parsed.When)
	{
		bool clauseResult = (bool) this.executer.Invoke(expression);
		result &= clauseResult;
	}

	if(result)
	{
		foreach (ActionExpression expression in parsed.Then)
		{
			executer.Invoke(expression);
		}
	}
}

We build a domain model to aid us here:

image

Basically this translate directly to the parameters that the DSL needs. This means that all we have left is just the setup of the DSL.

[Test]
public void CanExecuteThenStatement()
{
	ExternalDSLDemo dsl = new ExternalDSLDemo(sentence);
	Customer customer = new Customer();
	customer.CustomerStatus = CustomerStatus.Preferred;
	dsl.AddParameter("customer", customer);
	Order order = new Order();
	order.TotalCost = 5000;
	order.ShippingType = ShippingType.Fast;
	dsl.AddParameter("order", order);
	dsl.AddParameter("apply", new ApplyCommands(order));
	dsl.Execute();

	Assert.AreEqual(4750, order.TotalCost);
	Assert.AreEqual(ShippingType.Free, order.ShippingType);
}

But I am pretty sure that I explained that this external DSL are very hard. This doesn't seem very hard all.

No, this external DSL is not really hard. But try to do support something like:

when (customer is preferred) and (order exceeds 1000 or order is new) then ...

This is when it gets suddenly much more complex... and why I think that External DSL are rarely worth the time to fully develop them. It is much easier to build on top of an existing infrastructure.

You can get the code for this here:

https://rhino-tools.svn.sourceforge.net/svnroot/rhino-tools/trunk/rhino-dsl/Rhino.DSL.Tests/ExternalDSL