Skip to content

Commit d1dd061

Browse files
committed
Nested rule evaluation, some more debug/error messages
1 parent 7e5da5c commit d1dd061

File tree

4 files changed

+163
-67
lines changed

4 files changed

+163
-67
lines changed

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ module.exports = {
77
collectCoverageFrom: ['./src/*.js'],
88
coverageThreshold: {
99
global: {
10-
branches: 70,
10+
branches: 75,
1111
functions: 85,
1212
lines: 85,
1313
},

src/index.d.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,21 @@ export type JobConstruct = EngineOptions & {
5858
context: Context;
5959
};
6060

61+
type StartingRuleEvent = {
62+
type: 'STARTING_RULE';
63+
rule: string;
64+
interpolated: FactMap[] | NamedFactMap;
65+
context: Context;
66+
};
67+
68+
type FinishedRuleEvent = {
69+
type: 'FINISHED_RULE';
70+
rule: string;
71+
interpolated: FactMap[] | NamedFactMap;
72+
context: Context;
73+
result: RuleResult;
74+
};
75+
6176
type StartingFactMapEvent = {
6277
type: 'STARTING_FACT_MAP';
6378
rule: string;
@@ -93,6 +108,12 @@ type EvaluatedFactEvent = {
93108
result: ValidatorResult;
94109
};
95110

111+
type RuleParseError = {
112+
type: 'RuleParsingError';
113+
rule: string;
114+
error: Error;
115+
};
116+
96117
type FactEvaluationError = {
97118
type: 'FactEvaluationError';
98119
rule: string;
@@ -120,6 +141,7 @@ type FactExecutionError = {
120141

121142
type ActionExecutionError = {
122143
type: 'ActionExecutionError';
144+
rule: string;
123145
action: string;
124146
params?: Record<string, any>;
125147
error: Error;
@@ -144,7 +166,9 @@ export type DebugEvent =
144166
| StartingFactMapEvent
145167
| StartingFactEvent
146168
| ExecutedFactEvent
147-
| EvaluatedFactEvent;
169+
| EvaluatedFactEvent
170+
| StartingRuleEvent
171+
| FinishedRuleEvent;
148172
export type ErrorEvent =
149173
| FactEvaluationError
150174
| FactExecutionError

src/rule.runner.js

Lines changed: 99 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -6,79 +6,113 @@ export const createRuleRunner = (validator, opts, emit) => {
66
const processor = createFactMapProcessor(validator, opts, emit);
77
const executor = createActionExecutor(opts, emit);
88
return async ([rule, { when, ...rest }]) => {
9-
// interpolated can be an array FactMap[] OR an object NamedFactMap
10-
const interpolated = interpolateDeep(
11-
when,
12-
opts.context,
13-
opts.pattern,
14-
opts.resolver,
15-
);
9+
try {
10+
// interpolated can be an array FactMap[] OR an object NamedFactMap
11+
const interpolated = interpolateDeep(
12+
when,
13+
opts.context,
14+
opts.pattern,
15+
opts.resolver,
16+
);
1617

17-
const process = processor(rule);
18+
emit('debug', {
19+
type: 'STARTING_RULE',
20+
rule,
21+
interpolated,
22+
context: opts.context,
23+
});
1824

19-
const ruleResults = await Promise.all(
20-
Array.isArray(interpolated)
21-
? interpolated.map(process)
22-
: Object.entries(interpolated).map(([factMap, id]) =>
23-
process(factMap, id),
24-
),
25-
);
25+
const process = processor(rule);
2626

27-
// create the context and evaluate whether the rules have passed or errored in a single loop
28-
const { passed, error, context } = ruleResults.reduce(
29-
({ passed, error, context }, result) => {
30-
if (error) return { error };
31-
passed =
32-
passed && Object.values(result).every(({ __passed }) => __passed);
33-
error = Object.values(result).some(({ __error }) => __error);
34-
return { passed, error, context: { ...context, ...result } };
35-
},
36-
{
37-
passed: true,
38-
error: false,
39-
context: {},
40-
},
41-
);
27+
const ruleResults = await Promise.all(
28+
Array.isArray(interpolated)
29+
? interpolated.map(process)
30+
: Object.entries(interpolated).map(([factMap, id]) =>
31+
process(factMap, id),
32+
),
33+
);
4234

43-
const nextContext = { ...opts.context, results: context };
44-
const ret = (rest = {}) => ({ [rule]: { ...rest, results: ruleResults } });
35+
// create the context and evaluate whether the rules have passed or errored in a single loop
36+
const { passed, error, context } = ruleResults.reduce(
37+
({ passed, error, context }, result) => {
38+
if (error) return { error };
39+
passed =
40+
passed && Object.values(result).every(({ __passed }) => __passed);
41+
error = Object.values(result).some(({ __error }) => __error);
42+
return { passed, error, context: { ...context, ...result } };
43+
},
44+
{
45+
passed: true,
46+
error: false,
47+
context: {},
48+
},
49+
);
4550

46-
if (error) return ret({ error: true });
47-
const key = passed ? 'then' : 'otherwise';
48-
const which = rest[key];
49-
if (!which) return ret();
51+
const nextContext = { ...opts.context, results: context };
52+
const ret = (rest = {}) => ({
53+
[rule]: {
54+
__error: error,
55+
__passed: passed,
56+
...rest,
57+
results: ruleResults,
58+
},
59+
});
5060

51-
const { actions, when: nextWhen } = which;
61+
const key = passed ? 'then' : 'otherwise';
62+
const which = rest[key];
63+
if (error || !which) return ret();
5264

53-
const [actionResults, nestedReults] = await Promise.all([
54-
actions
55-
? Promise.all(
56-
interpolateDeep(
57-
actions,
58-
nextContext,
59-
opts.pattern,
60-
opts.resolver,
61-
).map(async (action) => {
62-
try {
63-
return { ...action, result: await executor(action) };
64-
} catch (error) {
65-
emit('error', { type: 'ActionExecutionError', action });
66-
return { ...action, error };
67-
}
68-
}),
69-
)
70-
: null,
71-
nextWhen
72-
? createRuleRunner(
73-
validator,
74-
{ ...opts, context: nextContext },
75-
emit,
76-
)([`${rule}.${key}`, which])
77-
: null,
78-
]);
65+
const { actions, when: nextWhen } = which;
7966

80-
const toRet = ret({ actions: actionResults });
67+
const [actionResults, nestedReults] = await Promise.all([
68+
actions
69+
? Promise.all(
70+
interpolateDeep(
71+
actions,
72+
nextContext,
73+
opts.pattern,
74+
opts.resolver,
75+
).map(async (action) => {
76+
try {
77+
return { ...action, result: await executor(action) };
78+
} catch (error) {
79+
emit('error', {
80+
type: 'ActionExecutionError',
81+
rule,
82+
action,
83+
error,
84+
params: action.params,
85+
});
86+
return { ...action, error };
87+
}
88+
}),
89+
).then((actionResults) => {
90+
// we've effectively finished this rule. The nested rules, if any, will print their own debug messages (I think this is acceptable behavior?)
91+
emit('debug', {
92+
type: 'FINISHED_RULE',
93+
rule,
94+
interpolated,
95+
context: opts.context,
96+
result: { actions: actionResults, results: ruleResults },
97+
});
98+
return actionResults;
99+
})
100+
: null,
101+
nextWhen
102+
? createRuleRunner(
103+
validator,
104+
{ ...opts, context: nextContext },
105+
emit,
106+
)([`${rule}.${key}`, which])
107+
: null,
108+
]);
81109

82-
return nestedReults ? { ...toRet, ...nestedReults } : toRet;
110+
const toRet = ret({ actions: actionResults });
111+
112+
return nestedReults ? { ...toRet, ...nestedReults } : toRet;
113+
} catch (error) {
114+
emit('error', { type: 'RuleExecutionError', error });
115+
return { [rule]: {} };
116+
}
83117
};
84118
};

test/engine.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,44 @@ describe('rules engine', () => {
4040
expect(call).toHaveBeenCalledWith({ message: 'Who are you?' });
4141
});
4242

43+
it('should process nested rules', async () => {
44+
const rules = {
45+
salutation: {
46+
when: [
47+
{
48+
firstName: { is: { type: 'string', pattern: '^A' } },
49+
},
50+
],
51+
then: {
52+
when: [
53+
{
54+
lastName: { is: { type: 'string', pattern: '^J' } },
55+
},
56+
],
57+
then: {
58+
actions: [
59+
{
60+
type: 'log',
61+
params: { message: 'You have the same initials as me!' },
62+
},
63+
],
64+
},
65+
otherwise: {
66+
actions: [{ type: 'log', params: { message: 'Hi' } }],
67+
},
68+
},
69+
},
70+
};
71+
engine.setRules(rules);
72+
await engine.run({ firstName: 'Andrew' });
73+
expect(log).toHaveBeenCalledWith({ message: 'Hi' });
74+
log.mockClear();
75+
await engine.run({ firstName: 'Andrew', lastName: 'Jackson' });
76+
expect(log).toHaveBeenCalledWith({
77+
message: 'You have the same initials as me!',
78+
});
79+
});
80+
4381
it('should memoize facts', async () => {
4482
const facts = { f1: jest.fn() };
4583
engine.setFacts(facts);

0 commit comments

Comments
 (0)