Hack book of Hoa\Ruler

Business rules (like “ all the customers that have reached 100€ in one time receive a voucher of 10% on the next purchase ”) are most of the time defined outside the application. They are even often written in a different language than the used programming language. The Hoa\Ruler library provides an engine allowing to simply execute business rules while being efficient and very extensible.

Table of contents

Introduction

The business logic is very different from the Computer logic. “ All the customers that have reached 100€ in one time receive a voucher of 10% on the next purchase ”. This rule allows to access to certain parts of the program if valids. However, it can change at any moment. Most of the time, in a team, it is not the role of the developer to implement this rule. It will probably come from a business rules repository, which have been written by other persons, either manually or thanks to a third-party program. This implies that the language used to express a rule is not the language used to develop the program. An even more obvious example is the use of a rule to filter elements: An element is accepted if “ its group is customer or guest and its number of points is greater than 30 ”. This rule can be written by a user via a command line interface to filter results from a database or logs.

This is important to understand that rules must be written in a dedicated language. Nevertheless, the way we use rules is very vast and unpredictable. This is why it is primordial to have flexible and extensible rules in the syntax. For instance, it should be allowed to add operators and functions: “ All the customers from the hotel with a Gold pass will receive a voucher of 10%” . The “Gold pass” can be an operator or a function specific to the current business.

The language the Hoa\Ruler library uses to describe rules respects these constraints of extensibility. The rules will not be close to the human language but they will stay natural when reading. If we take the example of the “ its group is customer or guest and its number of points is greater than 30 ”, it will be written: group in ["customer", "guest"] and points > 30 . The group and points elements are variables of the rule. Their values will be defined in a context.

From a more formal point of view, a rule is a predicate, it means that its result is always a boolean: true or false . Because these rules are likely to be manipulated (modified) and executed, the Hoa\Ruler library provides several tools to work efficiently with these constraints, presented in the following sections.

Global workflow

The global workflow of the Hoa\Ruler library follows 3 steps:

Defining a rule, Defining a context, Use of an asserter for the execution.

The rule is a string matching a specific syntax, which is described by the grammar of the language defined by the Hoa\Ruler library (detailed hereinafter). This rule contains variables whose values are defined by the context. The context can contain scalar values, arrays or even functions or objects. Finally, the asserter associates the context to the rule in order to execute it and to obtain a result. We remind you about the result which is necessarily a boolean. This is therefore a predicate.

The context is represented by the Hoa\Ruler\Context class. The asserter is represented by the Hoa\Ruler\Visitor\Asserter class. We can use the Hoa\Ruler\Ruler::assert method to ease its usage. Thus:

$ruler = new Hoa\Ruler\Ruler(); // 1. Write a rule. $rule = 'group in ["customer", "guest"] and points > 30'; // 2. Create a context. $context = new Hoa\Ruler\Context(); $context['group'] = 'customer'; $context['points'] = function () { return 42; }; // 3. Assert! var_dump( $ruler->assert($rule, $context) ); /** * Will output: * bool(true) */

The rule is defined in the $rule variable. The context, as far as it is concerned, is defined in the $context variable. It contains 2 variables: group and point , respectively with the 'customer' and 42 (returned by a function) values. Finally, the last step uses the Hoa\Ruler\Ruler::assert method to execute the $rule rule with the $context context (this latter is optional). The result is true because group belongs to the list customer or guest , and point is greater than 30. Change the values in the context or the rule to observe a different result.

The following sections detail the behavior of each step but the classical usage remains as simple as that!

Grammar (through examples)

The grammar of the rules language is described in the hoa://Library/Ruler/Grammar.pp file. This grammar is expressed with the PP language. To get more information, please see the Hoa\Compiler library. We clarify that the language supports Unicode. We are not going to explain the language whilst the grammar provides all the necessary details. Nevertheless, we are going to give several syntax examples.

Language of Hoa\Ruler through examples. syntax semantics 'foo' , "foo" , 'f\'oo' strings true , false , null pre-defined constants 4.2 a real 42 an integer ['foo', true, 4.2, 42] an array (heterogeneous) sum(1, 2, 3) a call to the sum function with 3 arguments points a variable points['x'] an array access line.pointA an object access (attribute) line.length() a call to a method and , or , xor , not logical operators = , != , > , < , >= , <= comparison operators is , in membership operators

Of course, these examples represent atomic parts of the grammar that we can combine. Thus: userA.allows(groups[groupId][userB]) is valid. Just like f(user, points > 7 and points < 42) which is also valid.

In actual fact, functions, comparison operators and membership operators are not defined by the grammar but by the asserter (detailed hereinafter). Hoa\Ruler does not make any difference between an operator and a function. Operators are considered as functions; an operator only being a function with an arity of 1 or 2. Thus, we can write 2 = 2 or =(2, 2) , this will strictly produce the same result. Just like the name of functions that is not defined in the grammar, the name of operators is neither defined by the grammar, excepted for the logical operators that have a particular processing (because of the operator precedence). The immediate result is that we can create our own operators or functions. We can imagine a ∈ A , √(42) or even userA allows userB being valid expressions.

Context

