Functions
Name an expression once and call it from any rule.
A function gives an expression a name and named parameters. A rule calls it with
{ fn: <name>, args: [...] }. The body reads its arguments as arg.<param>, and also sees data,
original, actor, ctx and params. Functions are part of the ruleset, so they behave the same
in every engine.
Syntax
functions:
lineTotal:
description: Quantity times unit price.
params: [qty, price]
body: { op: mul, args: [{ var: arg.qty }, { var: arg.price }] }
# in a rule
assert: { op: gt, args: [{ fn: lineTotal, args: [2, 5] }, 0] }Example
lineTotal computes each order line, and isBlank treats a reference of only spaces as missing.
The total 12 does not match the lines (2 × 5), and the reference is blank.
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.functions, version: 1.0.0, title: "Functions" }
scope:
- { level: organization, id: learn }
entities:
Order:
schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }
functions:
lineTotal:
description: Quantity times unit price.
params: [qty, price]
body: { op: mul, args: [{ var: arg.qty }, { var: arg.price }] }
isBlank:
description: True for a missing or empty text.
params: [text]
body: { op: empty, args: [{ op: trim, args: [{ op: coalesce, args: [{ var: arg.text }, ""] }] }] }
rules:
- id: order.total.matches-lines
kind: validation
target: { entity: Order, field: /total }
operations: [create]
assert:
op: eq
args:
- { var: data.total }
- { op: sum, args: [{ var: data.items }, { fn: lineTotal, args: [{ var: item.qty }, { var: item.price }] }] }
severity: error
finding: { code: LRN-FN-001, message: order.totalMismatch }
- id: order.reference.required
kind: validation
target: { entity: Order, field: /reference }
operations: [create]
assert: { op: not, args: [{ fn: isBlank, args: [{ var: data.reference }] }] }
severity: error
finding: { code: LRN-FN-002, message: order.referenceRequired }
messages:
en:
order.totalMismatch: "The total does not match the order lines."
order.referenceRequired: "Give a reference."
tests:
- name: a wrong total and a blank reference
entity: Order
operation: create
given:
data: { reference: " ", total: 12, items: [{ sku: "pen", qty: 2, price: 5 }] }
expect:
decision: deny
findings:
- { rule: order.total.matches-lines }
- { rule: order.reference.required }
- name: a correct order
entity: Order
operation: create
given:
data: { reference: "A-1", total: 10, items: [{ sku: "pen", qty: 2, price: 5 }] }
expect: { decision: allow, findings: [] }{
"entity": "Order",
"operation": "create",
"data": {
"reference": " ",
"total": 12,
"items": [
{
"sku": "pen",
"qty": 2,
"price": 5
}
]
}
}Result, from the engine
Decisiondeny2 findings, server channel
LRN-FN-001errorblockingThe total does not match the order lines./totalLRN-FN-002errorblockingGive a reference./reference
Common mistakes
- A function that calls itself, directly or through another function:
FUNCTION_RECURSIVE. - Calling a function that is not declared (
FUNCTION_UNKNOWN) or with the wrong number of arguments (FUNCTION_ARITY). - Declaring a function the parent already has:
FUNCTION_REDEFINED. Inherited rules depend on what it means. - Reading
itemor an undeclaredarg.xinside the body:SCOPE_INVALID. Pass the value as an argument instead, as the example does withitem.qty.
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.functions-recursive, version: 1.0.0, title: "A function that calls itself" }
scope:
- { level: organization, id: learn }
entities:
Order:
schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }
functions:
countdown:
params: [n]
body: { op: if, args: [{ op: lte, args: [{ var: arg.n }, 0] }, 0, { fn: countdown, args: [{ op: sub, args: [{ var: arg.n }, 1] }] }] }
rules:
- id: order.quantity.check
kind: validation
target: { entity: Order, field: /quantity }
operations: [create]
assert: { op: eq, args: [{ fn: countdown, args: [{ var: data.quantity }] }, 0] }
severity: error
finding: { code: LRN-FN-009, message: order.quantityCheck }
messages:
en:
order.quantityCheck: "The quantity is not valid."
tests:
- name: any order
entity: Order
operation: create
given:
data: { quantity: 3 }
expect: { decision: allow, findings: [] }{
"entity": "Order",
"operation": "create",
"data": {
"quantity": 3
}
}Result, from the engine
does not loadThe engine refuses the ruleset before it evaluates anything.
FUNCTION_RECURSIVEfunction countdown calls itself, directly or indirectly (line 11)
Exercise
Add a function withTax that adds 20% to an amount. Use it in a new rule: the total may not be
more than withTax(100). Test it with a total of 130.
Hint
Add withTax with params: [amount] and body mul(arg.amount, 1.2), then a rule asserting that data.total is at most withTax(100) (op lte).
Show answer
ruleCascade: 1.0.0
kind: RuleSet
metadata: { id: learn.functions, version: 1.0.0, title: "Functions" }
scope:
- { level: organization, id: learn }
entities:
Order:
schema: { $ref: "./learn.openapi.yaml#/components/schemas/Order" }
functions:
lineTotal:
description: Quantity times unit price.
params: [qty, price]
body: { op: mul, args: [{ var: arg.qty }, { var: arg.price }] }
isBlank:
description: True for a missing or empty text.
params: [text]
body: { op: empty, args: [{ op: trim, args: [{ op: coalesce, args: [{ var: arg.text }, ""] }] }] }
withTax:
description: The amount with 20% tax added.
params: [amount]
body: { op: mul, args: [{ var: arg.amount }, 1.2] }
rules:
- id: order.total.matches-lines
kind: validation
target: { entity: Order, field: /total }
operations: [create]
assert:
op: eq
args:
- { var: data.total }
- { op: sum, args: [{ var: data.items }, { fn: lineTotal, args: [{ var: item.qty }, { var: item.price }] }] }
severity: error
finding: { code: LRN-FN-001, message: order.totalMismatch }
- id: order.reference.required
kind: validation
target: { entity: Order, field: /reference }
operations: [create]
assert: { op: not, args: [{ fn: isBlank, args: [{ var: data.reference }] }] }
severity: error
finding: { code: LRN-FN-002, message: order.referenceRequired }
- id: order.total.cap
kind: validation
target: { entity: Order, field: /total }
operations: [create]
assert: { op: lte, args: [{ var: data.total }, { fn: withTax, args: [100] }] }
severity: error
finding: { code: LRN-FN-003, message: order.totalCap }
messages:
en:
order.totalMismatch: "The total does not match the order lines."
order.referenceRequired: "Give a reference."
order.totalCap: "The total may not exceed 120."
tests:
- name: a wrong total and a blank reference
entity: Order
operation: create
given:
data: { reference: " ", total: 12, items: [{ sku: "pen", qty: 2, price: 5 }] }
expect:
decision: deny
findings:
- { rule: order.total.matches-lines }
- { rule: order.reference.required }
- name: a correct order
entity: Order
operation: create
given:
data: { reference: "A-1", total: 10, items: [{ sku: "pen", qty: 2, price: 5 }] }
expect: { decision: allow, findings: [] }
- name: a total above 100 with tax is denied
entity: Order
operation: create
given:
data: { reference: "A-1", total: 130, items: [{ sku: "pen", qty: 13, price: 10 }] }
expect:
decision: deny
findings:
- { rule: order.total.cap }{
"entity": "Order",
"operation": "create",
"data": {
"reference": " ",
"total": 12,
"items": [
{
"sku": "pen",
"qty": 2,
"price": 5
}
]
}
}Result, from the engine
Decisiondeny2 findings, server channel
LRN-FN-001errorblockingThe total does not match the order lines./totalLRN-FN-002errorblockingGive a reference./reference