Skip to content

Commit

Permalink
Nested rule evaluation, some more debug/error messages
Browse files Browse the repository at this point in the history
  • Loading branch information
akmjenkins committed Sep 7, 2021
1 parent 7e5da5c commit d1dd061
Show file tree
Hide file tree
Showing 4 changed files with 163 additions and 67 deletions.
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ module.exports = {
collectCoverageFrom: ['./src/*.js'],
coverageThreshold: {
global: {
branches: 70,
branches: 75,
functions: 85,
lines: 85,
},
Expand Down
26 changes: 25 additions & 1 deletion src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ export type JobConstruct = EngineOptions & {
context: Context;
};

type StartingRuleEvent = {
type: 'STARTING_RULE';
rule: string;
interpolated: FactMap[] | NamedFactMap;
context: Context;
};

type FinishedRuleEvent = {
type: 'FINISHED_RULE';
rule: string;
interpolated: FactMap[] | NamedFactMap;
context: Context;
result: RuleResult;
};

type StartingFactMapEvent = {
type: 'STARTING_FACT_MAP';
rule: string;
Expand Down Expand Up @@ -93,6 +108,12 @@ type EvaluatedFactEvent = {
result: ValidatorResult;
};

type RuleParseError = {
type: 'RuleParsingError';
rule: string;
error: Error;
};

type FactEvaluationError = {
type: 'FactEvaluationError';
rule: string;
Expand Down Expand Up @@ -120,6 +141,7 @@ type FactExecutionError = {

type ActionExecutionError = {
type: 'ActionExecutionError';
rule: string;
action: string;
params?: Record<string, any>;
error: Error;
Expand All @@ -144,7 +166,9 @@ export type DebugEvent =
| StartingFactMapEvent
| StartingFactEvent
| ExecutedFactEvent
| EvaluatedFactEvent;
| EvaluatedFactEvent
| StartingRuleEvent
| FinishedRuleEvent;
export type ErrorEvent =
| FactEvaluationError
| FactExecutionError
Expand Down
164 changes: 99 additions & 65 deletions src/rule.runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,79 +6,113 @@ export const createRuleRunner = (validator, opts, emit) => {
const processor = createFactMapProcessor(validator, opts, emit);
const executor = createActionExecutor(opts, emit);
return async ([rule, { when, ...rest }]) => {
// interpolated can be an array FactMap[] OR an object NamedFactMap
const interpolated = interpolateDeep(
when,
opts.context,
opts.pattern,
opts.resolver,
);
try {
// interpolated can be an array FactMap[] OR an object NamedFactMap
const interpolated = interpolateDeep(
when,
opts.context,
opts.pattern,
opts.resolver,
);

const process = processor(rule);
emit('debug', {
type: 'STARTING_RULE',
rule,
interpolated,
context: opts.context,
});

const ruleResults = await Promise.all(
Array.isArray(interpolated)
? interpolated.map(process)
: Object.entries(interpolated).map(([factMap, id]) =>
process(factMap, id),
),
);
const process = processor(rule);

// create the context and evaluate whether the rules have passed or errored in a single loop
const { passed, error, context } = ruleResults.reduce(
({ passed, error, context }, result) => {
if (error) return { error };
passed =
passed && Object.values(result).every(({ __passed }) => __passed);
error = Object.values(result).some(({ __error }) => __error);
return { passed, error, context: { ...context, ...result } };
},
{
passed: true,
error: false,
context: {},
},
);
const ruleResults = await Promise.all(
Array.isArray(interpolated)
? interpolated.map(process)
: Object.entries(interpolated).map(([factMap, id]) =>
process(factMap, id),
),
);

const nextContext = { ...opts.context, results: context };
const ret = (rest = {}) => ({ [rule]: { ...rest, results: ruleResults } });
// create the context and evaluate whether the rules have passed or errored in a single loop
const { passed, error, context } = ruleResults.reduce(
({ passed, error, context }, result) => {
if (error) return { error };
passed =
passed && Object.values(result).every(({ __passed }) => __passed);
error = Object.values(result).some(({ __error }) => __error);
return { passed, error, context: { ...context, ...result } };
},
{
passed: true,
error: false,
context: {},
},
);

if (error) return ret({ error: true });
const key = passed ? 'then' : 'otherwise';
const which = rest[key];
if (!which) return ret();
const nextContext = { ...opts.context, results: context };
const ret = (rest = {}) => ({
[rule]: {
__error: error,
__passed: passed,
...rest,
results: ruleResults,
},
});

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

const [actionResults, nestedReults] = await Promise.all([
actions
? Promise.all(
interpolateDeep(
actions,
nextContext,
opts.pattern,
opts.resolver,
).map(async (action) => {
try {
return { ...action, result: await executor(action) };
} catch (error) {
emit('error', { type: 'ActionExecutionError', action });
return { ...action, error };
}
}),
)
: null,
nextWhen
? createRuleRunner(
validator,
{ ...opts, context: nextContext },
emit,
)([`${rule}.${key}`, which])
: null,
]);
const { actions, when: nextWhen } = which;

const toRet = ret({ actions: actionResults });
const [actionResults, nestedReults] = await Promise.all([
actions
? Promise.all(
interpolateDeep(
actions,
nextContext,
opts.pattern,
opts.resolver,
).map(async (action) => {
try {
return { ...action, result: await executor(action) };
} catch (error) {
emit('error', {
type: 'ActionExecutionError',
rule,
action,
error,
params: action.params,
});
return { ...action, error };
}
}),
).then((actionResults) => {
// we've effectively finished this rule. The nested rules, if any, will print their own debug messages (I think this is acceptable behavior?)
emit('debug', {
type: 'FINISHED_RULE',
rule,
interpolated,
context: opts.context,
result: { actions: actionResults, results: ruleResults },
});
return actionResults;
})
: null,
nextWhen
? createRuleRunner(
validator,
{ ...opts, context: nextContext },
emit,
)([`${rule}.${key}`, which])
: null,
]);

return nestedReults ? { ...toRet, ...nestedReults } : toRet;
const toRet = ret({ actions: actionResults });

return nestedReults ? { ...toRet, ...nestedReults } : toRet;
} catch (error) {
emit('error', { type: 'RuleExecutionError', error });
return { [rule]: {} };
}
};
};
38 changes: 38 additions & 0 deletions test/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,44 @@ describe('rules engine', () => {
expect(call).toHaveBeenCalledWith({ message: 'Who are you?' });
});

it('should process nested rules', async () => {
const rules = {
salutation: {
when: [
{
firstName: { is: { type: 'string', pattern: '^A' } },
},
],
then: {
when: [
{
lastName: { is: { type: 'string', pattern: '^J' } },
},
],
then: {
actions: [
{
type: 'log',
params: { message: 'You have the same initials as me!' },
},
],
},
otherwise: {
actions: [{ type: 'log', params: { message: 'Hi' } }],
},
},
},
};
engine.setRules(rules);
await engine.run({ firstName: 'Andrew' });
expect(log).toHaveBeenCalledWith({ message: 'Hi' });
log.mockClear();
await engine.run({ firstName: 'Andrew', lastName: 'Jackson' });
expect(log).toHaveBeenCalledWith({
message: 'You have the same initials as me!',
});
});

it('should memoize facts', async () => {
const facts = { f1: jest.fn() };
engine.setFacts(facts);
Expand Down

0 comments on commit d1dd061

Please sign in to comment.