Conditions
A condition is a tree. Its leaves compare one field with a value, and its groups combine leaves with AND, OR and NOT, nested as deep as you need. The set of operators is closed, so every condition can be validated, diffed and generated by a UI.
Leaves and groups
{ "field": "Customer.Age", "operator": "GreaterThanOrEqual", "value": 18 }
{ "type": "group", "operator": "AND", "rules": [ <condition>, <condition>, ... ] }
- A leaf has a
field, anoperatorand, for most operators, avalue. It can use a computedexpressionin place offield: see Computed values. - A group has
"type": "group", anoperatorofAND,ORorNOT, and its children inrules.ANDandORtake one or more children and short-circuit.NOTtakes exactly one.
Every operator on two customers
This rule set has one rule per kind of test. Rules with no actions are allowed: they still appear in FiredRules when they match, which makes a set like this handy for checking conditions.
{
"name": "Condition operators",
"rules": [
{
"id": "adult",
"condition": { "field": "Customer.Age", "operator": "GreaterThanOrEqual", "value": 18 }
},
{
"id": "gold-or-platinum",
"condition": { "field": "Customer.Tier", "operator": "In", "value": ["gold", "platinum"] }
},
{
"id": "ships-to-nz",
"condition": { "field": "Customer.Country", "operator": "Equals", "value": "NZ" }
},
{
"id": "ships-abroad",
"condition": { "field": "Customer.Country", "operator": "NotEquals", "value": "NZ" }
},
{
"id": "example-address",
"condition": { "field": "Customer.Email", "operator": "EndsWith", "value": "@example.com" }
},
{
"id": "email-shaped",
"condition": { "field": "Customer.Email", "operator": "MatchesRegex", "value": "^[^@\\s]+@[^@\\s]+\\.[a-z]{2,}$" }
},
{
"id": "loyal-and-no-coupon",
"condition": {
"type": "group",
"operator": "AND",
"rules": [
{ "field": "Customer.LoyaltyYears", "operator": "GreaterThanOrEqual", "value": 5 },
{
"type": "group",
"operator": "NOT",
"rules": [ { "field": "Order.Coupon", "operator": "IsNotNull" } ]
}
]
}
}
]
}
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;
LoadedRuleSet rules = engine.LoadRuleSet(File.ReadAllText("conditions.json"));
Checkout aroha = Facts.Vip(); // 34, gold, NZ, six loyal years, aroha@example.com
Checkout ben = Facts.Newcomer(); // 17, standard, no country, first order, "not-an-email"
RuleEvaluationResult forAroha = engine.Evaluate(rules, aroha);
RuleEvaluationResult forBen = engine.Evaluate(rules, ben);
Console.WriteLine($"{"rule",-22}{"Aroha",-8}Ben");
foreach (Rule rule in rules.RuleSet.Rules)
{
string a = forAroha.FiredRules.Any(f => f.RuleId == rule.Id) ? "yes" : "-";
string b = forBen.FiredRules.Any(f => f.RuleId == rule.Id) ? "yes" : "-";
Console.WriteLine($"{rule.Id,-22}{a,-8}{b}");
}
Output
rule Aroha Ben
adult yes -
gold-or-platinum yes -
ships-to-nz yes -
ships-abroad - yes
example-address yes -
email-shaped yes -
loyal-and-no-coupon yes -
The operators
| Operator | value |
Notes |
|---|---|---|
Equals, NotEquals |
a scalar, or null |
Numbers compare across numeric types: 34 equals 34.0. |
GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual |
a number, a string or a date string | Strings compare ordinally, by character code, never by culture. |
Contains, StartsWith, EndsWith |
a string | Ordinal and case-sensitive. For case-insensitive equality, use the built-in EqualsIgnoreCase function. |
MatchesRegex |
a pattern | Checked when the rules load, and time-limited when they run. See Accept rules you didn't write. |
In, NotIn |
a non-empty array of scalars | Membership of the field in a closed set. A null element is rejected. |
Any, All, None |
none: takes a condition |
Quantifiers over a collection field. See Collections. |
IsNull, IsNotNull |
none | True when any segment of the path is null. |
custom + name |
whatever the function expects | Calls a function registered on the builder. See Custom functions. |
Note
The JSON spelling is Equals / NotEquals. In C#, the ConditionOperator enum calls them Equal and NotEqual, so they don't collide with object.Equals.
Field paths
- A path is dotted:
Customer.Address.City. Each segment is a property or public field. - On a typed fact, a segment matches a member case-insensitively. A path that doesn't exist is a
RuleCompilationExceptionthe first time the rule is compiled for that type. - On a dictionary fact, a segment matches a key through the dictionary's comparer: exactly by default, or without regard to case if you built it with
StringComparer.OrdinalIgnoreCase(the JSON fact helpers take one). A missing key reads as null. - A null anywhere along the path makes the whole field null. Nothing throws.
Null and missing values
A null field, or a null anywhere along its path, makes every operator return false, with four exceptions: IsNull, Equals null, NotEquals a value, and NotIn a set. A missing dictionary key behaves exactly like a null. Here it is, on both paths:
{
"name": "What each operator says about a missing value",
"rules": [
{
"id": "Equals \"NZ\"",
"condition": { "field": "Customer.Country", "operator": "Equals", "value": "NZ" }
},
{
"id": "Equals null",
"condition": { "field": "Customer.Country", "operator": "Equals", "value": null }
},
{
"id": "NotEquals \"NZ\"",
"condition": { "field": "Customer.Country", "operator": "NotEquals", "value": "NZ" }
},
{
"id": "In [\"NZ\",\"AU\"]",
"condition": { "field": "Customer.Country", "operator": "In", "value": ["NZ", "AU"] }
},
{
"id": "NotIn [\"NZ\",\"AU\"]",
"condition": { "field": "Customer.Country", "operator": "NotIn", "value": ["NZ", "AU"] }
},
{
"id": "GreaterThan \"A\"",
"condition": { "field": "Customer.Country", "operator": "GreaterThan", "value": "A" }
},
{
"id": "StartsWith \"N\"",
"condition": { "field": "Customer.Country", "operator": "StartsWith", "value": "N" }
},
{
"id": "IsNull",
"condition": { "field": "Customer.Country", "operator": "IsNull" }
},
{
"id": "IsNotNull",
"condition": { "field": "Customer.Country", "operator": "IsNotNull" }
}
]
}
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;
LoadedRuleSet rules = engine.LoadRuleSet(File.ReadAllText("null-semantics.json"));
// Typed: Country is a null string. Dictionary: the key is not there at all.
var typed = new Checkout { Customer = new Customer { Country = null } };
var dictionary = new Dictionary<string, object?> { ["Customer"] = new Dictionary<string, object?>() };
RuleEvaluationResult compiled = engine.Evaluate(rules, typed);
RuleEvaluationResult interpreted = engine.Evaluate(rules, dictionary);
Console.WriteLine($"{"Customer.Country ...",-24}{"null field",-12}missing key");
foreach (Rule rule in rules.RuleSet.Rules)
{
bool a = compiled.FiredRules.Any(f => f.RuleId == rule.Id);
bool b = interpreted.FiredRules.Any(f => f.RuleId == rule.Id);
Console.WriteLine($"{rule.Id,-24}{a,-12}{b}");
}
Output
Customer.Country ... null field missing key
Equals "NZ" False False
Equals null True True
NotEquals "NZ" True True
In ["NZ","AU"] False False
NotIn ["NZ","AU"] True True
GreaterThan "A" False False
StartsWith "N" False False
IsNull True True
IsNotNull False False
The reasoning: NotEquals "NZ" asks "is this not NZ?", and an unknown country isn't NZ. Ordering operators and text tests have nothing to compare, so they're false. None over a null collection follows the same idea: see Collections.
Tip
Ask for null explicitly when it matters. ships-abroad in the first example matched Ben because his country is unknown, not because he lives abroad. If that is not what you mean, add { "field": "Customer.Country", "operator": "IsNotNull" } to the group.
Comparing values of different types
Comparison values are converted to the field's type when a rule compiles, so the comparison itself runs without boxing:
- JSON
18against anintfield compares asint.34.5against anintwidens both sides todecimal. - A string compares with
DateTime,DateTimeOffset,TimeSpanandGuidfields when it parses as one, and with enum fields by member name. - A string is never turned into a number.
"18"against a numeric field is aRuleCompilationExceptionon a typed fact, and simplyfalseon a dictionary fact. See Values, numbers and text.
Next
Actions and outputs: what a rule does when its condition holds.