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.
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, - 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
- 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.
An error or a suggestion about the documentation? Contributions are welcome!