-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathrule-engine.ts
More file actions
291 lines (256 loc) · 8.69 KB
/
rule-engine.ts
File metadata and controls
291 lines (256 loc) · 8.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
'use strict';
import type {
ApplyRulesResponse,
Condition,
Effect,
Rule,
RuleError,
UnknownObject,
ValidOperand,
} from './types';
import { Action, Operator } from './enums';
import {
validateApplyRulesFeedback,
getObjectKeyValue,
isPlainObject,
runArithmeticComparisonGuard,
setObjectKeyValue,
} from './helpers';
import isEqual from 'lodash/isEqual';
import set from 'lodash/set';
export class RuleEngine {
private readonly rules: Rule;
constructor(rules: Rule) {
// TODO: add rule validator to validate rules passed to this class
// we want to throw error for malformed rules
this.rules = rules;
}
/**
* Fetches a rule that is already loaded into this rule engine using the name
* of the rule e.g. in
* `{
* trackHasStrongLanguage: {...}
* }`,
* `getRule('trackHasStrongLanguage')` would return `{...}`
*/
getRule(ruleName: string): { conditions: Condition[]; effect: Effect } {
if (this.rules[ruleName] != null) return this.rules[ruleName];
throw new Error(`No rule found for ${ruleName}`);
}
/**
* The first character of the `field` string is checked to see if it equals
* `"$."`. e.g. if the string is `"$.tags"`, it uses the
* `getObjectKeyValue` method to check the provided object for a `tags`
* key. If the key is found, the value is returned. Else, it checks the fallback
* object for a `tags` key.
*
* However, if the provided `field` does
* not start with `"$."`, that string is returned as is.
*
* You should provide a fallback object where one or more rules compare values
* across different objects.
*/
getFieldValue<T extends UnknownObject, F extends UnknownObject>(
field: ValidOperand,
object: T,
fallbackObject?: F,
): unknown {
if (typeof field === 'string' && field.slice(0, 2) === '$.')
return getObjectKeyValue(field, object, fallbackObject);
return field;
}
/**
* Compares the `left` and `right` operands based on the `operator`, and returns
* a boolean.
*/
conditionIsTruthy(
left: ValidOperand,
operator: Operator,
right: ValidOperand,
): boolean {
switch (operator) {
case Operator.LESS_THAN:
runArithmeticComparisonGuard(left, right);
return left < right;
case Operator.LESS_THAN_OR_EQUALS:
runArithmeticComparisonGuard(left, right);
return left <= right;
case Operator.GREATER_THAN:
runArithmeticComparisonGuard(left, right);
return left > right;
case Operator.GREATER_THAN_OR_EQUALS:
runArithmeticComparisonGuard(left, right);
return left >= right;
case Operator.EQUALS:
if (typeof left === 'string' && typeof right === 'string')
return left.toLowerCase() === right.toLowerCase();
return isEqual(left, right);
case Operator.CONTAINS:
if (!Array.isArray(left))
throw new Error(
'Left field should be an array when operator is CONTAINS',
);
if (typeof right === 'boolean')
throw new Error('Cannot check whether array contains boolean');
return left.includes(right);
default:
throw new Error('No handler defined for operator');
}
}
/**
* Performs a decrement or increment action on the provided object as defined
* in the rule effect
*/
handleArithmeticAction<T extends UnknownObject>(
object: T,
effect: Effect,
): T {
const objectClone = structuredClone(object);
const { action } = effect;
const { property } = effect;
if (property == null || property === '' || Number.isInteger(property))
throw new Error(
`Cannot ${action} where effect property is undefined or invalid`,
);
const propertyValue = this.getFieldValue(property, objectClone);
if (
typeof propertyValue !== 'number' ||
effect.value == null ||
!(Number.isInteger(effect.value) && typeof effect.value === 'number')
) {
throw new Error(`Cannot ${action} a non-integer`);
}
if (![Action.INCREMENT, Action.DECREMENT].includes(action))
throw new Error(`${action} action is invalid for arithmetic operation`);
const newValue =
action === Action.INCREMENT
? propertyValue + effect.value
: propertyValue - effect.value;
return setObjectKeyValue<T>(property, newValue, objectClone);
}
/**
* Adds or replaces a key-value in the provided object as defined in the rule
* effect.
*/
handleAddAction<T extends UnknownObject>(object: T, effect: Effect): T {
const objectClone = structuredClone(object);
if (
effect.property == null ||
effect.property === '' ||
effect.value === undefined
)
throw new Error(
'Cannot set object key because effect value or property is undefined/invalid',
);
return set(objectClone, effect.property, effect.value);
}
/**
* Runs applicable effect against the provided `target` object or array of objects.
* When `target` is an array of objects, then the action is definitely OMIT or
* another action that aims at arrays.
*/
runEffect<T extends UnknownObject>(
target: T | T[],
effect: Effect,
): T | Action {
switch (effect.action) {
case Action.INCREMENT:
case Action.DECREMENT:
if (isPlainObject(target)) {
return this.handleArithmeticAction<T>(target, effect);
}
throw new Error(`Cannot perform ${effect.action} action on non-object`);
case Action.ADD:
if (isPlainObject(target)) {
return this.handleAddAction(target, effect);
}
throw new Error(`Cannot perform ${effect.action} action on non-object`);
case Action.OMIT:
return effect.action;
default:
throw new Error(
`No handler defined for "${effect.action}" action in runEffect`,
);
}
}
/**
* Checks the rules loaded into this rule engine against the provided `object` or
* `fallback` object, and returns an array of rule names whose conditions are satisfied
* by the object.
*/
checkForMatchingRules<U extends UnknownObject, F extends UnknownObject>(
object: U,
fallback?: F,
): string[] {
const matchedRules: string[] = [];
for (const ruleName in this.rules) {
const { conditions } = this.getRule(ruleName);
let numberOfRulesMatched = 0;
for (const condition of conditions) {
const [leftField, operator, rightField] = condition;
const left = this.getFieldValue(leftField, object, fallback);
const right = this.getFieldValue(rightField, object, fallback);
if (!(typeof right === 'string' || typeof right === 'number'))
throw new Error("argument 'right' should be a string or number");
if (
!(
typeof left === 'string' ||
typeof left === 'number' ||
Array.isArray(left)
)
)
throw new Error(
"argument 'left' should be a string, number or array",
);
if (this.conditionIsTruthy(left, operator, right)) {
numberOfRulesMatched++;
} else break;
}
if (numberOfRulesMatched === conditions.length) {
matchedRules.push(ruleName);
}
}
return matchedRules;
}
/**
* Loops through the provided array of objects, checks for matching rules for
* each object, and applies the applicable rule effect on each object.
*/
applyRules<U extends UnknownObject, F extends UnknownObject>(
objects: U[],
fallback?: F,
): ApplyRulesResponse {
const results: UnknownObject[] = [];
const omitted: Array<[UnknownObject, RuleError | null]> = [];
for (const object of objects) {
const matchedRules: string[] = this.checkForMatchingRules(
object,
fallback,
);
if (matchedRules?.length === 0) {
results.push(object);
continue;
}
for (const ruleName of matchedRules) {
const { effect } = this.getRule(ruleName);
const feedback = this.runEffect(object, effect);
switch (feedback) {
// if feedback is "omit" or "omit_with_silent_error", then we should
// omit the current object by not pushing it to results array.
case Action.OMIT:
omitted.push([object, null]);
continue;
case Action.OMIT_WITH_SILENT_ERROR:
if (effect?.error == null)
throw new Error('Expected error on effect, but found none');
omitted.push([object, { message: effect.error.message }]);
continue;
default:
// push feedback to results array by default
results.push(validateApplyRulesFeedback(feedback));
}
}
}
return { results, omitted };
}
}