Table of Contents

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, an operator and, for most operators, a value. It can use a computed expression in place of field: see Computed values.
  • A group has "type": "group", an operator of AND, OR or NOT, and its children in rules. AND and OR take one or more children and short-circuit. NOT takes 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 RuleCompilationException the 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 18 against an int field compares as int. 34.5 against an int widens both sides to decimal.
  • A string compares with DateTime, DateTimeOffset, TimeSpan and Guid fields 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 a RuleCompilationException on a typed fact, and simply false on a dictionary fact. See Values, numbers and text.

Next

Actions and outputs: what a rule does when its condition holds.