Skip to content

Commit f319d83

Browse files
authored
BAL-3498: enhance rule-engine evaluation logic with path comparison (#3069)
* feat(rule-engine): enhance rule evaluation logic with path comparison - Introduce isPathComparison flag in Rule schema for clearer distinction - Update extractValue method to handle operators with and without path comparison - Adjust validation logic to accommodate new extracted value structure * fix(rule-engine): correct path comparison validation logic - Ensure 'isPathComparison' is only true when present in the rule - Update condition to prevent false positives in comparison checks * refactor(rule-engine): improve operator extraction logic and constants usage - Move OPERATORS_WITHOUT_PATH_COMPARISON to constants for better reusability - Refactor extraction logic to use isObject utility for clarity - Update imports to reflect the new constants structure * fix(rule-engine): improve rule extraction validation - Refactor validation logic for extracted values - Utilize 'in' operator for better readability and accuracy * feat(tests): enhance rule engine tests with path comparison functionality - Add isPathComparison flag to various rule definitions - Implement new unit tests for path comparison scenarios - Update integration tests to include path comparison in validation
1 parent 46ca852 commit f319d83

File tree

7 files changed

+136
-8
lines changed

7 files changed

+136
-8
lines changed

packages/common/src/rule-engine/operators/constants.ts

+6
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,9 @@ export const OperationHelpers = {
3131
[OPERATION.NOT_IN]: NOT_IN,
3232
[OPERATION.AML_CHECK]: AML_CHECK,
3333
} as const;
34+
35+
export const OPERATORS_WITHOUT_PATH_COMPARISON = [
36+
OPERATION.AML_CHECK,
37+
OPERATION.BETWEEN,
38+
OPERATION.LAST_YEAR,
39+
] as const;

packages/common/src/rule-engine/operators/helpers.ts

+23-4
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { z, ZodSchema } from 'zod';
1414
import { BetweenSchema, LastYearsSchema, PrimitiveArraySchema, PrimitiveSchema } from './schemas';
1515

1616
import { ValidationFailedError, DataValueNotFoundError } from '../errors';
17-
import { OperationHelpers } from './constants';
17+
import { OperationHelpers, OPERATORS_WITHOUT_PATH_COMPARISON } from './constants';
1818
import { Rule } from '@/rule-engine';
1919
import { EndUserAmlHitsSchema } from '@/schemas';
2020

@@ -40,11 +40,30 @@ export abstract class BaseOperator<TDataValue = Primitive, TConditionValue = Pri
4040
extractValue(data: unknown, rule: Rule) {
4141
const value = get(data, rule.key);
4242

43-
if (value === undefined || value === null) {
44-
throw new DataValueNotFoundError(rule.key);
43+
const isPathComparison =
44+
!OPERATORS_WITHOUT_PATH_COMPARISON.includes(
45+
rule.operator as (typeof OPERATORS_WITHOUT_PATH_COMPARISON)[number],
46+
) &&
47+
'isPathComparison' in rule &&
48+
rule.isPathComparison;
49+
50+
if (!isPathComparison) {
51+
if (value === undefined || value === null) {
52+
throw new DataValueNotFoundError(rule.key);
53+
}
54+
55+
return value;
56+
}
57+
58+
const comparisonValueAsPath = rule.value as string;
59+
60+
const evaluatedComparisonValue = get(data, comparisonValueAsPath);
61+
62+
if (evaluatedComparisonValue === undefined || evaluatedComparisonValue === null) {
63+
throw new DataValueNotFoundError(comparisonValueAsPath);
4564
}
4665

47-
return value;
66+
return { value, comparisonValue: evaluatedComparisonValue };
4867
}
4968

5069
execute(dataValue: TDataValue, conditionValue: TConditionValue) {

packages/common/src/rule-engine/rules/schemas.ts

+9
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,13 @@ export const RuleSchema = z.discriminatedUnion('operator', [
3939
key: z.string(),
4040
operator: z.literal(OPERATION.EQUALS),
4141
value: PrimitiveSchema,
42+
isPathComparison: z.boolean().default(false),
4243
}),
4344
z.object({
4445
key: z.string(),
4546
operator: z.literal(OPERATION.NOT_EQUALS),
4647
value: PrimitiveSchema,
48+
isPathComparison: z.boolean().default(false),
4749
}),
4850
z.object({
4951
key: z.string(),
@@ -54,21 +56,25 @@ export const RuleSchema = z.discriminatedUnion('operator', [
5456
key: z.string(),
5557
operator: z.literal(OPERATION.GT),
5658
value: PrimitiveSchema,
59+
isPathComparison: z.boolean().default(false),
5760
}),
5861
z.object({
5962
key: z.string(),
6063
operator: z.literal(OPERATION.LT),
6164
value: PrimitiveSchema,
65+
isPathComparison: z.boolean().default(false),
6266
}),
6367
z.object({
6468
key: z.string(),
6569
operator: z.literal(OPERATION.GTE),
6670
value: PrimitiveSchema,
71+
isPathComparison: z.boolean().default(false),
6772
}),
6873
z.object({
6974
key: z.string(),
7075
operator: z.literal(OPERATION.LTE),
7176
value: PrimitiveSchema,
77+
isPathComparison: z.boolean().default(false),
7278
}),
7379
z.object({
7480
key: z.string(),
@@ -79,16 +85,19 @@ export const RuleSchema = z.discriminatedUnion('operator', [
7985
key: z.string(),
8086
operator: z.literal(OPERATION.IN),
8187
value: PrimitiveArraySchema,
88+
isPathComparison: z.boolean().default(false),
8289
}),
8390
z.object({
8491
key: z.string(),
8592
operator: z.literal(OPERATION.IN_CASE_INSENSITIVE),
8693
value: PrimitiveArraySchema,
94+
isPathComparison: z.boolean().default(false),
8795
}),
8896
z.object({
8997
key: z.string(),
9098
operator: z.literal(OPERATION.NOT_IN),
9199
value: PrimitiveArraySchema,
100+
isPathComparison: z.boolean().default(false),
92101
}),
93102
]);
94103

services/workflows-service/src/rule-engine/core/rule-engine.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
OPERATOR,
99
RuleSchema,
1010
ValidationFailedError,
11+
isObject,
1112
} from '@ballerine/common';
1213

1314
export const validateRule = (rule: Rule, data: any): RuleResult => {
@@ -23,11 +24,17 @@ export const validateRule = (rule: Rule, data: any): RuleResult => {
2324
throw new OperatorNotFoundError(rule.operator);
2425
}
2526

26-
const value = operator.extractValue(data, rule);
27+
const extractedValue = operator.extractValue(data, rule);
28+
29+
const isPathComparison =
30+
isObject(extractedValue) && 'value' in extractedValue && 'comparisonValue' in extractedValue;
31+
32+
const { value, comparisonValue } = isPathComparison
33+
? extractedValue
34+
: { value: extractedValue, comparisonValue: rule.value };
2735

2836
try {
29-
// @ts-expect-error - rule
30-
const result = operator.execute(value, rule.value);
37+
const result = operator.execute(value, comparisonValue);
3138

3239
return { status: result ? 'PASSED' : 'FAILED', error: undefined };
3340
} catch (error) {

services/workflows-service/src/rule-engine/core/test/rule-engine.unit.test.ts

+85
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe('Rule Engine', () => {
2626
key: 'country',
2727
operator: OPERATION.EQUALS,
2828
value: 'US',
29+
isPathComparison: false,
2930
},
3031
{
3132
operator: OPERATOR.AND,
@@ -34,6 +35,7 @@ describe('Rule Engine', () => {
3435
key: 'name',
3536
operator: OPERATION.EQUALS,
3637
value: 'John',
38+
isPathComparison: false,
3739
},
3840
{
3941
operator: OPERATOR.OR,
@@ -42,11 +44,13 @@ describe('Rule Engine', () => {
4244
key: 'age',
4345
operator: OPERATION.GT,
4446
value: 40,
47+
isPathComparison: false,
4548
},
4649
{
4750
key: 'age',
4851
operator: OPERATION.LTE,
4952
value: 35,
53+
isPathComparison: false,
5054
},
5155
],
5256
},
@@ -73,6 +77,7 @@ describe('Rule Engine', () => {
7377
key: 'nonexistent',
7478
operator: OPERATION.EQUALS,
7579
value: 'US',
80+
isPathComparison: false,
7681
},
7782
],
7883
};
@@ -95,6 +100,7 @@ describe('Rule Engine', () => {
95100
operator: 'UNKNOWN',
96101
// @ts-ignore - intentionally using an unknown operator
97102
value: 'US',
103+
isPathComparison: false,
98104
},
99105
],
100106
};
@@ -141,6 +147,7 @@ describe('Rule Engine', () => {
141147
key: 'country',
142148
operator: OPERATION.EQUALS,
143149
value: 'CA',
150+
isPathComparison: false,
144151
},
145152
],
146153
};
@@ -220,6 +227,7 @@ describe('Rule Engine', () => {
220227
key: '',
221228
operator: OPERATION.EQUALS,
222229
value: 'US',
230+
isPathComparison: false,
223231
},
224232
],
225233
};
@@ -232,6 +240,7 @@ describe('Rule Engine', () => {
232240
"error": [DataValueNotFoundError: Field is missing or null],
233241
"message": "Field is missing or null",
234242
"rule": {
243+
"isPathComparison": false,
235244
"key": "",
236245
"operator": "EQUALS",
237246
"value": "US",
@@ -493,6 +502,7 @@ describe('Rule Engine', () => {
493502
key: 'pluginsOutput.companySanctions.data.length',
494503
operator: OPERATION.NOT_EQUALS,
495504
value: 0,
505+
isPathComparison: false,
496506
},
497507
],
498508
};
@@ -506,6 +516,7 @@ describe('Rule Engine', () => {
506516
{
507517
"error": undefined,
508518
"rule": {
519+
"isPathComparison": false,
509520
"key": "pluginsOutput.companySanctions.data.length",
510521
"operator": "NOT_EQUALS",
511522
"value": 0,
@@ -526,6 +537,7 @@ describe('Rule Engine', () => {
526537
{
527538
"error": undefined,
528539
"rule": {
540+
"isPathComparison": false,
529541
"key": "pluginsOutput.companySanctions.data.length",
530542
"operator": "NOT_EQUALS",
531543
"value": 0,
@@ -545,6 +557,7 @@ describe('Rule Engine', () => {
545557
key: 'entity.data.country',
546558
operator: OPERATION.IN,
547559
value: ['IL', 'AF', 'US', 'GB'],
560+
isPathComparison: false,
548561
},
549562
],
550563
};
@@ -558,6 +571,7 @@ describe('Rule Engine', () => {
558571
{
559572
"error": undefined,
560573
"rule": {
574+
"isPathComparison": false,
561575
"key": "entity.data.country",
562576
"operator": "IN",
563577
"value": [
@@ -583,6 +597,7 @@ describe('Rule Engine', () => {
583597
{
584598
"error": undefined,
585599
"rule": {
600+
"isPathComparison": false,
586601
"key": "entity.data.country",
587602
"operator": "IN",
588603
"value": [
@@ -607,6 +622,7 @@ describe('Rule Engine', () => {
607622
key: 'country',
608623
operator: OPERATION.IN_CASE_INSENSITIVE,
609624
value: ['us', 'ca'],
625+
isPathComparison: false,
610626
},
611627
],
612628
};
@@ -633,6 +649,7 @@ describe('Rule Engine', () => {
633649
key: 'countries',
634650
operator: OPERATION.IN_CASE_INSENSITIVE,
635651
value: ['us', 'ca'],
652+
isPathComparison: false,
636653
},
637654
],
638655
};
@@ -661,6 +678,7 @@ describe('Rule Engine', () => {
661678
key: 'entity.data.country',
662679
operator: OPERATION.NOT_IN,
663680
value: ['IL', 'CA', 'US', 'GB'],
681+
isPathComparison: false,
664682
},
665683
],
666684
};
@@ -674,6 +692,7 @@ describe('Rule Engine', () => {
674692
{
675693
"error": undefined,
676694
"rule": {
695+
"isPathComparison": false,
677696
"key": "entity.data.country",
678697
"operator": "NOT_IN",
679698
"value": [
@@ -699,6 +718,7 @@ describe('Rule Engine', () => {
699718
{
700719
"error": undefined,
701720
"rule": {
721+
"isPathComparison": false,
702722
"key": "entity.data.country",
703723
"operator": "NOT_IN",
704724
"value": [
@@ -1038,4 +1058,69 @@ describe('Rule Engine', () => {
10381058
`);
10391059
});
10401060
});
1061+
1062+
describe('Path comparison', () => {
1063+
it('should compare values from two different paths', () => {
1064+
const ruleSetExample: RuleSet = {
1065+
operator: OPERATOR.AND,
1066+
rules: [
1067+
{
1068+
key: 'pluginsOutput.businessInformation.data[0].companyName',
1069+
operator: OPERATION.NOT_EQUALS,
1070+
value: 'entity.data.companyName',
1071+
isPathComparison: true,
1072+
},
1073+
],
1074+
};
1075+
1076+
const engine = RuleEngine(ruleSetExample);
1077+
const result = engine.run(context);
1078+
expect(result).toBeDefined();
1079+
expect(result).toHaveLength(1);
1080+
expect(result[0]).toMatchInlineSnapshot(`
1081+
{
1082+
"error": undefined,
1083+
"rule": {
1084+
"isPathComparison": true,
1085+
"key": "pluginsOutput.businessInformation.data[0].companyName",
1086+
"operator": "NOT_EQUALS",
1087+
"value": "entity.data.companyName",
1088+
},
1089+
"status": "PASSED",
1090+
}
1091+
`);
1092+
});
1093+
1094+
it('should handle invalid paths', () => {
1095+
const ruleSetExample: RuleSet = {
1096+
operator: OPERATOR.AND,
1097+
rules: [
1098+
{
1099+
key: 'pluginsOutput.businessInformation.data[0].companyName',
1100+
operator: OPERATION.NOT_EQUALS,
1101+
value: 'entity.invalid.path',
1102+
isPathComparison: true,
1103+
},
1104+
],
1105+
};
1106+
1107+
const engine = RuleEngine(ruleSetExample);
1108+
const result = engine.run(context);
1109+
expect(result).toBeDefined();
1110+
expect(result).toHaveLength(1);
1111+
expect(result[0]).toMatchInlineSnapshot(`
1112+
{
1113+
"error": [DataValueNotFoundError: Field entity.invalid.path is missing or null],
1114+
"message": "Field entity.invalid.path is missing or null",
1115+
"rule": {
1116+
"isPathComparison": true,
1117+
"key": "pluginsOutput.businessInformation.data[0].companyName",
1118+
"operator": "NOT_EQUALS",
1119+
"value": "entity.invalid.path",
1120+
},
1121+
"status": "FAILED",
1122+
}
1123+
`);
1124+
});
1125+
});
10411126
});

services/workflows-service/src/rule-engine/rule-engine.service.intg.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ describe('RuleEngineService', () => {
2121
key: 'single',
2222
operator: 'IN_CASE_INSENSITIVE',
2323
value: ['sole'],
24+
isPathComparison: false,
2425
},
2526
{
2627
key: 'array',
2728
operator: 'IN_CASE_INSENSITIVE',
2829
value: ['ownership'],
30+
isPathComparison: false,
2931
},
3032
],
3133
};

0 commit comments

Comments
 (0)