Repository: CacheControl/json-rules-engine
Pull request #376 (2024-10-09 17:44:41)

Operator decorators
Operator Decorators are a new feature for the JSON rules engine. This resolves a number of issues including #374 

**How do they work?**
A decorator, or decorators are declared in the operator name using the colon ":" character to separate it from the main operator. For instance "everyFact:lessThan" would decorate the lessThan operator with the everyFact decorator.

**What Decorators are Included** 
By default the following decorators are included
* `someFact/someValue` - uses `Array.some` to determine if some of the facts or some of the values match the decorated operator.
* `everyFact/everyValue` - uses `Array.every` to determine if all of the facts or all of the values match the decorated operator.
* `not` - applies a boolean not to the result of the decorated operator
* `swap` - swaps the factValue and jsonValue.


swap and not are useful when you're working with custom decorators. The default operators are already symetrical eg. (in / contains) and negated eg. (in / notIn) but when creating a custom decorator this means writing 4 instances. For instance a `endsWith` operator can now become a `not:endsWith`, `swap:endsWith` - effectively "ending of" and `not:swap:endsWith`


chris-pardy: @CacheControl this needs a bunch of documentation written. I can do that if we're going to add the feature.

Patch:
diff --git a/docs/engine.md b/docs/engine.md
index 8546656..b944adc 100644
--- a/docs/engine.md
+++ b/docs/engine.md
@@ -12,6 +12,8 @@ The Engine stores and executes rules, emits events, and maintains state.
     * [engine.removeRule(Rule instance | String ruleId)](#engineremoverulerule-instance)
     * [engine.addOperator(String operatorName, Function evaluateFunc(factValue, jsonValue))](#engineaddoperatorstring-operatorname-function-evaluatefuncfactvalue-jsonvalue)
     * [engine.removeOperator(String operatorName)](#engineremoveoperatorstring-operatorname)
+    * [engine.addOperatorDecorator(String decoratorName, Function evaluateFunc(factValue, jsonValue, next))](#engineaddoperatordecoratorstring-decoratorname-function-evaluatefuncfactvalue-jsonvalue-next)
+    * [engine.removeOperatorDecorator(String decoratorName)](#engineremoveoperatordecoratorstring-decoratorname)
     * [engine.setCondition(String name, Object conditions)](#enginesetconditionstring-name-object-conditions)
     * [engine.removeCondition(String name)](#engineremovecondtionstring-name)
     * [engine.run([Object facts], [Object options]) -&gt; Promise ({ events: [], failureEvents: [], almanac: Almanac, results: [], failureResults: []})](#enginerunobject-facts-object-options---promise--events--failureevents--almanac-almanac-results--failureresults-)
@@ -181,6 +183,62 @@ engine.addOperator('startsWithLetter', (factValue, jsonValue) => {
 engine.removeOperator('startsWithLetter');
 ```
 
+### engine.addOperatorDecorator(String decoratorName, Function evaluateFunc(factValue, jsonValue, next))
+
+Adds a custom operator decorator to the engine.
+
+```js
+/*
+ * decoratorName - operator decorator identifier used in the rule condition
+ * evaluateFunc(factValue, jsonValue, next) - uses the decorated operator to compare the fact result to the condition 'value'
+ *    factValue - the value returned from the fact
+ *    jsonValue - the "value" property stored in the condition itself
+ *    next - the evaluateFunc of the decorated operator
+ */
+engine.addOperatorDecorator('first', (factValue, jsonValue, next) => {
+  if (!factValue.length) return false
+  return next(factValue[0], jsonValue)
+})
+
+engine.addOperatorDecorator('caseInsensitive', (factValue, jsonValue, next) => {
+  return next(factValue.toLowerCase(), jsonValue.toLowerCase())
+})
+
+// and to use the decorator...
+let rule = new Rule(
+  conditions: {
+    all: [
+      {
+        fact: 'username',
+        operator: 'first:caseInsensitive:equal', // reference the decorator:operator in the rule
+        value: 'a'
+      }
+    ]
+  }
+)
+```
+
+See the [operator decorator example](../examples/13-using-operator-decorators.js)
+
+
+
+### engine.removeOperatorDecorator(String decoratorName)
+
+Removes a operator decorator from the engine
+
+```javascript
+engine.addOperatorDecorator('first', (factValue, jsonValue, next) => {
+  if (!factValue.length) return false
+  return next(factValue[0], jsonValue)
+})
+
+engine.addOperatorDecorator('caseInsensitive', (factValue, jsonValue, next) => {
+  return next(factValue.toLowerCase(), jsonValue.toLowerCase())
+})
+
+engine.removeOperator('first');
+```
+
 ### engine.setCondition(String name, Object conditions)
 
 Adds or updates a condition to the engine. Rules may include references to this condition. Conditions must start with `all`, `any`, `not`, or reference a condition.
diff --git a/docs/rules.md b/docs/rules.md
index 45b679e..7f6d423 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -27,6 +27,11 @@ Rules contain a set of _conditions_ and a single _event_.  When the engine is ru
     * [String and Numeric operators:](#string-and-numeric-operators)
     * [Numeric operators:](#numeric-operators)
     * [Array operators:](#array-operators)
+* [Operator Decorators](#operator-decorators)
+    * [Array decorators:](#array-decorators)
+    * [Logical decorators:](#logical-decorators)
+    * [Utility decorators:](#utility-decorators)
+    * [Decorator composition:](#decorator-composition)
 * [Rule Results](#rule-results)
 * [Persisting](#persisting)
 
@@ -406,6 +411,35 @@ The ```operator``` compares the value returned by the ```fact``` to what is stor
 
   ```doesNotContain```  - _fact_ (an array) must not include _value_
 
+## Operator Decorators
+
+Operator Decorators modify the behavior of an operator either by changing the input or the output. To specify one or more decorators prefix the name of the operator with them in the ```operator``` field and use the colon (```:```) symbol to separate decorators and the operator. For instance ```everyFact:greaterThan``` will produce an operator that checks that every element of the _fact_ is greater than the value.
+
+See [12-using-operator-decorators.js](../examples/13-using-operator-decorators.js) for an example.
+
+### Array Decorators:
+
+  ```everyFact``` - _fact_ (an array) must have every element pass the decorated operator for _value_
+
+  ```everyValue``` - _fact_ must pass the decorated operator for every element of _value_ (an array)
+
+  ```someFact``` - _fact_ (an array) must have at-least one element pass the decorated operator for _value_
+
+  ```someValue``` - _fact_ must pass the decorated operator for at-least one element of _value_ (an array)
+
+### Logical Decorators
+
+  ```not``` - negate the result of the decorated operator
+
+### Utility Decorators
+  ```swap``` - Swap _fact_ and _value_ for the decorated operator
+
+### Decorator Composition
+
+Operator Decorators can be composed by chaining them together with the colon to separate them. For example if you wanted to ensure that every number in an array was less than every number in another array you could use ```everyFact:everyValue:lessThan```.
+
+```swap``` and ```not``` are useful when there are not symmetric or negated versions of custom operators, for instance you could check if a _value_ does not start with a letter contained in a _fact_ using the decorated custom operator ```swap:not:startsWithLetter```. This allows a single custom operator to have 4 permutations.
+
 ## Rule Results
 
 After a rule is evaluated, a `rule result` object is provided to the `success` and `failure` events.  This argument is similar to a regular rule, and contains additional metadata about how the rule was evaluated.  Rule results can be used to extract the results of individual conditions, computed fact values, and boolean logic results.  `name` can be used to easily identify a given rule.
diff --git a/examples/13-using-operator-decorators.js b/examples/13-using-operator-decorators.js
new file mode 100644
index 0000000..fbfeaa5
--- /dev/null
+++ b/examples/13-using-operator-decorators.js
@@ -0,0 +1,98 @@
+'use strict'
+/*
+ * This example demonstrates using operator decorators.
+ *
+ * In this example, a fact contains a list of strings and we want to check if any of these are valid.
+ *
+ * Usage:
+ *   node ./examples/12-using-operator-decorators.js
+ *
+ * For detailed output:
+ *   DEBUG=json-rules-engine node ./examples/12-using-operator-decorators.js
+ */
+
+require('colors')
+const { Engine } = require('json-rules-engine')
+
+async function start () {
+  /**
+   * Setup a new engine
+   */
+  const engine = new Engine()
+
+  /**
+   * Add a rule for validating a tag (fact)
+   * against a set of tags that are valid (also a fact)
+   */
+  const validTags = {
+    conditions: {
+      all: [{
+        fact: 'tags',
+        operator: 'everyFact:in',
+        value: { fact: 'validTags' }
+      }]
+    },
+    event: {
+      type: 'valid tags'
+    }
+  }
+
+  engine.addRule(validTags)
+
+  engine.addFact('validTags', ['dev', 'staging', 'load', 'prod'])
+
+  let facts
+
+  engine
+    .on('success', event => {
+      console.log(facts.tags.join(', ') + ' WERE'.green + ' all ' + event.type)
+    })
+    .on('failure', event => {
+      console.log(facts.tags.join(', ') + ' WERE NOT'.red + ' all ' + event.type)
+    })
+
+  // first run with valid tags
+  facts = { tags: ['dev', 'prod'] }
+  await engine.run(facts)
+
+  // second run with an invalid tag
+  facts = { tags: ['dev', 'deleted'] }
+  await engine.run(facts)
+
+  // add a new decorator to allow for a case-insensitive match
+  engine.addOperatorDecorator('caseInsensitive', (factValue, jsonValue, next) => {
+    return next(factValue.toLowerCase(), jsonValue.toLowerCase())
+  })
+
+  // new rule for case-insensitive validation
+  const caseInsensitiveValidTags = {
+    conditions: {
+      all: [{
+        fact: 'tags',
+        // everyFact has someValue that caseInsensitive is equal
+        operator: 'everyFact:someValue:caseInsensitive:equal',
+        value: { fact: 'validTags' }
+      }]
+    },
+    event: {
+      type: 'valid tags (case insensitive)'
+    }
+  }
+
+  engine.addRule(caseInsensitiveValidTags);
+
+  // third run with a tag that is valid if case insensitive
+  facts = { tags: ['dev', 'PROD'] }
+  await engine.run(facts);
+
+}
+start()
+
+/*
+ * OUTPUT:
+ *
+ * dev, prod WERE all valid tags
+ * dev, deleted WERE NOT all valid tags
+ * dev, PROD WERE NOT all valid tags
+ * dev, PROD WERE all valid tags (case insensitive)
+ */
diff --git a/examples/package-lock.json b/examples/package-lock.json
index abbb622..da2712e 100644
--- a/examples/package-lock.json
+++ b/examples/package-lock.json
@@ -1,53 +1,53 @@
 {
-  "name": "examples",
+  "name": "json-rules-engine-examples",
   "version": "1.0.0",
-  "lockfileVersion": 1,
+  "lockfileVersion": 3,
   "requires": true,
-  "dependencies": {
-    "clone": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
-      "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18="
-    },
-    "curriable": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/curriable/-/curriable-1.3.0.tgz",
-      "integrity": "sha512-7kfjDPRSF+pguU0TlfSFBMCd8XlmF29ZAiXcq/zaN4LhZvWdvV0Y72AvaWFqInXZG9Yg1kA1UMkpE9lFBKMpQA=="
-    },
-    "eventemitter2": {
-      "version": "6.4.3",
-      "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz",
-      "integrity": "sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ=="
-    },
-    "hash-it": {
-      "version": "4.0.5",
-      "resolved": "https://registry.npmjs.org/hash-it/-/hash-it-4.0.5.tgz",
-      "integrity": "sha512-bVZPdJn9GqaAkmGXcBoWG0MKn99VJYYC1X17UWQUPKFxsUSTYMhzz+RdBzCgtG61iT5IwfunE3NKVFZWkAc/OQ==",
-      "requires": {
-        "curriable": "^1.1.0"
+  "packages": {
+    "": {
+      "name": "json-rules-engine-examples",
+      "version": "1.0.0",
+      "license": "ISC",
+      "dependencies": {
+        "json-rules-engine": "../"
       }
     },
-    "json-rules-engine": {
-      "version": "6.0.0-alpha-3",
-      "resolved": "https://registry.npmjs.org/json-rules-engine/-/json-rules-engine-6.0.0-alpha-3.tgz",
-      "integrity": "sha512-P575ORK6cKlzAH7UXlRZ7TxWVdBm+eBVhW3MxkehbP+k/mwB+npobFJZp8R5ZHwROhSp+QwgltOne0H4jJQOow==",
-      "requires": {
+    "..": {
+      "version": "6.4.2",
+      "license": "ISC",
+      "dependencies": {
         "clone": "^2.1.2",
-        "eventemitter2": "^6.4.3",
-        "hash-it": "^4.0.5",
-        "jsonpath-plus": "^4.0.0",
+        "eventemitter2": "^6.4.4",
+        "hash-it": "^6.0.0",
+        "jsonpath-plus": "^7.2.0",
         "lodash.isobjectlike": "^4.0.0"
+      },
+      "devDependencies": {
+        "babel-cli": "6.26.0",
+        "babel-core": "6.26.3",
+        "babel-eslint": "10.1.0",
+        "babel-loader": "8.2.2",
+        "babel-polyfill": "6.26.0",
+        "babel-preset-es2015": "~6.24.1",
+        "babel-preset-stage-0": "~6.24.1",
+        "babel-register": "6.26.0",
+        "chai": "^4.3.4",
+        "chai-as-promised": "^7.1.1",
+        "colors": "~1.4.0",
+        "dirty-chai": "2.0.1",
+        "lodash": "4.17.21",
+        "mocha": "^8.4.0",
+        "perfy": "^1.1.5",
+        "sinon": "^11.1.1",
+        "sinon-chai": "^3.7.0",
+        "snazzy": "^9.0.0",
+        "standard": "^16.0.3",
+        "tsd": "^0.17.0"
       }
     },
-    "jsonpath-plus": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-4.0.0.tgz",
-      "integrity": "sha512-e0Jtg4KAzDJKKwzbLaUtinCn0RZseWBVRTRGihSpvFlM3wTR7ExSp+PTdeTsDrLNJUe7L7JYJe8mblHX5SCT6A=="
-    },
-    "lodash.isobjectlike": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/lodash.isobjectlike/-/lodash.isobjectlike-4.0.0.tgz",
-      "integrity": "sha1-dCxfxlrdJ5JNPSQZFoGqmheytg0="
+    "node_modules/json-rules-engine": {
+      "resolved": "..",
+      "link": true
     }
   }
 }
diff --git a/src/engine-default-operator-decorators.js b/src/engine-default-operator-decorators.js
new file mode 100644
index 0000000..4bf8331
--- /dev/null
+++ b/src/engine-default-operator-decorators.js
@@ -0,0 +1,14 @@
+'use strict'
+
+import OperatorDecorator from './operator-decorator'
+
+const OperatorDecorators = []
+
+OperatorDecorators.push(new OperatorDecorator('someFact', (factValue, jsonValue, next) => factValue.some(fv => next(fv, jsonValue)), Array.isArray))
+OperatorDecorators.push(new OperatorDecorator('someValue', (factValue, jsonValue, next) => jsonValue.some(jv => next(factValue, jv))))
+OperatorDecorators.push(new OperatorDecorator('everyFact', (factValue, jsonValue, next) => factValue.every(fv => next(fv, jsonValue)), Array.isArray))
+OperatorDecorators.push(new OperatorDecorator('everyValue', (factValue, jsonValue, next) => jsonValue.every(jv => next(factValue, jv))))
+OperatorDecorators.push(new OperatorDecorator('swap', (factValue, jsonValue, next) => next(jsonValue, factValue)))
+OperatorDecorators.push(new OperatorDecorator('not', (factValue, jsonValue, next) => !next(factValue, jsonValue)))
+
+export default OperatorDecorators
diff --git a/src/engine.js b/src/engine.js
index c67f3a2..4bd77ca 100644
--- a/src/engine.js
+++ b/src/engine.js
@@ -2,12 +2,13 @@
 
 import Fact from './fact'
 import Rule from './rule'
-import Operator from './operator'
 import Almanac from './almanac'
 import EventEmitter from 'eventemitter2'
 import defaultOperators from './engine-default-operators'
+import defaultDecorators from './engine-default-operator-decorators'
 import debug from './debug'
 import Condition from './condition'
+import OperatorMap from './operator-map'
 
 export const READY = 'READY'
 export const RUNNING = 'RUNNING'
@@ -25,12 +26,13 @@ class Engine extends EventEmitter {
     this.allowUndefinedConditions = options.allowUndefinedConditions || false
     this.replaceFactsInEventParams = options.replaceFactsInEventParams || false
     this.pathResolver = options.pathResolver
-    this.operators = new Map()
+    this.operators = new OperatorMap()
     this.facts = new Map()
     this.conditions = new Map()
     this.status = READY
     rules.map(r => this.addRule(r))
     defaultOperators.map(o => this.addOperator(o))
+    defaultDecorators.map(d => this.addOperatorDecorator(d))
   }
 
   /**
@@ -127,30 +129,32 @@ class Engine extends EventEmitter {
    * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered.
    */
   addOperator (operatorOrName, cb) {
-    let operator
-    if (operatorOrName instanceof Operator) {
-      operator = operatorOrName
-    } else {
-      operator = new Operator(operatorOrName, cb)
-    }
-    debug(`engine::addOperator name:${operator.name}`)
-    this.operators.set(operator.name, operator)
+    this.operators.addOperator(operatorOrName, cb)
   }
 
   /**
    * Remove a custom operator definition
    * @param {string}   operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc
-   * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered.
    */
   removeOperator (operatorOrName) {
-    let operatorName
-    if (operatorOrName instanceof Operator) {
-      operatorName = operatorOrName.name
-    } else {
-      operatorName = operatorOrName
-    }
+    return this.operators.removeOperator(operatorOrName)
+  }
 
-    return this.operators.delete(operatorName)
+  /**
+   * Add a custom operator decorator
+   * @param {string}   decoratorOrName - decorator identifier within the condition; i.e. instead of 'someFact', 'everyValue', etc
+   * @param {function(factValue, jsonValue, next)} callback - the method to execute when the decorator is encountered.
+   */
+  addOperatorDecorator (decoratorOrName, cb) {
+    this.operators.addOperatorDecorator(decoratorOrName, cb)
+  }
+
+  /**
+   * Remove a custom operator decorator
+   * @param {string}   decoratorOrName - decorator identifier within the condition; i.e. instead of 'someFact', 'everyValue', etc
+   */
+  removeOperatorDecorator (decoratorOrName) {
+    return this.operators.removeOperatorDecorator(decoratorOrName)
   }
 
   /**
diff --git a/src/json-rules-engine.js b/src/json-rules-engine.js
index 6f3b149..bed371d 100644
--- a/src/json-rules-engine.js
+++ b/src/json-rules-engine.js
@@ -3,8 +3,9 @@ import Fact from './fact'
 import Rule from './rule'
 import Operator from './operator'
 import Almanac from './almanac'
+import OperatorDecorator from './operator-decorator'
 
-export { Fact, Rule, Operator, Engine, Almanac }
+export { Fact, Rule, Operator, Engine, Almanac, OperatorDecorator }
 export default function (rules, options) {
   return new Engine(rules, options)
 }
diff --git a/src/operator-decorator.js b/src/operator-decorator.js
new file mode 100644
index 0000000..b919622
--- /dev/null
+++ b/src/operator-decorator.js
@@ -0,0 +1,37 @@
+'use strict'
+
+import Operator from './operator'
+
+export default class OperatorDecorator {
+  /**
+   * Constructor
+   * @param {string}   name - decorator identifier
+   * @param {function(factValue, jsonValue, next)} callback - callback that takes the next operator as a parameter
+   * @param {function}  [factValueValidator] - optional validator for asserting the data type of the fact
+   * @returns {OperatorDecorator} - instance
+   */
+  constructor (name, cb, factValueValidator) {
+    this.name = String(name)
+    if (!name) throw new Error('Missing decorator name')
+    if (typeof cb !== 'function') throw new Error('Missing decorator callback')
+    this.cb = cb
+    this.factValueValidator = factValueValidator
+    if (!this.factValueValidator) this.factValueValidator = () => true
+  }
+
+  /**
+   * Takes the fact result and compares it to the condition 'value', using the callback
+   * @param   {Operator} operator - fact result
+   * @returns {Operator} - whether the values pass the operator test
+   */
+  decorate (operator) {
+    const next = operator.evaluate.bind(operator)
+    return new Operator(
+        `${this.name}:${operator.name}`,
+        (factValue, jsonValue) => {
+          return this.cb(factValue, jsonValue, next)
+        },
+        this.factValueValidator
+    )
+  }
+}
diff --git a/src/operator-map.js b/src/operator-map.js
new file mode 100644
index 0000000..9e00c0e
--- /dev/null
+++ b/src/operator-map.js
@@ -0,0 +1,137 @@
+'use strict'
+
+import Operator from './operator'
+import OperatorDecorator from './operator-decorator'
+import debug from './debug'
+
+export default class OperatorMap {
+  constructor () {
+    this.operators = new Map()
+    this.decorators = new Map()
+  }
+
+  /**
+     * Add a custom operator definition
+     * @param {string}   operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc
+     * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered.
+     */
+  addOperator (operatorOrName, cb) {
+    let operator
+    if (operatorOrName instanceof Operator) {
+      operator = operatorOrName
+    } else {
+      operator = new Operator(operatorOrName, cb)
+    }
+    debug(`operatorMap::addOperator name:${operator.name}`)
+    this.operators.set(operator.name, operator)
+  }
+
+  /**
+     * Remove a custom operator definition
+     * @param {string}   operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc
+     * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered.
+     */
+  removeOperator (operatorOrName) {
+    let operatorName
+    if (operatorOrName instanceof Operator) {
+      operatorName = operatorOrName.name
+    } else {
+      operatorName = operatorOrName
+    }
+
+    // Delete all the operators that end in :operatorName these
+    // were decorated on-the-fly leveraging this operator
+    const suffix = ':' + operatorName
+    const operatorNames = Array.from(this.operators.keys())
+    for (let i = 0; i < operatorNames.length; i++) {
+      if (operatorNames[i].endsWith(suffix)) {
+        this.operators.delete(operatorNames[i])
+      }
+    }
+
+    return this.operators.delete(operatorName)
+  }
+
+  /**
+     * Add a custom operator decorator
+     * @param {string}   decoratorOrName - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc
+     * @param {function(factValue, jsonValue, next)} callback - the method to execute when the decorator is encountered.
+     */
+  addOperatorDecorator (decoratorOrName, cb) {
+    let decorator
+    if (decoratorOrName instanceof OperatorDecorator) {
+      decorator = decoratorOrName
+    } else {
+      decorator = new OperatorDecorator(decoratorOrName, cb)
+    }
+    debug(`operatorMap::addOperatorDecorator name:${decorator.name}`)
+    this.decorators.set(decorator.name, decorator)
+  }
+
+  /**
+     * Remove a custom operator decorator
+     * @param {string}   decoratorOrName - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc
+     */
+  removeOperatorDecorator (decoratorOrName) {
+    let decoratorName
+    if (decoratorOrName instanceof OperatorDecorator) {
+      decoratorName = decoratorOrName.name
+    } else {
+      decoratorName = decoratorOrName
+    }
+
+    // Delete all the operators that include decoratorName: these
+    // were decorated on-the-fly leveraging this decorator
+    const prefix = decoratorName + ':'
+    const operatorNames = Array.from(this.operators.keys())
+    for (let i = 0; i < operatorNames.length; i++) {
+      if (operatorNames[i].includes(prefix)) {
+        this.operators.delete(operatorNames[i])
+      }
+    }
+
+    return this.decorators.delete(decoratorName)
+  }
+
+  /**
+   * Get the Operator, or null applies decorators as needed
+   * @param {string} name - the name of the operator including any decorators
+   * @returns an operator or null
+   */
+  get (name) {
+    const decorators = []
+    let opName = name
+    // while we don't already have this operator
+    while (!this.operators.has(opName)) {
+      // try splitting on the decorator symbol (:)
+      const firstDecoratorIndex = opName.indexOf(':')
+      if (firstDecoratorIndex > 0) {
+        // if there is a decorator, and it's a valid decorator
+        const decoratorName = opName.slice(0, firstDecoratorIndex)
+        const decorator = this.decorators.get(decoratorName)
+        if (!decorator) {
+          debug(`operatorMap::get invalid decorator named ${decoratorName}`)
+          return null
+        }
+        // we're going to apply this later, use unshift since we'll apply in reverse order
+        decorators.unshift(decorator)
+        // continue looking for a known operator with the rest of the name
+        opName = opName.slice(firstDecoratorIndex + 1)
+      } else {
+        debug(`operatorMap::get no operator named ${opName}`)
+        return null
+      }
+    }
+
+    let op = this.operators.get(opName)
+    // apply all the decorators
+    for (let i = 0; i < decorators.length; i++) {
+      op = decorators[i].decorate(op)
+      // create an entry for the decorated operation so we don't need
+      // to do this again
+      this.operators.set(op.name, op)
+    }
+    // return the operation
+    return op
+  }
+}
diff --git a/types/index.d.ts b/types/index.d.ts
index c6ace7f..d8af3ec 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -35,13 +35,17 @@ export class Engine {
   setCondition(name: string, conditions: TopLevelCondition): this;
   removeCondition(name: string): boolean;
 
-  addOperator(operator: Operator): Map<string, Operator>;
+  addOperator(operator: Operator): void;
   addOperator<A, B>(
     operatorName: string,
     callback: OperatorEvaluator<A, B>
-  ): Map<string, Operator>;
+  ): void;
   removeOperator(operator: Operator | string): boolean;
 
+  addOperatorDecorator(decorator: OperatorDecorator): void;
+  addOperatorDecorator<A, B, NextA, NextB>(decoratorName: string, callback: OperatorDecoratorEvaluator<A, B, NextA, NextB>): void;
+  removeOperatorDecorator(decorator: OperatorDecorator | string): boolean;
+
   addFact<T>(fact: Fact<T>): this;
   addFact<T>(
     id: string,
@@ -72,6 +76,19 @@ export class Operator<A = unknown, B = unknown> {
   );
 }
 
+export interface OperatorDecoratorEvaluator<A, B, NextA, NextB> {
+  (factValue: A, compareToValue: B, next: OperatorEvaluator<NextA, NextB>): boolean
+}
+
+export class OperatorDecorator<A = unknown, B = unknown, NextA = unknown, NextB = unknown> {
+  public name: string;
+  constructor(
+    name: string,
+    evaluator: OperatorDecoratorEvaluator<A, B, NextA, NextB>,
+    validator?: (factValue: A) => boolean
+  )
+}
+
 export class Almanac {
   constructor(options?: AlmanacOptions);
   factValue<T>(


Test patch:
diff --git a/test/engine-operator-map.test.js b/test/engine-operator-map.test.js
new file mode 100644
index 0000000..a54afa2
--- /dev/null
+++ b/test/engine-operator-map.test.js
@@ -0,0 +1,86 @@
+'use strict'
+
+import { expect } from 'chai'
+import engineFactory, { Operator, OperatorDecorator } from '../src/index'
+
+const startsWithLetter = new Operator('startsWithLetter', (factValue, jsonValue) => {
+  return factValue[0] === jsonValue
+})
+
+const never = new OperatorDecorator('never', () => false)
+
+describe('Engine Operator Map', () => {
+  let engine
+  beforeEach(() => {
+    engine = engineFactory()
+    engine.addOperator(startsWithLetter)
+    engine.addOperatorDecorator(never)
+  })
+
+  describe('undecorated operator', () => {
+    let op
+    beforeEach(() => {
+      op = engine.operators.get('startsWithLetter')
+    })
+
+    it('has the operator', () => {
+      expect(op).not.to.be.null()
+    })
+
+    it('the operator evaluates correctly', () => {
+      expect(op.evaluate('test', 't')).to.be.true()
+    })
+
+    it('after being removed the operator is null', () => {
+      engine.operators.removeOperator(startsWithLetter)
+      op = engine.operators.get('startsWithLetter')
+      expect(op).to.be.null()
+    })
+  })
+
+  describe('decorated operator', () => {
+    let op
+    beforeEach(() => {
+      op = engine.operators.get('never:startsWithLetter')
+    })
+
+    it('has the operator', () => {
+      expect(op).not.to.be.null()
+    })
+
+    it('the operator evaluates correctly', () => {
+      expect(op.evaluate('test', 't')).to.be.false()
+    })
+
+    it('removing the base operator removes the decorated version', () => {
+      engine.operators.removeOperator(startsWithLetter)
+      op = engine.operators.get('never:startsWithLetter')
+      expect(op).to.be.null()
+    })
+
+    it('removing the decorator removes the decorated operator', () => {
+      engine.operators.removeOperatorDecorator(never)
+      op = engine.operators.get('never:startsWithLetter')
+      expect(op).to.be.null()
+    })
+  })
+
+  describe('combinatorics with default operators', () => {
+    it('combines every, some, not, and greaterThanInclusive operators', () => {
+      const odds = [1, 3, 5, 7]
+      const evens = [2, 4, 6, 8]
+
+      // technically not:greaterThanInclusive is the same as lessThan
+      const op = engine.operators.get('everyFact:someValue:not:greaterThanInclusive')
+      expect(op.evaluate(odds, evens)).to.be.true()
+    })
+  })
+
+  it('the swap decorator', () => {
+    const factValue = 1;
+    const jsonValue = [1, 2, 3];
+
+    const op = engine.operators.get('swap:contains');
+    expect(op.evaluate(factValue, jsonValue)).to.be.true()
+  })
+})
diff --git a/test/engine.test.js b/test/engine.test.js
index 6030466..8ad3ca7 100644
--- a/test/engine.test.js
+++ b/test/engine.test.js
@@ -5,8 +5,6 @@ import engineFactory, { Fact, Rule, Operator } from '../src/index'
 import defaultOperators from '../src/engine-default-operators'
 
 describe('Engine', () => {
-  const operatorCount = defaultOperators.length
-
   let engine
   let sandbox
   before(() => {
@@ -34,7 +32,9 @@ describe('Engine', () => {
     it('initializes with the default state', () => {
       expect(engine.status).to.equal('READY')
       expect(engine.rules.length).to.equal(0)
-      expect(engine.operators.size).to.equal(operatorCount)
+      defaultOperators.forEach(op => {
+        expect(engine.operators.get(op.name)).to.be.an.instanceof(Operator)
+      })
     })
 
     it('can be initialized with rules', () => {
@@ -199,37 +199,31 @@ describe('Engine', () => {
 
   describe('addOperator()', () => {
     it('adds the operator', () => {
-      expect(engine.operators.size).to.equal(operatorCount)
       engine.addOperator('startsWithLetter', (factValue, jsonValue) => {
         return factValue[0] === jsonValue
       })
-      expect(engine.operators.size).to.equal(operatorCount + 1)
       expect(engine.operators.get('startsWithLetter')).to.exist()
       expect(engine.operators.get('startsWithLetter')).to.be.an.instanceof(Operator)
     })
 
     it('accepts an operator instance', () => {
-      expect(engine.operators.size).to.equal(operatorCount)
       const op = new Operator('my-operator', _ => true)
       engine.addOperator(op)
-      expect(engine.operators.size).to.equal(operatorCount + 1)
       expect(engine.operators.get('my-operator')).to.equal(op)
     })
   })
 
   describe('removeOperator()', () => {
     it('removes the operator', () => {
-      expect(engine.operators.size).to.equal(operatorCount)
       engine.addOperator('startsWithLetter', (factValue, jsonValue) => {
         return factValue[0] === jsonValue
       })
-      expect(engine.operators.size).to.equal(operatorCount + 1)
+      expect(engine.operators.get('startsWithLetter')).to.be.an.instanceof(Operator)
       engine.removeOperator('startsWithLetter')
-      expect(engine.operators.size).to.equal(operatorCount)
+      expect(engine.operators.get('startsWithLetter')).to.be.null()
     })
 
     it('can only remove added operators', () => {
-      expect(engine.operators.size).to.equal(operatorCount)
       const isRemoved = engine.removeOperator('nonExisting')
       expect(isRemoved).to.equal(false)
     })
diff --git a/test/operator-decorator.test.js b/test/operator-decorator.test.js
new file mode 100644
index 0000000..3c10ff4
--- /dev/null
+++ b/test/operator-decorator.test.js
@@ -0,0 +1,40 @@
+'use strict'
+
+import { OperatorDecorator, Operator } from '../src/index'
+
+const startsWithLetter = new Operator('startsWithLetter', (factValue, jsonValue) => {
+  return factValue[0] === jsonValue
+})
+
+describe('OperatorDecorator', () => {
+  describe('constructor()', () => {
+    function subject (...args) {
+      return new OperatorDecorator(...args)
+    }
+
+    it('adds the decorator', () => {
+      const decorator = subject('test', () => false)
+      expect(decorator.name).to.equal('test')
+      expect(decorator.cb).to.an.instanceof(Function)
+    })
+
+    it('decorator name', () => {
+      expect(() => {
+        subject()
+      }).to.throw(/Missing decorator name/)
+    })
+
+    it('decorator definition', () => {
+      expect(() => {
+        subject('test')
+      }).to.throw(/Missing decorator callback/)
+    })
+  })
+
+  describe('decorating', () => {
+    const subject = new OperatorDecorator('test', () => false).decorate(startsWithLetter)
+    it('creates a new operator with the prefixed name', () => {
+      expect(subject.name).to.equal('test:startsWithLetter')
+    })
+  })
+})
diff --git a/types/index.test-d.ts b/types/index.test-d.ts
index dcf5541..13b7460 100644
--- a/types/index.test-d.ts
+++ b/types/index.test-d.ts
@@ -7,6 +7,8 @@ import rulesEngine, {
   Fact,
   Operator,
   OperatorEvaluator,
+  OperatorDecorator,
+  OperatorDecoratorEvaluator,
   PathResolver,
   Rule,
   RuleProperties,
@@ -72,7 +74,7 @@ const operatorEvaluator: OperatorEvaluator<number, number> = (
   a: number,
   b: number
 ) => a === b;
-expectType<Map<string, Operator>>(
+expectType<void>(
   engine.addOperator("test", operatorEvaluator)
 );
 const operator: Operator = new Operator(
@@ -80,9 +82,26 @@ const operator: Operator = new Operator(
   operatorEvaluator,
   (num: number) => num > 0
 );
-expectType<Map<string, Operator>>(engine.addOperator(operator));
+expectType<void>(engine.addOperator(operator));
 expectType<boolean>(engine.removeOperator(operator));
 
+// Operator Decorator tests
+const operatorDecoratorEvaluator: OperatorDecoratorEvaluator<number[], number, number, number> = (
+  a: number[],
+  b: number,
+  next: OperatorEvaluator<number, number>
+) => next(a[0], b);
+expectType<void>(
+  engine.addOperatorDecorator("first", operatorDecoratorEvaluator)
+);
+const operatorDecorator: OperatorDecorator = new OperatorDecorator(
+  "first",
+  operatorDecoratorEvaluator,
+  (a: number[]) => a.length > 0
+);
+expectType<void>(engine.addOperatorDecorator(operatorDecorator));
+expectType<boolean>(engine.removeOperatorDecorator(operatorDecorator));
+
 // Fact tests
 const fact = new Fact<number>("test-fact", 3);
 const dynamicFact = new Fact<number[]>("test-fact", () => [42]);