The context defines values of variables present in rules. These values can be of kind:

Constants , like 42 or 'foo' which are scalars, or [1, 1, 2, 3, 5] or an object which are structured types,

, like or which are scalars, or or an object which are structured types, Computed values, it means returned by a function or a method.

By default, the computed values are computed only once. Indeed, they are stored in a cache for performance reasons. If we would like to recompute them at each read, an encapsulation in an object of kind Hoa\Ruler\DynamicCallable is required.

Before detailing this part, let's present the context. A context is an instance of the Hoa\Ruler\Context class which implements the ArrayAccess interface. Thus, we use the context like an array:

$context = new Hoa\Ruler\Context(); $context['key'] = 'value'; var_dump( isset($context['key']), $context['key'] ); /** * Will output: * bool(true) * string(5) "value" */

To register computed values, we can use a function; thus:

$context['computed'] = function () { return 42; }; var_dump($context['computed']); /** * Will output: * int(42) */

We said computed values are stored in a cache on the first read. To illustrate this we will use a function incrementing an integer at each call:

$i = 0; $context['int'] = function () use (&$i) { return ++$i; }; var_dump( $context['int'], $context['int'], $context['int'], $i ); /** * Will output: * int(1) * int(1) * int(1) * int(1) */

The $i variable has been incremented once to go from 0 to 1, and then, has been stored in a cache. Now, if we encapsulate this function in an instance of the Hoa\Ruler\DynamicCallable class, let's observe what happens:

$i = 0; $context['int'] = new Hoa\Ruler\DynamicCallable( function () use (&$i) { return ++$i; } ); var_dump( $context['int'], $context['int'], $context['int'], $i ); /** * Will output: * int(1) * int(2) * int(3) * int(3) */

The result is no longer stored in a cache.

We can also use a declared function thanks to its name. Attention, it is not possible to call native PHP functions for security reasons. The context has not such a scope. Thus:

function answer() { return 42; } $context['the_answer'] = 'answer'; var_dump($context['the_answer']); /** * Will output: * int(42) */

Nothing more to know about the context. It is not that complicated!

Asserter

Given a rule and a context, the asserter is responsible to compute the result of a rule, including the values of variables which are in the context.

The rule can have two different forms:

a string or

or an object model.

If it is a string, it will be transformed into an object model automatically by the Hoa\Ruler\Ruler::assert method. This object model implements the interfaces of the Hoa\Visitor library and thus can be visited. This is why the Hoa\Ruler\Visitor\Asserter asserter is a visitor. Finally, the context is defined on the asserter with the Hoa\Ruler\Visitor\Asserter::setContext method. Thus:

$ruler = new Hoa\Ruler\Ruler(); $rule = 'points > 30'; $context = new Hoa\Ruler\Context(); $context['points'] = 42; // Define an asserter. $asserter = new Hoa\Ruler\Visitor\Asserter(); // Set this asserter on the ruler. $ruler->setAsserter($asserter); // Assert! var_dump( $ruler->assert($rule, $context) ); /** * Will output: * bool(true) */

The Hoa\Ruler\Ruler::assert method will automatically define the context on the asserter.

Add functions

We said the names of the operators and of the functions in the rules are free. Therefore, we supposed the ability to define our own operators and functions. Let's add the logged function that tests if an object of kind User is connected. Here is this object:

class User { const DISCONNECTED = 0; const CONNECTED = 1; protected $_status = 1; public function getStatus() { return $this->_status; } }

The implementation of the logged function might be the following:

$logged = function (User $user) { return $user::CONNECTED === $user->getStatus(); };

Finally, to declare this function, we will use the Hoa\Ruler\Visitor\Asserter::setOperator method. We can also cite the operatorExists , getOperator and getOperators methods which respectively allow to test if an operator exists, to get a previously declared operator and to get all the declared operators. Thus:

$ruler = new Hoa\Ruler\Ruler(); $rule = 'logged(user) and points > 30'; $context = new Hoa\Ruler\Context(); $context['user'] = new User(); $context['points'] = 42; // Declare the `logged` function. $asserter = new Hoa\Ruler\Visitor\Asserter(); $asserter->setOperator('logged', $logged); $ruler->setAsserter($asserter); // Assert! var_dump( $ruler->assert($rule, $context) ); /** * Will output: * bool(true) */

The Hoa\Ruler\Ruler class only contains methods to work faster and to hide the underlying mechanism (detailed hereinafter). One of them is the getDefaultAsserter static method which returns a unique instance of the Hoa\Ruler\Visitor\Asserter class. We can use this unique instance to define new operators for all the rules. Its usage is very similar to what we saw previously:

$ruler = new Hoa\Ruler\Ruler(); $rule = 'logged(user) and points > 30'; $context = new Hoa\Ruler\Context(); $context['user'] = new User(); $context['points'] = 42; // Declare the `logged` function. $ruler->getDefaultAsserter()->setOperator('logged', $logged); // Assert! var_dump( $ruler->assert($rule, $context) ); /** * Will output: * bool(true) */

The Hoa\Ruler\Visitor\Asserter::setOperator method accepts any valid callable.

