Lightweight business rules library using just simple functional composition.
Composition means you have a category of objects, and some means of combining those objects, in such way that you get other objects from the same category, that you can further combine.
Throughout the process of composition, you start with some simple primitive rules, compose those using some combinators or higher order rules and you get some prety powerful business rules pipelines.
const rule = shape({
advance: maximumValue(loan => loan.aquisitionPrice),
advancePercent: [computed(loan => loan.advance * 100 / loan.aquisitionPrice), maximumValue(100)] |> chainRules,
approved: constant(false) |> when(
[
propertyChanged(loan => loan.advance),
propertyChanged(loan => loan.interestRate)
] |> any),
person: shape({
fullName: computed(person => `${person.surname} ${person.name}`) |> when(propertiesChanged(person => [person.name, person.surname])),
})
}) |> logTo(console)
npm install @totalsoft/rules-algebra
import { applyRule, minimumValue, maximumValue, chainRules } from '@totalsoft/rules-algebra';
const percent = -1
const rule = [minimumValue(0), maximumValue(100)] |> chainRules
const newPercent = percent |> applyRule(rule)
A rule is just a function that takes in a value and returns another value according to the rule logic.
const abs = Rule(x => x >= 0 ? x : -x)
The library provides some out of the box primitive rules that you can use in the composition process.
A rule that returns the spciffied value regardless of the model
const rule = constant(5)
A rule that returns a value computed based on the "document" in scope and its previous value.
The computation function has 3 arguments:
- document - can be the model passed when applying the rule, or a nested object if the scope modifier is used
- previous document (optional) - can be previous model passed when applying the rule, or a nested object if the scope modifier is used
- property value (optional) - the current property value if the rule is for a property in a shape; otherwise the same as document
const fullNameRule = computed(doc => doc.firstName + " " + doc.lastName);
const resetValueRule = computed((doc, prevDoc, propValue) => prevDoc.enabled && !doc.enabled ? 0 : propValue);
A rule that returns the minimum of two properties or values
const rule1 = min(doc => doc.percent, doc => doc.maxPercent)
const rule2 = min(doc => doc.percent, 100)
A rule that returns the maximum of two properties or values
const rule1 = max(doc => doc.percent, doc => doc.minPercent)
const rule2 = max(doc => doc.percent, 0)
A rule that returns the minimum between the current value and the argument
const percentRule = minimumValue(0)
A rule that returns the maximum between the current value and the argument
const percentRule = maximumValue(100)
A rule that returns the sum between two prperties or values
const rule = sum(doc => doc.amount, doc => doc.taxes)
A rule that returns a string produced according to the provided format
const rule = sprintf('{{name}} {{surname}}')
A HoR is just a function that takes rules as inputs and returns rules.
The library provides the following HoR's:
Used to reduce a list of rules. It applies all rules by chaining outputs to inputs.
const rule = [computed(loan => loan.advance * 100 / loan.aquisitionPrice), maximumValue(100)] |> chainRules
Used to create a conditional rule by providing a predicate or condition and a rule.
For details on predicates see the predicates section
const rule = when(doc => doc.isValueComputed, computed(doc => doc.amount * doc.percent));
Used to create a conditional rule by providing a predicate or condition, a rule for the "true" branch and another for the "false" branch.
For details on predicates see the predicates section
const rule = ifThenElse(doc => doc.isNewVersion, constant(2), constant(0));
Used to create a rule that repeats the provided rule until the condition is true.
For details on predicates see the predicates section
const rule = Rule(x => x * x) |> until(x => x >= 100)
Used to apply a rule for just a field of the model.
const percent = field("percent");
const rule = maximumValue(100) |> percent
Used to compose complex rules from field rules. The "shape" higher order rule implicitly scopes the fieds the current object. All the field rule computations will be relative to the current object. To access the root or other nesting levels use the scope higher order rule for fields.
const rule = shape({
percent: maximumValue(100),
amount: minimumValue(0)
})
Takes an item rule and produces applies it for each item in the provided collection.
const rule = minimumValue(0) |> items;
Useful when you need the model in the composition process. Note: The "fromModel" higher order rule does not work with "propertyChanged" conditions. In this case use scope higher order rule instead.
const rule =
shape({
personalInfo: fromModel(model =>
shape({
age: minimumValue(model.minimumAllowedAge)
})
)
});
Useful when you need the parent model in the composition process. Note: The "fromParent" higher order rule does not work with propertyChanged conditions. In this case use scope higher order rule instead.
const rule =
shape({
personalInfo: shape({
age: fromParent(parent => minimumValue(parent.minimumAllowedAge))
})
});
Useful when you need the root model in the composition process. Note: The "fromParent" higher order rule does not work with propertyChanged conditions. In this case use scope higher order rule instead.
const rule =
shape({
personalInfo: shape({
age: fromRoot(root => minimumValue(root.minimumAllowedAge))
})
});
Creates a scope over the given rule where the document is substituted by the specified value. It can be used together with "root" and "parent" modifiers.
- Note 1: The shape rule is implicitly scoped to the current object. There is no need to specify " |> scope |> parent " for fields.
- Note 2: You can chain multiple "parent" modifiers to go up the hierarchy. eg: |> scope |> parent |> parent
const rule =
shape({
loan: shape({
advance: computed(root => root.loan.amount * root.advancePercent)
|> when(propertiesChanged(root => [root.loan.amount, root.advancePercent]))
|> scope |> root
})
});
Logs the rule application process to the speficfied logger.
const rule = [minimumValue(0), maximumValue(100)] |> chainRules |> logTo(console);
A predicate is a condition that can be expressed in relation to the current document in scope. Predicates can be used in conjuction with conditional higher order rules such as when and ifThenElse
const fullNameRule = computed(person => `${person.surname} ${person.name}`) |> when(propertiesChanged(person => [person.name, person.surname]))
Checks if the selected values are equal:
const predicate1 = equals(doc => doc.property1, doc => doc.property2);
Checks if the selected property is a number:
const predicate = isNumber(doc => doc.age);
Checks if the selected property in the current models differs from the same property in the previous model.
const predicate = propertyChanged(doc => doc.property);
Checks if the selected properties in the current models differ from the same properties in the previous model.
const predicate = propertiesChanged(doc => [doc.property1, doc.property2]);
Negates the selected value: It also works as a higher order predicate to negate other predicates
const predicate1 = not(doc => doc.isEnabled);
const predicate2 = not(equals(doc => doc.field1, doc => doc.field2));
Checks if all the selected values are true. It also works as a higher order predicate if used with other primitive predicates.
const predicate1 = all(doc => doc.isEnabled, doc => doc.isValid);
const predicate2 = [doc => doc.isEnabled, doc => doc.isValid] |> all;
const predicate2 = [equals(doc => doc.field1, doc => doc.field2), equals(doc => doc.field3, doc => doc.field4)] |> all;
Checks if any of the selected values are true. It also works as a higher order predicate if used with other primitive predicates.
const predicate1 = any(doc => doc.isEnabled, doc => doc.isValid);
const predicate2 = [doc => doc.isEnabled, doc => doc.isValid] |> any;
const predicate2 = [propertyChanged(doc => doc.property1), propertyChanged(doc => doc.property)] |> any;
To support externally defined business rules, the library provides an option specify rules as strings.
Creates a rule from the given string. The string should be valid javascript code (eg. no pipeline operators).
All rules-algebra combinators and predicates are included in the parsing scope and can be used in the rule text.
const ruleText = `
items(
shape({
fullPrice: when(propertyChanged(item => item.price), computed(item => (item.price) * (1 + taxPercent)))
})
)
`
const rule = parse(ruleText)
You can use custom functions and constants by adding them to the parsing scope:
const ruleText = `
items(
shape({
fullPrice: when(propertyChanged(item => item.price), computed(item => multiply(1 + taxPercent)(item.price)))
})
)
`
const { multiply } = require("ramda")
const taxPercent = 0.19
const rule = parse(ruleText, { scope: { multiply, taxPercent } })
Because rules are composable you can parse the rule for only a subset of your model. In the example below the parsed rule needs context from the global model. To make this possible we parse a rule factory function that takes the context as an argument:
const propValueRuleFactoryText = `(asset) => when(prop => prop.code == "ASSET_TYPE", computed(_ => asset.price < 72000 ? "STD" : "NON_STD"))`
const propValueRuleFactory = parse(propValueRuleFactoryText)
const rule =
fromModel(asset =>
shape({
properties: items(
shape({
value: propValueRuleFactory(asset)
})
)
}))