Creating a rules engine expression
Summary
Rules engine expressions are a combination of an i18n resource file (.properties
file) and a convention based handler that implements an evaluateExpression
action and, optionally, a prepareFilters
action should the expression be available for building filters.
Info
An expression can be scaffolded using the dev console new ruleexpression
command
i18n resource file
By convention, expression resource files must live at: /i18n/rules/expressions/{idOfExpression}.properties
. This file must, at a minimum, declare two keys, label
and text
:
label=User cancelled their place on an event
text=User {_has} cancelled their place on the event: {emsEvent}
The label
item is used in the expression library selection box:
The text
item is used in the condition builder, with {somevar}
placeholders switched out for configurable fields:
Default expression field texts (for required fields that have yet to be configured) can also be declared by convention in the .properties
file. In the example above, the {emsEvent}
field label is declared thus:
label=User cancelled their place on an event
text=User {_has} cancelled their place on the event: {emsEvent}
field.emsEvent.label=select an event
Info
Note the {_has}
field. Chances are, if a field starts with an underscore, _
, it is a "magic" system field that is automatically configured for you. See "Magic field names", in Rules engine field types.
The evaluateExpression handler action
Each expression must implement a handler with an evaluateExpression
action (method) that returns true
or false
depending on the payload and configured expression field values. The handler must live at /handlers/rules/expressions/{idOfExpression}.cfc
:
// /handlers/rules/expressions/userIsLoggedIn.cfc
/**
* Expression handler for "User is/is not logged in"
*
* @feature websiteUsers
* @expressionContexts webrequest
*/
component {
private boolean function evaluateExpression( boolean _is=true ) {
return arguments._is == isLoggedIn();
}
}
Expression context
The handler CFC file can be annotated with an expressionContexts
attribute that will define in what contexts the expression can be used.
Arguments passed to the evaluateExpression method
Because it is a ColdBox handler action, the method will always receive event
, rc
and prc
arguments for you to use when relevant. In addition, the method will also always receive a payload
argument that is a structure containing data relevant to the context in which the expression is being evaluated. For example, the webrequest context provides a payload with page
and user
keys, each with a structure containing details of the current page and logged in user, respectively.
Any further arguments are treated as expression fields and should map to the {placeholder}
fields defined in your expression resource file's text
key. These arguments can also be decorated to configure the field further. For example, you may wish to define the field type + any further arguments that the field type requires:
/**
* @expressionContexts user
*/
component {
property name="emsUserQueriesService" inject="emsUserQueriesService";
/**
* @emsEvent.fieldType object
* @emsEvent.object ems_event
* @emsEvent.multiple false
*
*/
private boolean function evaluateExpression(
required string emsEvent
, boolean _has = true
) {
var userId = payload.user.id ?: "";
if ( !userId.len() || !emsEvent.len() ) {
return !_has;
}
var hasCancelled = emsUserQueriesService.userHasCancelledAttendance( userId, emsEvent );
return hasCancelled == _has;
}
}
Notice the annotations around the emsEvent
argument above. Here they define the object
field type and specify that the object for the field type is ems_event
and that multiple selection is turned off.
Tip
We prefer to leave the event
, rc
, prc
and payload
arguments out of the function definition to show the expression fields more cleanly; this is a preference though, and you can define them if you wish.
The prepareFilters handler action
The prepareFilters()
handler action accepts the same dynamic arguments based on the configured expression as the evaluateExpression()
action. However, instead of returning a boolean result, the method must return an array of preside data object filters. A simplistic example:
component {
// ...
/**
* @objects event_session
*
*/
private boolean function prepareFilters(
required string eventId // arguments from configured expression
, required string objectName // always passed to prepareFilters()
, string filterPrefix = "" // always passed to prepareFilters() before 10.18.22. As of 10.20.4, 10.19.11 & 10.18.22 *this is always empty and can be ignored*
) {
var paramName = "eventId" & CreateUUId(); // important to avoid clashing SQL param names
/* prior to 10.18.22:
var fieldPrefix = arguments.filterPrefix.len() ? arguments.filterPrefix : arguments.objectName;
return [ {
filter = "#fieldPrefix#.event = :#paramName#"
filterParams = { "#paramName#" = arguments.eventId }
} ];
*/
// from 10.18.22, 10.19.11 and 10.20.4 onwards:
return [ {
filter = "#arguments.objectName#.event = :#paramName#"
filterParams = { "#paramName#" = arguments.eventId }
} ];
}
}
Annotations
The prepareFilters()
method expects an objects
annotation that is a comma separated list of objects that the filter can apply to. You may have some common fields across different objects that require a custom expression, specifying multiple objects will make this possible. e.g.
/**
* @expressionContexts page,event,profile,article
*/
component {
private boolean function evaluateExpression() {
// ...
}
/**
* @objects page,event,profile,article
*
*/
private array function prepareFilters() {
// ...
}
}
Notice how the @expressionContexts
for the CFC is also likely to be the same list of objects.
Arguments
Your prepareFilters()
method will always receive objectName
and filterPrefix
arguments (prior to latest hotfixes of 10.18, 10.19 and 10.20 onwards).
objectName
is the name of the object being filtered.
filterPrefix
ONLY PRIOR TO latest hotfixes of 10.18, 10.19 and 10.20 ONWARDS - IGNORE FOR LATEST is a calculated prefix that should be put in front of any fields on the object that you use in filters. If the prefix is empty, then we are filtering directly on the object (you may then wish to use the object name as a prefix as we have done in the example above). This is to allow filters to be nested and to be able to be buried deep in a traversal of the database entity relationships.
Any other arguments will by dynamically generated based on the expression's evaluateExpression
definition and the user configured expression fields.
A complex filter example
A rules engine filter can get a little complicated quite easily. For example, we may need to join on subqueries to be able to use some kind of statistical filter in conjunction with other dynamically generated filters. What follows is a more realistic example. Here we are filtering on whether or not website users have cancelled their place on a specific event:
component {
// ...
/**
* @objects website_user
*/
private boolean function prepareFilters(
required string eventId // arguments from configured expression
, required boolean _has // arguments from configured expression
, required string objectName // always passed to prepareFilters()
, string filterPrefix = ""
) {
// setup params and filter clause for the passed eventId
var paramName = "eventId" & CreateUUId();
var params = { "#paramName#"={ value=arguments.eventId, type="cf_sql_varchar" } };
var subQueryAlias = "eventCancellations" & CreateUUId();
var filterSql = "#subQueryAlias#.cancellation_count #( arguments._has ? '>' : '=' )# 0";
var fieldPrefix = arguments.filterPrefix.len() ? arguments.filterPrefix : arguments.objectName; // only necessary prior to latest 10.18
// generate a subquery with user ID and cancellation count
// fields filtered by the passed eventID.
// notice the 'getSqlAndParamsOnly' argument (added in 10.8.0)
var subQuery = eventCancellationDao.selectData(
getSqlAndParamsOnly = true
, selectFields = [ "Count( id ) as cancellation_count", "website_user as id" ]
, groupBy = "website_user"
, filter = "event = :#paramName#"
, filterParams = params
);
// return a preside object data filter that includes 'extraJoins'
// array to allow us to join on our subquery
return [ { filter=filterSql, filterParams=params, extraJoins=[ {
type = "left"
, subQuery = subQuery.sql
, subQueryAlias = subQueryAlias
, subQueryColumn = "id"
, joinToTable = fieldPrefix
, joinToColumn = "id"
} ] } ];
}
}