The and , or , xor , not , = , != and sum etc. operators are defined in the Hoa\Ruler\Visitor\Asserter class. Feel free to read it to get inspired!

Language transformation

The underlying mechanism hidden by the Hoa\Ruler\Ruler class is simple and very modular. The following sections detail the possible transformations, and associated usage, of the language.

First of all, the rule is interpreted by an interpreter to be transformed into an object model. Then, this object model is used by the asserter, or can be transformed into PHP code or transformed into a rule. This object model is the central point of the language, this is its most advanced form.

Interpreter: Language to object model

A rule is a string. To transform it into an object model, we will use the Hoa\Compiler library.

Thanks to the grammar of rules (defined in the hoa://Library/Ruler/Grammar.pp file), we will get an AST: An abstract syntax tree. For instance, for the rule points > 30 , its AST is:

$ echo 'points > 30' | hoa compiler:pp hoa://Library/Ruler/Grammar.pp 0 --visitor dump > #expression > > #operation > > > token(identifier, points) > > > token(identifier, >) > > > token(integer, 30)

In order to be exploitable, this tree will be transformed into an object model. This transformation is ensured by the Hoa\Ruler\Visitor\Interpreter visitor. Thus, if we should apply it manually:

$compiler = Hoa\Compiler\Llk::load( new Hoa\File\Read('hoa://Library/Ruler/Grammar.pp') ); $ast = $compiler->parse('points > 30'); $interpreter = new Hoa\Ruler\Visitor\Interpreter(); $model = $interpreter->visit($ast); var_dump( get_class($model) ); /** * Will output: * string(21) "Hoa\Ruler\Model\Model" */

We learn that the model is represented by classes belonging to the Hoa\Ruler\Model namespace.

All these operations are replaced by the Hoa\Ruler\Ruler::interpret static method.

$model = Hoa\Ruler\Ruler::interpret('points > 30');

We can get the compiler with the Hoa\Ruler\Ruler::getCompiler method.

We will see how this step can be important to get better performances.

Compiler: Object model to PHP

The object model can be created manually by instantiating all the objects of kind Hoa\Ruler\Model\* and by combining them together.

The PHP code required for this operation can be automatically generated thanks to the Hoa\Ruler\Visitor\Compiler class. Thus:

$compiler = new Hoa\Ruler\Visitor\Compiler(); echo $compiler->visit($model); /** * Will output: * $model = new \Hoa\Ruler\Model(); * $model->expression = * $model->{'>'}( * $model->variable('points'), * 30 * ); */

The generated code is simplified and optimized to be as short as possible whilst staying readable for a human.

We will see how this step can be important to get better performances.

Disassembler: Object model to language

So far, we have seen how to jump from a rule to its object model. The disassembler applies the opposite operation: It transforms an object model into a string.

The generated rule can differ a little bit from the original one in term of syntax (parenthesis, quotes, spacing…) but never in term of semantics. Thus:

$disassembly = new Hoa\Ruler\Visitor\Disassembly(); echo $disassembly->visit($model); /** * Will output: * (points > 30) */

Performances

Transforming a rule into an object model is not a low-cost operation. It becomes significant when applied thousand times per minute. Nevertheless, applying an asserter on an object model is a low-cost operation. We will present two ways to avoid the transformation of a rule into an object model.

Serialize the object model

Once the object model is present, we can serialize it with the help of the serialize PHP function. We will get a string representing instances of objects forming the object model. To get back to the object model and being able to apply an asserter, we will use the unserialize PHP function. The result of this serialization can be stored in a database instead of rules. This requires a little bit more space but let's remind that we can transform an object model to its rule easily thanks to the Hoa\Ruler\Visitor\Disassembly class, consequently this information is not lost. Thus:

$ruler = new Hoa\Ruler\Ruler(); $rule = 'points > 30'; $context = new Hoa\Ruler\Context(); $context['points'] = 42; // Nothing in the database. if (null === $serialized = $database->get($ruleId)) { // We transform the rule into an object model. $model = Hoa\Ruler\Ruler::interpret($rule); // We serialize and save the object model. $database->save($ruleId, serialize($model)); } else { // We have a serialization! We unserialize it to get the object model. $model = unserialize($serialized); } // We can assert by using a model instead of a rule! var_dump( $ruler->assert($model, $context) ); /** * Will output: * bool(true) */

This way, the rule is transformed into an object model only once!

Save and execute PHP code

Another way to avoid the transformation of a rule into its object model is to save the PHP code allowing to build the object model thanks to the Hoa\Ruler\Visitor\Compiler class. Once this PHP code stored and executed, we get back our object model.

However, executing such a PHP code will prove to be slightly slower and more difficult to deploy than the previous technique.

Conclusion

The Hoa\Ruler library defines a language of simple business rules inspired by the SQL language for the syntax. The language can be transformed in many ways: To an object model in order to be executed, or from this object model, into PHP code or into the original language. The instantiation of the variables present in the language relies on a context. All these operations are hidden through a simple and clear interface.

The performance aspect has been addressed and two solutions have been proposed. Today it is used by the industry on important projects, Hoa\Ruler is able to support heavy loads if these simple methodologies are applied.