Regent provides a lightweight framework aimed at helping you organize your application's business logic.
- Quick Start
- How Rules Work
- Querying Rules
- Custom Predicates
- Troubleshooting
- Initialization
- Predicates
- Composition
- Queries
- Examples
npm install --save regent
Our first rule will tell us if we need an umbrella. This is easy to identify in real life - if it is raining, we need an umbrella.
To write our rule, we'll want to import regent.
import regent from 'regent';
The default import (regent
) gives us the ability to compose and query rules.
Let's create a rule to determine if it is raining.
A rule is an object with three properties: left
, fn
, and right
. Think of left
and right
as two sides of an equation, with fn
being the operator. The fn
property tells regent which predicate to use for evaluation. Our isRaining
rule will look like this:
const isRaining = { left: '@isRaining', fn: 'equals', right: true };
This rule tells Regent to compare the left isRaining
, using the equals
predicate, to the value true
. You can read more about how rules work, or the available predicates.
NOTE: The @
preceding isRaining
tells regent that this value is a path to the property in your data object. You can use the @
symbol in the left or right properties. If you need a literal @
, you can escape the character with another: right: '@@twitterHandle'
We can now use Regent's evaluate
function to verify the rule against some weather data. To read about more ways to query rules, see the Queries section.
const weatherData = {
isRaining: true,
};
const doINeedAnUmbrella = regent.evaluate(isRaining, weatherData); // true
Umbrellas don't work well when it is windy. Let's make our rule better - we only need an umbrella if it is raining, and there is not much wind.
We'll start by adding a second rule - isCalm
. We'll define "calm" as having wind speeds under 15mph.
const isCalm = { left: '@windSpeedInMph', fn: 'lessThan', 15 };
This rule tells Regent to compare the property windSpeedInMph
, using the lessThan
predicate, to the value 15
.
We can now compose our two rules into one. We'll be using the and
composition function.
const isRainingAndCalm = regent.and(isRaining, isCalm);
You can learn more about rule composition in the Composition section of the docs.
We can use our regent.evaluate
function to test our improved rule for determining if we need an umbrella.
const weatherData = {
isRaining: true,
windSpeedInMph: 20,
};
const doINeedAnUmbrella = regent.evaluate(isRainingAndCalm, weatherData); // false
Regent is based on defining rules. A rule is an object with three properties on it: left
, fn
, and right
. Here's an example of a rule:
const isRaining = { left: '@isRaining', fn: 'equals', right: true };
The left
property represents the left side of our predicate. In the above example the @
character means this value will be looked up in our data object.
When you specify a lookup value with an @
Regent uses lodash.get
to evaluate strings representing fully qualified object paths. This means you can navigate deep into the data structure for your rule, like this:
const tomorrowsRecordHighIsRecent = { left: '@forecast[0].records.high.year', fn: 'greaterThan', right: 2010};
const data = {
forecast: [
{
day: 'Monday',
high: 65,
low: 32,
records: {
low: {
temperature: 10,
year: 1905
},
high: {
temperature: 89,
year: 2017
}
}
}
]
};
Both left
and right
support lookup values. Please visit the Lodash.get docs for more information on how lookup properties are evaluated.
fn
represents our predicate. Regent ships with 10 built-in predicates, and also supports custom predicates. In our above example we are using equals
, which checks strict equality between the left
and right
value.
Regent's built in predicates are:
dateAfterInclusive
, dateBeforeInclusive
, deepEquals
, empty
, equals
, greaterThan
, includes
, lessThan
, regex
, typeOf
You can learn more about predicates in the Predicates section of the docs.
The right
property represents the right side of our predicate.
Predicates define how we are comparing the left and right values. Here are a few quick examples.
left === right
left < right
typeof left === right
For full documentation of all our built in predicates please visit the Predicates section of the docs.
With Regent, it is best to define your rules as granular as possible, and use our composition helpers to build them up into more complex pieces. Regent provides three helper functions to help you with this pattern.
A rule composed with and
will return true if every rule inside the composed rule returns true.
const isRaining = { left: '@isRaining', fn: 'equals', right: true };
const isWindy = { left: '@windSpeedInMph', fn: 'greaterThan', 15 };
const isCold = { left: '@temperature', fn: 'lessThan', 40 };
const awfulDayToGoOutside = and(isRaining, isWindy, isCold);
The and
helper outputs this object.
{
compose: 'and',
rules: [isRaining, isWindy, isCold]
}
In this example, awfulDayToGoOutside
will return true if data.isRaining
is true, data.windSpeedInMph
is greater than 15, and data.temperature
is less than 40.
A rule composed with or
will return true if any of the rules returns true.
const isRaining = { left: '@isRaining', fn: 'equals', right: true };
const isCold = { left: '@temperature', fn: 'lessThan', 55 };
const iNeedAJacket = or(isRaining, isCold);
The or
helper outputs this object.
{
compose: 'or',
rules: [isRaining, isCold]
}
In this example, iNeedAJacket
will return true if data.isRaining
is true, data.temperature
is less than 40, or both.
not
isn't really a composed rule, but rather an inverted one. A rule created with the not
helper will return true if the passed in rule returns false, and vice versa.
const isCold = { left: '@temperature', fn: 'lessThan', 40 };
const isWarm = not(isCold);
The not
helper outputs this object.
{
not: isCold
}
In this example, isWarm
will return true if isCold
returns false.
You do not need to use helper functions to compose rules. A composed rule can be written without the use of the and
, or
, and not
helper methods. Let's look at our awfulDayToGoOutside
rule from an earlier example.
const isRaining = { left: '@isRaining', fn: 'equals', right: true };
const isWindy = { left: '@windSpeedInMph', fn: 'greaterThan', 15 };
const isCold = { left: '@temperature', fn: 'lessThan', 40 };
const awfulDayToGoOutside = {
compose: 'and',
rules: [
isRaining,
isWindy,
isCold
]
};
A not
rule has a different structure.
const isCalm = {
not: isWindy
}
The functions and
, or
, or not
exist only to help you clean up your code by abstracting away this composed rule syntax. They all return an object literal.
Regent provides tools that allow you to parse your rules into boolean values
evaluate
takes a rule and a data object and returns a boolean value.
evaluate(regentRule, data, [customPredicates])
const data = {
temperature: 78,
}
const beachTemperature = { left: '@temperature', fn: 'greaterThan', right: 75 };
evaluate(beachTemperature, data) // true
In the above example we are evaluating the rule beachTemperature
against a data object.
Regent provides two ways to query logic tables. Logic tables are simply an array of objects. Each object in the array must have a property named rule
which is a regent rule to test.
const clothingLogic = [
{ value: ['hat', 'scarf', 'boots'], rule: IS_COLD },
{ value: ['sandals', 't-shirt'], rule: IS_WARM },
{ value: ['sunglasses'], rule: NO_PRECIPITATION },
{ value: ['umbrella'], rule: IS_RAINING },
];
find
will iterate over the logic array and return the first item whose rule returns true. You can think of it like Array.find()
. find
will return the entire object.
find(logicArray, data, [customPredicates])
const IS_WARM = { left: '@temperature', fn: 'greaterThan', right: 68 };
const data {
temperature: 82
};
const clothingLogic = [
{ value: ['hat', 'scarf', 'boots'], rule: IS_COLD },
{ value: ['sandals', 't-shirt'], rule: IS_WARM },
{ value: ['sunglasses'], rule: NO_PRECIPITATION },
{ value: ['umbrella'], rule: IS_RAINING },
];
const clothingItems = find(clothingLogic, data);
// => { value: ['sandals', 't-shirt'], rule: IS_WARM }
In the above example the second array item will be returned, because IS_WARM
returns true. find
will not continue looking through the following rows.
filter
has the same signature as find
, but returns an array of all the rows whose rules all return true. You can think of it like Array.filter()
.
const IS_WARM = { left: '@temperature', fn: 'greaterThan', right: 68 };
const IS_RAINING = { left: '@precipitation', fn: 'includes', right: 'rain' };
const data {
temperature: 82,
precipitation: ['rain']
};
const clothingLogic = [
{ value: ['hat', 'scarf', 'boots'], rule: IS_COLD },
{ value: ['sandals', 't-shirt'], rule: IS_WARM },
{ value: ['sunglasses'], rule: NO_PRECIPITATION },
{ value: ['umbrella'], rule: IS_RAINING },
];
const clothingItems = filter(clothingLogic, data);
// => [{ value: ['sandals', 't-shirt'], rule: IS_WARM }, { value: ['umbrella'], rule: IS_RAINING }]
In the above example filter
will return an array of all rows that have a rule that evaluates to true. If there are no matches, filter
will return an empty array.
import { evaluate, and, or, not, filter } from '../src/index';
const data = {
precipitation: ['rain'],
temperature: 78,
};
const IS_RAINING = { left: '@precipitation', fn: 'includes', right: 'rain' };
const NOT_RAINING = not(IS_RAINING);
const IS_SNOWING = { left: '@precipitation', fn: 'includes', right: 'snow' };
const NOT_SNOWING = not(IS_SNOWING);
const IS_COLD = { left: '@temperature', fn: 'lessThan', right: 75 };
const IS_WARM = not(IS_COLD);
const NO_PRECIPITATION = and(NOT_RAINING, NOT_SNOWING);
const SHOULD_WEAR_COAT = or(IS_RAINING, IS_SNOWING, IS_COLD);
evaluate(SHOULD_WEAR_COAT, data); // true
const clothingLogic = [
{ value: ['hat', 'scarf', 'boots'], rule: IS_COLD },
{ value: ['sandals', 't-shirt'], rule: IS_WARM },
{ value: ['sunglasses'], rule: NO_PRECIPITATION },
{ value: ['umbrella'], rule: IS_RAINING },
];
const myClothing = filter(clothingLogic, data);
const clothing = myClothing.reduce((acc, row) => ([
...acc,
...row.value,
]), []);
console.log(clothing); // ['sandals', 't-shirt', 'umbrella']
Regent can be used with custom predicates to handle specific logical expressions that built-in predicates can not. A custom predicate is simply a function that accepts up to two arguments, left
and right
, and returns a boolean value.
We will import a function from lodash that takes two arguments and returns a boolean value.
lodash.isMatch() performs a partial deep comparison between object and source to determine if object contains equivalent property values. For our purposes it will check to see if the object passed in the left
property includes the object passed in the right
property.
import isMatch form 'lodash.ismatch';
const zdata = {
typesOrPrecipitation: {
liquid: {
rain: 'rain'
},
solid: {
sleet: 'sleet',
hail: 'hail',
snow: 'snow'
}
},
currentPrecipitaion: {
snow: 'snow'
}
};
const PRECIPITATION_IS_SOLID = {
left: '@typesOrPrecipitation.solid',
fn: 'isMatch',
right: '@currentPrecipitaion'
};
// We need to tell evaluate about isMatch, so we pass
// in an object as the third param. More on that below.
evaluate(PRECIPITATION_IS_SOLID, zdata, { isMatch }) // true
Regent will pass left
in as the first argument, and right
in as the second. isMatch
will return true so our rule will return true.
In order to use custom predicates we need to tell regent that they exist. There are two ways to do that.
The first is to use regent.init
(aliased to regent.crown
). init
takes an optional object of custom predicates and returns the entire api of regent with the custom predicates applied. See the init docs for more details.
To make the above example work we need to init
regent with the custom predicate isMatch
.
import isMatch form 'lodash.ismatch';
const customPredicates = {
isMatch
};
const { evaluate } = regent.init(customPredicates);
The advantage to using init
to register your predicates with Regent is that you can evaluate multiple rules with the returned object. This is helpful when you have a large amount of rules to query. You can even import, init with custom predicates, and export regent in a module. This allows you to import regent from your module and you will always have access to the same custom evaluators. Here is an example.
import regent from 'regent';
const customPredicates = {
// define custom predicates here
};
export default regent.init(customPredicates)
The second way to make Regent aware of your custom predicates is to simply pass them into evaluate
, find
, or filter
as an optional third parameter.
const nameIsMike = left => left === 'Mike';
const customPredicates = {
nameIsMike
};
const NAME_IS_MIKE = { left: '@firstName', fn: 'nameIsMike' };
const data = {
firstName: 'Mike'
};
evaluate(nameIsMike, data, customPredicates); // true
The advantage to passing predicates into evaluate
, find
, or filter
is that you don't need to keep the initialized object around. This is handy for querying isolated rules.
You can read the evaluate
docs, find
docs, or filter
docs for more information.
Let's take a look at a custom predicate that does some custom data parsing.
const temperatureIsRising = (dailyTemperatureArray) => (
// return true if the first temperature in the array is less
dailyTemperatureArray[0] < dailyTemperatureArray[dailyTemperatureArray.length - 1]
)
This predicate will expect an array in left
(nothing in right
) and check that the first value is less than the last.
Other notable use cases of a custom predicate could include custom date formatting, or data manipulation that needs to be done before a logical expression can be expressed.
explain
was built to help a developer visualize their logic. Because we are defining small rules and composing them together, a rule abstracts away the actual logic check. Running the rule through explain returns the logic in a human readable form. Check out this example.
const IS_RAINING = { left: '@precipitation', fn: 'includes', right: 'snow' };
const IS_SNOWING = { left: '@precipitation', fn: 'includes', right: 'snow' };
const PRECIPITATION = and(IS_RAINING, IS_SNOWING);
explain(PRECIPITATION)
// => ((@precipitation includes "snow") and (@precipitation includes "snow"))
explain
also accepts an optional data object as a second argument. When provided explain will show the actual values of the lookup keys in the explanation.
const IS_RAINING = { left: '@precipitation', fn: 'includes', right: 'snow' };
const IS_SNOWING = { left: '@precipitation', fn: 'includes', right: 'snow' };
const PRECIPITATION = and(IS_RAINING, IS_SNOWING);
const data = {
precipitation: ['sleet', 'hail']
};
explain(PRECIPITATION, data)
// => ((@precipitation->["sleet","hail"] includes "snow") and (@precipitation->["sleet","hail"] includes "snow"))
init
accepts an optional object of custom predicates, and returns the full regent api, with the knowledge of the custom predicates. The custom predicate property keys will become the reference strings to each custom predicate. The value of each property should be a function that accepts up to two arguments, and returns a boolean value.
init([customPredicates])
const customPredicates = {
isANumber: val => typeof val === 'number'
};
const Regent = regent.init(customPredicates);
// We now can write a rule using `isANumber` as the fn value
const ageIsANumber = { left: '@age', fn: 'isANumber' };
An alias of init, sticking with the regent theme.
Regent exports an object named constants
that contains the names of all built-in predicates. This object can be used to help avoid misspelled predicates in rules.
import regent, { constants } from 'regent';
const isRaining = { left: '@isRaining', fn: constants.equals, right: true };
// ...
Uses Date.parse to parse and compare date values in left
and right
. This predicate will return true if left
is greater than or equal too right
(inclusive).
const data = {
currentDate: '01/02/1999',
hurricaneDate: '02/02/1999'
}
{ left: '@hurricaneDate', fn: 'dateAfterInclusive', right: '@currentDate' } // true
Uses Date.parse to parse and compare date values in left
and right
. This predicate will return true if left
is less than or equal too right
(inclusive).
const data = {
currentDate: '01/02/1999',
hurricaneDate: '02/02/1999'
}
{ left: '@currentDate', fn: 'dateBeforeInclusive', right: '@hurricaneDate' } // true
Uses lodash.isEqual to perform a deep comparison between two values to determine if they are equivalent.
const data = {
weatherPreferences: { temp: 72 }
}
{ left: '@weatherPreferences', fn: 'deepEquals', right: { temp: 72 } } // true
{ left: '@weatherPreferences.temp', fn: 'deepEquals', right: 72 } // true
Returns true if left
is one of undefined
, null
, 'undefined'
, or ''
. Empty only needs a left
value.
const data = {
sunshine: null,
spring: '',
endOfWinter: 'undefined',
}
{ left: '@sunshine', fn: 'empty' } // true
{ left: '@spring', fn: 'empty' } // true
{ left: '@endOfWinter', fn: 'empty' } // true
{ left: '@beachWeather', fn: 'empty' } // true
Uses the strict equals operator and returns true if left
is equal to right
.
const data = {
currentTemp: 68,
highTemp: 68
}
{ left: '@currentTemp', fn: 'equals', right: '@highTemp' } // true
Uses the greater than operator and returns true if left
is greater than right
.
const data = {
currentTemp: 68,
highTemp: 72
}
{ left: '@highTemp', fn: 'greaterThan', right: '@currentTemp' } // true
Uses lodash.includes to check if right
is in left
.
{ left: [1, 2, 3], fn: 'includes', right: 1 } // true
{ left: { 'a': 1, 'b': 2 }, fn: 'includes', right: 1 } // true
{ left: 'abcd', fn: 'includes', right: 'bc' } // true
Uses the less than operator and returns true if left
is less than right
.
const data = {
currentTemp: 68,
highTemp: 72
}
{ left: '@currentTemp', fn: 'lessThan', right: '@highTemp' } // true
Tests left
against the regex in right
. Uses RegExp.prototype.test().
const data = {
firstName: 'Bernard',
phone: '(123) 456-7890'
}
{ left: '@firstName', fn: 'regex', right: /[a-zA-Z]+/} // true
{ left: '@phone', fn: 'regex', right: /[a-zA-Z]+/ } // false
Checks the typeof
left
against the value of right
. Uses the typeof operator.
const data = {
firstName: 'Bernard',
favoriteMovies: [ 'Happy Gilmore', 'Cold Mountain' ]
}
{ left: '@firstName', fn: 'typeof', right: 'string' } // true
{ left: '@favoriteMoves', fn: 'typeof', right: 'string' } // false
and
accepts any number of rules as arguments and returns a composed rule that returns true if all the subrules are true.
and(rule1, rule2, [rule3], [rule4...])
not
accepts a single rule as an argument and returns a rule that returns the inverse value.
or
accepts any number of rules as arguments and returns a composed rule that returns true if any of the subrules are true.
or(rule1, rule2, [rule3], [rule4...])
evaluate
accepts a data object and a rule and returns a boolean value. It also optionally accepts an object of customPredicates.
evaluate(rule, data, [customPredicates])
explain
accepts a regent rule and returns a human readable description of the logic (and composed logic) that makes up the rule. It optionally accepts an object (data) which it will use to show the actual values of lookup properties in the description.
explain(rule, [data])
find
accepts an object of data and an array of objects that contain a rules
property. It also optionally accepts an object of customPredicates. It evaluates each rule in the rules array and returns the first array item that has all its rules return true.
find(logicArray, data, [customPredicates])
const IS_RAINING = { left: '@precipitation', fn: 'includes', right: 'rain' };
const IS_SNOWING = { left: '@precipitation', fn: 'includes', right: 'snow' };
const clothingLogic = [
{ value: ['hat', 'scarf', 'boots'], rules: [IS_SNOWING] },
{ value: ['umbrella'], rules: [IS_RAINING] },
];
const data = {
precipitation: ['rain']
};
const myClothing = find(clothingLogic, data); // => { value: ['umbrella'], rules: [IS_RAINING] }
filter
has the same api as find, but it returns an array of all logic array items with all their rules passing.
filter(logicArray, data, [customPredicates])
const IS_RAINING = { left: '@precipitation', fn: 'includes', right: 'rain' };
const IS_SNOWING = { left: '@precipitation', fn: 'includes', right: 'snow' };
const IS_COLD = { left: '@temperature', fn: 'lessThan', right: 60 };
const IS_WARM = { left: '@temperature', fn: 'greaterThan', right: 78 };
const PRECIPITATION = and(IS_RAINING, IS_SNOWING);
const NO_PRECIPITATION = not(PRECIPITATION);
const clothingLogic = [
{ value: ['hat', 'scarf', 'boots'], rules: [IS_COLD] },
{ value: ['sandals', 't-shirt'], rules: [IS_WARM] },
{ value: ['sunglasses'], rules: [NO_PRECIPITATION] },
{ value: ['umbrella'], rules: [IS_RAINING] },
];
const data = {
temperature: 65,
precipitation: ['rain']
};
const myClothing = filter(clothingLogic, data); // =>
/*
[
{ value: [ 'sunglasses' ], rules: [ [Object] ] },
{ value: [ 'umbrella' ], rules: [ [Object] ] }
]
*/
For more examples please see our examples folder.