Turn filter action into conditions (#13005)
Previous logic was using the previous step output and filtering items that were passing filters. What we actually want is: - send filters, right operand being always a step output key, left operand being either a key, either a value - resolve those filter variables - apply the filters to decide whether the condition is passed or not
This commit is contained in:
@ -585,7 +585,8 @@ export class WorkflowVersionStepWorkspaceService {
|
||||
settings: {
|
||||
...BASE_STEP_DEFINITION,
|
||||
input: {
|
||||
filter: {},
|
||||
filterGroups: [],
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -8,10 +8,9 @@ import {
|
||||
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
|
||||
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
|
||||
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
|
||||
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
|
||||
import { isWorkflowFilterAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/guards/is-workflow-filter-action.guard';
|
||||
import { applyFilter } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/apply-filter.util';
|
||||
|
||||
import { getPreviousStepOutput } from './utils/get-previous-step-output.util';
|
||||
import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util';
|
||||
|
||||
@Injectable()
|
||||
export class FilterWorkflowAction implements WorkflowExecutor {
|
||||
@ -34,37 +33,30 @@ export class FilterWorkflowAction implements WorkflowExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
const { filter } = step.settings.input;
|
||||
const { filterGroups, filters } = step.settings.input;
|
||||
|
||||
if (!filter) {
|
||||
if (!filterGroups || !filters) {
|
||||
throw new WorkflowStepExecutorException(
|
||||
'Filter is not defined',
|
||||
WorkflowStepExecutorExceptionCode.INVALID_STEP_SETTINGS,
|
||||
);
|
||||
}
|
||||
|
||||
const previousStepOutput = getPreviousStepOutput(
|
||||
steps,
|
||||
currentStepId,
|
||||
context,
|
||||
);
|
||||
const resolvedFilters = filters.map((filter) => ({
|
||||
...filter,
|
||||
rightOperand: resolveInput(filter.value, context),
|
||||
leftOperand: resolveInput(filter.stepOutputKey, context),
|
||||
}));
|
||||
|
||||
const isPreviousStepOutputArray = Array.isArray(previousStepOutput);
|
||||
|
||||
const previousStepOutputArray = isPreviousStepOutputArray
|
||||
? previousStepOutput
|
||||
: [previousStepOutput];
|
||||
|
||||
const filteredOutput = applyFilter(previousStepOutputArray, filter);
|
||||
|
||||
if (filteredOutput.length === 0) {
|
||||
return {
|
||||
result: undefined,
|
||||
};
|
||||
}
|
||||
const matchesFilter = evaluateFilterConditions({
|
||||
filterGroups,
|
||||
filters: resolvedFilters,
|
||||
});
|
||||
|
||||
return {
|
||||
result: isPreviousStepOutputArray ? filteredOutput : filteredOutput[0],
|
||||
result: {
|
||||
matchesFilter,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,44 @@
|
||||
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
|
||||
export enum LogicalOperator {
|
||||
AND = 'AND',
|
||||
OR = 'OR',
|
||||
}
|
||||
|
||||
export enum Operand {
|
||||
EQ = 'eq',
|
||||
NE = 'ne',
|
||||
GT = 'gt',
|
||||
GTE = 'gte',
|
||||
LT = 'lt',
|
||||
LTE = 'lte',
|
||||
LIKE = 'like',
|
||||
ILIKE = 'ilike',
|
||||
IN = 'in',
|
||||
IS = 'is',
|
||||
}
|
||||
|
||||
export type FilterGroup = {
|
||||
id: string;
|
||||
logicalOperator: LogicalOperator;
|
||||
parentRecordFilterGroupId?: string;
|
||||
positionInRecordFilterGroup?: number;
|
||||
};
|
||||
|
||||
export type Filter = {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
value: string;
|
||||
operand: Operand;
|
||||
displayValue: string;
|
||||
recordFilterGroupId: string;
|
||||
stepOutputKey: string;
|
||||
};
|
||||
|
||||
export type WorkflowFilterActionSettings = BaseWorkflowActionSettings & {
|
||||
input: {
|
||||
filter: Partial<ObjectRecordFilter>;
|
||||
filterGroups?: FilterGroup[];
|
||||
filters?: Filter[];
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,324 +0,0 @@
|
||||
import { applyFilter } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/apply-filter.util';
|
||||
|
||||
describe('applyFilter', () => {
|
||||
const testData = [
|
||||
{
|
||||
name: { firstName: 'John', lastName: 'Doe' },
|
||||
companyId: '20202020-171e-4bcc-9cf7-43448d6fb278',
|
||||
emails: { primaryEmail: 'john.doe@example.com' },
|
||||
city: 'New York',
|
||||
age: 30,
|
||||
tags: ['developer', 'senior'],
|
||||
metadata: { lastLogin: '2024-03-15', isActive: true },
|
||||
},
|
||||
{
|
||||
name: { firstName: 'Jane', lastName: 'Smith' },
|
||||
companyId: '30303030-171e-4bcc-9cf7-43448d6fb278',
|
||||
emails: { primaryEmail: 'jane.smith@test.com' },
|
||||
city: null,
|
||||
age: 25,
|
||||
tags: ['designer', 'junior'],
|
||||
metadata: { lastLogin: '2024-03-10', isActive: false },
|
||||
},
|
||||
{
|
||||
name: { firstName: 'Tom', lastName: 'Cruise' },
|
||||
companyId: '20202020-171e-4bcc-9cf7-43448d6fb278',
|
||||
emails: { primaryEmail: 'tom.cruise@example.com' },
|
||||
city: '',
|
||||
age: 40,
|
||||
tags: ['manager', 'senior'],
|
||||
metadata: { lastLogin: '2024-03-20', isActive: true },
|
||||
},
|
||||
];
|
||||
|
||||
describe('Logical Operators', () => {
|
||||
it('should handle AND conditions', () => {
|
||||
const filter = {
|
||||
and: [{ age: { gt: 25 } }, { metadata: { isActive: { eq: true } } }],
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([
|
||||
testData[0], // John Doe
|
||||
testData[2], // Tom Cruise
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle OR conditions', () => {
|
||||
const filter = {
|
||||
or: [{ age: { lt: 30 } }, { city: { is: 'NULL' } }],
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([
|
||||
testData[1], // Jane Smith
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle NOT conditions', () => {
|
||||
const filter = {
|
||||
not: {
|
||||
metadata: { isActive: { eq: true } },
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([
|
||||
testData[1], // Jane Smith
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle complex nested logical operators', () => {
|
||||
const filter = {
|
||||
and: [
|
||||
{
|
||||
or: [{ age: { gt: 35 } }, { city: { is: 'NULL' } }],
|
||||
},
|
||||
{
|
||||
not: {
|
||||
metadata: { isActive: { eq: false } },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([
|
||||
testData[2], // Tom Cruise
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Comparison Operators', () => {
|
||||
it('should handle eq operator', () => {
|
||||
const filter = {
|
||||
age: { eq: 30 },
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[0]]);
|
||||
});
|
||||
|
||||
it('should handle ne operator', () => {
|
||||
const filter = {
|
||||
age: { ne: 30 },
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[1], testData[2]]);
|
||||
});
|
||||
|
||||
it('should handle gt operator', () => {
|
||||
const filter = {
|
||||
age: { gt: 35 },
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[2]]);
|
||||
});
|
||||
|
||||
it('should handle gte operator', () => {
|
||||
const filter = {
|
||||
age: { gte: 30 },
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[0], testData[2]]);
|
||||
});
|
||||
|
||||
it('should handle lt operator', () => {
|
||||
const filter = {
|
||||
age: { lt: 30 },
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[1]]);
|
||||
});
|
||||
|
||||
it('should handle lte operator', () => {
|
||||
const filter = {
|
||||
age: { lte: 30 },
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[0], testData[1]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('String Operators', () => {
|
||||
it('should handle like operator', () => {
|
||||
const filter = {
|
||||
name: {
|
||||
firstName: { like: 'J%' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[0], testData[1]]);
|
||||
});
|
||||
|
||||
it('should handle ilike operator', () => {
|
||||
const filter = {
|
||||
name: {
|
||||
firstName: { ilike: 'j%' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[0], testData[1]]);
|
||||
});
|
||||
|
||||
it('should handle like with multiple wildcards', () => {
|
||||
const filter = {
|
||||
emails: {
|
||||
primaryEmail: { like: '%@example.com' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[0], testData[2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Array and Null Operators', () => {
|
||||
it('should handle in operator', () => {
|
||||
const filter = {
|
||||
companyId: {
|
||||
in: ['20202020-171e-4bcc-9cf7-43448d6fb278'],
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[0], testData[2]]);
|
||||
});
|
||||
|
||||
it('should handle is NULL operator', () => {
|
||||
const filter = {
|
||||
city: { is: 'NULL' },
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[1]]);
|
||||
});
|
||||
|
||||
it('should handle array contains', () => {
|
||||
const filter = {
|
||||
tags: {
|
||||
in: ['senior'],
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[0], testData[2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nested Object Conditions', () => {
|
||||
it('should handle deeply nested conditions', () => {
|
||||
const filter = {
|
||||
and: [
|
||||
{
|
||||
name: {
|
||||
firstName: { like: 'J%' },
|
||||
lastName: { like: 'D%' },
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
isActive: { eq: true },
|
||||
lastLogin: { like: '2024-03-15' },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[0]]);
|
||||
});
|
||||
|
||||
it('should handle multiple levels of nesting', () => {
|
||||
const filter = {
|
||||
and: [
|
||||
{
|
||||
name: {
|
||||
firstName: { like: 'T%' },
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
isActive: { eq: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
emails: {
|
||||
primaryEmail: { like: '%@example.com' },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([testData[2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty filter', () => {
|
||||
const filter = {};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
});
|
||||
|
||||
it('should handle empty array input', () => {
|
||||
const filter = {
|
||||
age: { gt: 30 },
|
||||
};
|
||||
|
||||
const result = applyFilter([], filter);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle non-existent fields', () => {
|
||||
const filter = {
|
||||
nonExistentField: { eq: 'value' },
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle null values in nested objects', () => {
|
||||
const filter = {
|
||||
metadata: {
|
||||
nonExistentField: { is: 'NULL' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyFilter(testData, filter);
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,805 @@
|
||||
import {
|
||||
FilterGroup,
|
||||
LogicalOperator,
|
||||
Operand,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type';
|
||||
import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util';
|
||||
|
||||
type ResolvedFilter = {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
rightOperand: unknown;
|
||||
operand: Operand;
|
||||
displayValue: string;
|
||||
fieldMetadataId: string;
|
||||
recordFilterGroupId: string;
|
||||
leftOperand: unknown;
|
||||
};
|
||||
|
||||
describe('evaluateFilterConditions', () => {
|
||||
describe('empty inputs', () => {
|
||||
it('should return true when no filters or groups are provided', () => {
|
||||
const result = evaluateFilterConditions({
|
||||
filterGroups: [],
|
||||
filters: [],
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when inputs are undefined', () => {
|
||||
const result = evaluateFilterConditions({});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('single filter operands', () => {
|
||||
const createFilter = (
|
||||
operand: Operand,
|
||||
leftOperand: unknown,
|
||||
rightOperand: unknown,
|
||||
): ResolvedFilter => ({
|
||||
id: 'filter1',
|
||||
type: 'text',
|
||||
label: 'Test Filter',
|
||||
rightOperand,
|
||||
operand,
|
||||
displayValue: String(rightOperand),
|
||||
fieldMetadataId: 'field1',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand,
|
||||
});
|
||||
|
||||
describe('eq operand', () => {
|
||||
it('should return true when values are equal', () => {
|
||||
const filter = createFilter(Operand.EQ, 'John', 'John');
|
||||
const result = evaluateFilterConditions({ filters: [filter] });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when values are loosely equal', () => {
|
||||
const filter = createFilter(Operand.EQ, '123', 123);
|
||||
const result = evaluateFilterConditions({ filters: [filter] });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when values are not equal', () => {
|
||||
const filter = createFilter(Operand.EQ, 'John', 'Jane');
|
||||
const result = evaluateFilterConditions({ filters: [filter] });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ne operand', () => {
|
||||
it('should return false when values are equal', () => {
|
||||
const filter = createFilter(Operand.NE, 'John', 'John');
|
||||
const result = evaluateFilterConditions({ filters: [filter] });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when values are not equal', () => {
|
||||
const filter = createFilter(Operand.NE, 'John', 'Jane');
|
||||
const result = evaluateFilterConditions({ filters: [filter] });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('numeric operands', () => {
|
||||
it('should handle gt operand correctly', () => {
|
||||
const filter1 = createFilter(Operand.GT, 30, 25);
|
||||
const filter2 = createFilter(Operand.GT, 20, 25);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle gte operand correctly', () => {
|
||||
const filter1 = createFilter(Operand.GTE, 25, 25);
|
||||
const filter2 = createFilter(Operand.GTE, 30, 25);
|
||||
const filter3 = createFilter(Operand.GTE, 20, 25);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle lt operand correctly', () => {
|
||||
const filter1 = createFilter(Operand.LT, 20, 25);
|
||||
const filter2 = createFilter(Operand.LT, 30, 25);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle lte operand correctly', () => {
|
||||
const filter1 = createFilter(Operand.LTE, 25, 25);
|
||||
const filter2 = createFilter(Operand.LTE, 20, 25);
|
||||
const filter3 = createFilter(Operand.LTE, 30, 25);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false);
|
||||
});
|
||||
|
||||
it('should convert string numbers for numeric comparisons', () => {
|
||||
const filter1 = createFilter(Operand.GT, '30', '25');
|
||||
const filter2 = createFilter(Operand.LT, '20', '25');
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('string operands', () => {
|
||||
it('should handle like operand correctly', () => {
|
||||
const filter1 = createFilter(Operand.LIKE, 'Hello World', 'World');
|
||||
const filter2 = createFilter(Operand.LIKE, 'Hello', 'World');
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle ilike operand correctly (case insensitive)', () => {
|
||||
const filter1 = createFilter(Operand.ILIKE, 'Hello World', 'WORLD');
|
||||
const filter2 = createFilter(Operand.ILIKE, 'Hello World', 'world');
|
||||
const filter3 = createFilter(Operand.ILIKE, 'Hello', 'WORLD');
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('in operand', () => {
|
||||
it('should handle JSON array values', () => {
|
||||
const filter1 = createFilter(
|
||||
Operand.IN,
|
||||
'apple',
|
||||
'["apple", "banana", "cherry"]',
|
||||
);
|
||||
const filter2 = createFilter(
|
||||
Operand.IN,
|
||||
'grape',
|
||||
'["apple", "banana", "cherry"]',
|
||||
);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle comma-separated string values when JSON parsing fails', () => {
|
||||
const filter1 = createFilter(
|
||||
Operand.IN,
|
||||
'apple',
|
||||
'apple, banana, cherry',
|
||||
);
|
||||
const filter2 = createFilter(
|
||||
Operand.IN,
|
||||
'grape',
|
||||
'apple, banana, cherry',
|
||||
);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle comma-separated values with whitespace', () => {
|
||||
const filter = createFilter(
|
||||
Operand.IN,
|
||||
'apple',
|
||||
' apple , banana , cherry ',
|
||||
);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter] })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-array JSON values', () => {
|
||||
const filter = createFilter(Operand.IN, 'apple', '{"key": "value"}');
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('is operand', () => {
|
||||
it('should handle null checks', () => {
|
||||
const filter1 = createFilter(Operand.IS, null, 'null');
|
||||
const filter2 = createFilter(Operand.IS, undefined, 'NULL');
|
||||
const filter3 = createFilter(Operand.IS, 'value', 'null');
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle not null checks', () => {
|
||||
const filter1 = createFilter(Operand.IS, 'value', 'not null');
|
||||
const filter2 = createFilter(Operand.IS, 'value', 'NOT NULL');
|
||||
const filter3 = createFilter(Operand.IS, null, 'not null');
|
||||
const filter4 = createFilter(Operand.IS, undefined, 'not null');
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false);
|
||||
expect(evaluateFilterConditions({ filters: [filter4] })).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle exact value comparisons for non-null/not-null cases', () => {
|
||||
const filter1 = createFilter(Operand.IS, 'exact', 'exact');
|
||||
const filter2 = createFilter(Operand.IS, 'value', 'different');
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
|
||||
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases', () => {
|
||||
it('should throw error for unknown operand', () => {
|
||||
const filter = createFilter('unknown' as Operand, 'value', 'value');
|
||||
|
||||
expect(() => evaluateFilterConditions({ filters: [filter] })).toThrow(
|
||||
'Unknown operand: unknown',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple filters without groups', () => {
|
||||
it('should apply AND logic by default for multiple filters', () => {
|
||||
const filters: ResolvedFilter[] = [
|
||||
{
|
||||
id: 'filter1',
|
||||
type: 'text',
|
||||
label: 'Name Filter',
|
||||
rightOperand: 'John',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'John',
|
||||
fieldMetadataId: 'field1',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 'John',
|
||||
},
|
||||
{
|
||||
id: 'filter2',
|
||||
type: 'number',
|
||||
label: 'Age Filter',
|
||||
rightOperand: 25,
|
||||
operand: Operand.GT,
|
||||
displayValue: '25',
|
||||
fieldMetadataId: 'field2',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 30,
|
||||
},
|
||||
];
|
||||
|
||||
const result = evaluateFilterConditions({ filters });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when one filter fails in AND logic', () => {
|
||||
const filters: ResolvedFilter[] = [
|
||||
{
|
||||
id: 'filter1',
|
||||
type: 'text',
|
||||
label: 'Name Filter',
|
||||
rightOperand: 'John',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'John',
|
||||
fieldMetadataId: 'field1',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 'John',
|
||||
},
|
||||
{
|
||||
id: 'filter2',
|
||||
type: 'number',
|
||||
label: 'Age Filter',
|
||||
rightOperand: 25,
|
||||
operand: Operand.GT,
|
||||
displayValue: '25',
|
||||
fieldMetadataId: 'field2',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 20, // This will fail
|
||||
},
|
||||
];
|
||||
|
||||
const result = evaluateFilterConditions({ filters });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter groups', () => {
|
||||
describe('single group with AND logic', () => {
|
||||
it('should return true when all filters pass', () => {
|
||||
const filterGroups: FilterGroup[] = [
|
||||
{
|
||||
id: 'group1',
|
||||
logicalOperator: LogicalOperator.AND,
|
||||
},
|
||||
];
|
||||
|
||||
const filters: ResolvedFilter[] = [
|
||||
{
|
||||
id: 'filter1',
|
||||
type: 'text',
|
||||
label: 'Name Filter',
|
||||
rightOperand: 'John',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'John',
|
||||
fieldMetadataId: 'field1',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 'John',
|
||||
},
|
||||
{
|
||||
id: 'filter2',
|
||||
type: 'number',
|
||||
label: 'Age Filter',
|
||||
rightOperand: 25,
|
||||
operand: Operand.GT,
|
||||
displayValue: '25',
|
||||
fieldMetadataId: 'field2',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 30,
|
||||
},
|
||||
];
|
||||
|
||||
const result = evaluateFilterConditions({ filterGroups, filters });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when one filter fails', () => {
|
||||
const filterGroups: FilterGroup[] = [
|
||||
{
|
||||
id: 'group1',
|
||||
logicalOperator: LogicalOperator.AND,
|
||||
},
|
||||
];
|
||||
|
||||
const filters: ResolvedFilter[] = [
|
||||
{
|
||||
id: 'filter1',
|
||||
type: 'text',
|
||||
label: 'Name Filter',
|
||||
rightOperand: 'John',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'John',
|
||||
fieldMetadataId: 'field1',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 'Jane', // This will fail
|
||||
},
|
||||
{
|
||||
id: 'filter2',
|
||||
type: 'number',
|
||||
label: 'Age Filter',
|
||||
rightOperand: 25,
|
||||
operand: Operand.GT,
|
||||
displayValue: '25',
|
||||
fieldMetadataId: 'field2',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 30,
|
||||
},
|
||||
];
|
||||
|
||||
const result = evaluateFilterConditions({ filterGroups, filters });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('single group with OR logic', () => {
|
||||
it('should return true when at least one filter passes', () => {
|
||||
const filterGroups: FilterGroup[] = [
|
||||
{
|
||||
id: 'group1',
|
||||
logicalOperator: LogicalOperator.OR,
|
||||
},
|
||||
];
|
||||
|
||||
const filters: ResolvedFilter[] = [
|
||||
{
|
||||
id: 'filter1',
|
||||
type: 'text',
|
||||
label: 'Name Filter',
|
||||
rightOperand: 'John',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'John',
|
||||
fieldMetadataId: 'field1',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 'Jane', // This will fail
|
||||
},
|
||||
{
|
||||
id: 'filter2',
|
||||
type: 'number',
|
||||
label: 'Age Filter',
|
||||
rightOperand: 25,
|
||||
operand: Operand.GT,
|
||||
displayValue: '25',
|
||||
fieldMetadataId: 'field2',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 30, // This will pass
|
||||
},
|
||||
];
|
||||
|
||||
const result = evaluateFilterConditions({ filterGroups, filters });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when all filters fail', () => {
|
||||
const filterGroups: FilterGroup[] = [
|
||||
{
|
||||
id: 'group1',
|
||||
logicalOperator: LogicalOperator.OR,
|
||||
},
|
||||
];
|
||||
|
||||
const filters: ResolvedFilter[] = [
|
||||
{
|
||||
id: 'filter1',
|
||||
type: 'text',
|
||||
label: 'Name Filter',
|
||||
rightOperand: 'John',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'John',
|
||||
fieldMetadataId: 'field1',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 'Jane', // This will fail
|
||||
},
|
||||
{
|
||||
id: 'filter2',
|
||||
type: 'number',
|
||||
label: 'Age Filter',
|
||||
rightOperand: 25,
|
||||
operand: Operand.GT,
|
||||
displayValue: '25',
|
||||
fieldMetadataId: 'field2',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 20, // This will fail
|
||||
},
|
||||
];
|
||||
|
||||
const result = evaluateFilterConditions({ filterGroups, filters });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty groups', () => {
|
||||
it('should return true for empty group', () => {
|
||||
const filterGroups: FilterGroup[] = [
|
||||
{
|
||||
id: 'group1',
|
||||
logicalOperator: LogicalOperator.AND,
|
||||
},
|
||||
];
|
||||
|
||||
const result = evaluateFilterConditions({ filterGroups, filters: [] });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested groups', () => {
|
||||
it('should handle nested groups correctly', () => {
|
||||
const filterGroups: FilterGroup[] = [
|
||||
{
|
||||
id: 'group1',
|
||||
logicalOperator: LogicalOperator.AND,
|
||||
},
|
||||
{
|
||||
id: 'group2',
|
||||
logicalOperator: LogicalOperator.OR,
|
||||
parentRecordFilterGroupId: 'group1',
|
||||
positionInRecordFilterGroup: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const filters: ResolvedFilter[] = [
|
||||
// Filter in parent group
|
||||
{
|
||||
id: 'filter1',
|
||||
type: 'text',
|
||||
label: 'Name Filter',
|
||||
rightOperand: 'John',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'John',
|
||||
fieldMetadataId: 'field1',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 'John', // This will pass
|
||||
},
|
||||
// Filters in child group with OR logic
|
||||
{
|
||||
id: 'filter2',
|
||||
type: 'number',
|
||||
label: 'Age Filter',
|
||||
rightOperand: 25,
|
||||
operand: Operand.GT,
|
||||
displayValue: '25',
|
||||
fieldMetadataId: 'field2',
|
||||
recordFilterGroupId: 'group2',
|
||||
leftOperand: 20, // This will fail
|
||||
},
|
||||
{
|
||||
id: 'filter3',
|
||||
type: 'text',
|
||||
label: 'City Filter',
|
||||
rightOperand: 'NYC',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'NYC',
|
||||
fieldMetadataId: 'field3',
|
||||
recordFilterGroupId: 'group2',
|
||||
leftOperand: 'NYC', // This will pass
|
||||
},
|
||||
];
|
||||
|
||||
// Parent group uses AND: filter1 (pass) AND group2 (pass because filter3 passes)
|
||||
const result = evaluateFilterConditions({ filterGroups, filters });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple child groups with correct positioning', () => {
|
||||
const filterGroups: FilterGroup[] = [
|
||||
{
|
||||
id: 'group1',
|
||||
logicalOperator: LogicalOperator.AND,
|
||||
},
|
||||
{
|
||||
id: 'group2',
|
||||
logicalOperator: LogicalOperator.OR,
|
||||
parentRecordFilterGroupId: 'group1',
|
||||
positionInRecordFilterGroup: 1,
|
||||
},
|
||||
{
|
||||
id: 'group3',
|
||||
logicalOperator: LogicalOperator.AND,
|
||||
parentRecordFilterGroupId: 'group1',
|
||||
positionInRecordFilterGroup: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const filters: ResolvedFilter[] = [
|
||||
// Group2 filters (OR logic)
|
||||
{
|
||||
id: 'filter1',
|
||||
type: 'text',
|
||||
label: 'Name Filter',
|
||||
rightOperand: 'John',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'John',
|
||||
fieldMetadataId: 'field1',
|
||||
recordFilterGroupId: 'group2',
|
||||
leftOperand: 'Jane', // This will fail
|
||||
},
|
||||
{
|
||||
id: 'filter2',
|
||||
type: 'text',
|
||||
label: 'Status Filter',
|
||||
rightOperand: 'active',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'active',
|
||||
fieldMetadataId: 'field2',
|
||||
recordFilterGroupId: 'group2',
|
||||
leftOperand: 'active', // This will pass
|
||||
},
|
||||
// Group3 filters (AND logic)
|
||||
{
|
||||
id: 'filter3',
|
||||
type: 'number',
|
||||
label: 'Age Filter',
|
||||
rightOperand: 18,
|
||||
operand: Operand.GTE,
|
||||
displayValue: '18',
|
||||
fieldMetadataId: 'field3',
|
||||
recordFilterGroupId: 'group3',
|
||||
leftOperand: 25, // This will pass
|
||||
},
|
||||
{
|
||||
id: 'filter4',
|
||||
type: 'number',
|
||||
label: 'Score Filter',
|
||||
rightOperand: 80,
|
||||
operand: Operand.GT,
|
||||
displayValue: '80',
|
||||
fieldMetadataId: 'field4',
|
||||
recordFilterGroupId: 'group3',
|
||||
leftOperand: 85, // This will pass
|
||||
},
|
||||
];
|
||||
|
||||
// Group1 uses AND: group2 (pass) AND group3 (pass)
|
||||
const result = evaluateFilterConditions({ filterGroups, filters });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple root groups', () => {
|
||||
it('should combine multiple root groups with AND logic', () => {
|
||||
const filterGroups: FilterGroup[] = [
|
||||
{
|
||||
id: 'group1',
|
||||
logicalOperator: LogicalOperator.OR,
|
||||
},
|
||||
{
|
||||
id: 'group2',
|
||||
logicalOperator: LogicalOperator.AND,
|
||||
},
|
||||
];
|
||||
|
||||
const filters: ResolvedFilter[] = [
|
||||
// Group1 filters (OR logic)
|
||||
{
|
||||
id: 'filter1',
|
||||
type: 'text',
|
||||
label: 'Name Filter',
|
||||
rightOperand: 'John',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'John',
|
||||
fieldMetadataId: 'field1',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 'John', // This will pass
|
||||
},
|
||||
{
|
||||
id: 'filter2',
|
||||
type: 'text',
|
||||
label: 'Status Filter',
|
||||
rightOperand: 'inactive',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'inactive',
|
||||
fieldMetadataId: 'field2',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 'active', // This will fail
|
||||
},
|
||||
// Group2 filters (AND logic)
|
||||
{
|
||||
id: 'filter3',
|
||||
type: 'number',
|
||||
label: 'Age Filter',
|
||||
rightOperand: 18,
|
||||
operand: Operand.GTE,
|
||||
displayValue: '18',
|
||||
fieldMetadataId: 'field3',
|
||||
recordFilterGroupId: 'group2',
|
||||
leftOperand: 25, // This will pass
|
||||
},
|
||||
{
|
||||
id: 'filter4',
|
||||
type: 'number',
|
||||
label: 'Score Filter',
|
||||
rightOperand: 80,
|
||||
operand: Operand.GT,
|
||||
displayValue: '80',
|
||||
fieldMetadataId: 'field4',
|
||||
recordFilterGroupId: 'group2',
|
||||
leftOperand: 85, // This will pass
|
||||
},
|
||||
];
|
||||
|
||||
// Root groups combined with AND: group1 (pass) AND group2 (pass)
|
||||
const result = evaluateFilterConditions({ filterGroups, filters });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when one root group fails', () => {
|
||||
const filterGroups: FilterGroup[] = [
|
||||
{
|
||||
id: 'group1',
|
||||
logicalOperator: LogicalOperator.OR,
|
||||
},
|
||||
{
|
||||
id: 'group2',
|
||||
logicalOperator: LogicalOperator.AND,
|
||||
},
|
||||
];
|
||||
|
||||
const filters: ResolvedFilter[] = [
|
||||
// Group1 filters (OR logic) - will pass
|
||||
{
|
||||
id: 'filter1',
|
||||
type: 'text',
|
||||
label: 'Name Filter',
|
||||
rightOperand: 'John',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'John',
|
||||
fieldMetadataId: 'field1',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 'John', // This will pass
|
||||
},
|
||||
// Group2 filters (AND logic) - will fail
|
||||
{
|
||||
id: 'filter2',
|
||||
type: 'number',
|
||||
label: 'Age Filter',
|
||||
rightOperand: 30,
|
||||
operand: Operand.GT,
|
||||
displayValue: '30',
|
||||
fieldMetadataId: 'field2',
|
||||
recordFilterGroupId: 'group2',
|
||||
leftOperand: 25, // This will fail
|
||||
},
|
||||
{
|
||||
id: 'filter3',
|
||||
type: 'number',
|
||||
label: 'Score Filter',
|
||||
rightOperand: 80,
|
||||
operand: Operand.GT,
|
||||
displayValue: '80',
|
||||
fieldMetadataId: 'field3',
|
||||
recordFilterGroupId: 'group2',
|
||||
leftOperand: 85, // This will pass
|
||||
},
|
||||
];
|
||||
|
||||
// Root groups combined with AND: group1 (pass) AND group2 (fail)
|
||||
const result = evaluateFilterConditions({ filterGroups, filters });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases', () => {
|
||||
it('should throw error when filter group is not found', () => {
|
||||
const filterGroups: FilterGroup[] = [
|
||||
{
|
||||
id: 'group1',
|
||||
logicalOperator: LogicalOperator.AND,
|
||||
},
|
||||
];
|
||||
|
||||
const filters: ResolvedFilter[] = [
|
||||
{
|
||||
id: 'filter1',
|
||||
type: 'text',
|
||||
label: 'Name Filter',
|
||||
rightOperand: 'John',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'John',
|
||||
fieldMetadataId: 'field1',
|
||||
recordFilterGroupId: 'nonexistent-group',
|
||||
leftOperand: 'John',
|
||||
},
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
evaluateFilterConditions({ filterGroups, filters }),
|
||||
).toThrow('Filter group with id nonexistent-group not found');
|
||||
});
|
||||
|
||||
it('should throw error for unknown logical operator', () => {
|
||||
const filterGroups: FilterGroup[] = [
|
||||
{
|
||||
id: 'group1',
|
||||
logicalOperator: 'UNKNOWN' as LogicalOperator,
|
||||
},
|
||||
];
|
||||
|
||||
const filters: ResolvedFilter[] = [
|
||||
{
|
||||
id: 'filter1',
|
||||
type: 'text',
|
||||
label: 'Name Filter',
|
||||
rightOperand: 'John',
|
||||
operand: Operand.EQ,
|
||||
displayValue: 'John',
|
||||
fieldMetadataId: 'field1',
|
||||
recordFilterGroupId: 'group1',
|
||||
leftOperand: 'John',
|
||||
},
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
evaluateFilterConditions({ filterGroups, filters }),
|
||||
).toThrow('Unknown logical operator: UNKNOWN');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,66 +0,0 @@
|
||||
import { WorkflowStepExecutorException } from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
|
||||
import { getPreviousStepOutput } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/get-previous-step-output.util';
|
||||
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
describe('getPreviousStepOutput', () => {
|
||||
const mockSteps: WorkflowAction[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
nextStepIds: ['step2'],
|
||||
} as WorkflowAction,
|
||||
{
|
||||
id: 'step2',
|
||||
nextStepIds: ['step3'],
|
||||
} as WorkflowAction,
|
||||
{
|
||||
id: 'step3',
|
||||
nextStepIds: undefined,
|
||||
} as WorkflowAction,
|
||||
];
|
||||
|
||||
const mockContext = {
|
||||
step1: { data: 'step1 output' },
|
||||
step2: { data: 'step2 output' },
|
||||
};
|
||||
|
||||
it('should return the previous step output when valid', () => {
|
||||
const result = getPreviousStepOutput(mockSteps, 'step2', mockContext);
|
||||
|
||||
expect(result).toEqual({ data: 'step1 output' });
|
||||
});
|
||||
|
||||
it('should throw an error when there is no previous step', () => {
|
||||
expect(() =>
|
||||
getPreviousStepOutput(mockSteps, 'step1', mockContext),
|
||||
).toThrow(WorkflowStepExecutorException);
|
||||
});
|
||||
|
||||
it('should throw an error when there are multiple previous steps', () => {
|
||||
const stepsWithMultiplePrevious: WorkflowAction[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
nextStepIds: ['step3'],
|
||||
} as WorkflowAction,
|
||||
{
|
||||
id: 'step2',
|
||||
nextStepIds: ['step3'],
|
||||
} as WorkflowAction,
|
||||
{
|
||||
id: 'step3',
|
||||
nextStepIds: undefined,
|
||||
} as WorkflowAction,
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
getPreviousStepOutput(stepsWithMultiplePrevious, 'step3', mockContext),
|
||||
).toThrow(WorkflowStepExecutorException);
|
||||
});
|
||||
|
||||
it('should throw an error when previous step output is not found', () => {
|
||||
const emptyContext = {};
|
||||
|
||||
expect(() =>
|
||||
getPreviousStepOutput(mockSteps, 'step2', emptyContext),
|
||||
).toThrow(WorkflowStepExecutorException);
|
||||
});
|
||||
});
|
||||
@ -1,179 +0,0 @@
|
||||
import { FilterCondition } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/filter-condition.type';
|
||||
import { FilterOperator } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/filter-operator.type';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const applyFilter = <T extends Record<string, any>>(
|
||||
array: T[],
|
||||
filter: FilterCondition,
|
||||
): T[] => {
|
||||
return array.filter((item) => evaluateFilter(item, filter));
|
||||
};
|
||||
|
||||
const evaluateFilter = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
item: Record<string, any>,
|
||||
filter: FilterCondition,
|
||||
): boolean => {
|
||||
if ('and' in filter) {
|
||||
return (
|
||||
filter.and?.every((subFilter) => evaluateFilter(item, subFilter)) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
if ('or' in filter) {
|
||||
return (
|
||||
filter.or?.some((subFilter) => evaluateFilter(item, subFilter)) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
if ('not' in filter && filter.not) {
|
||||
return !evaluateFilter(item, filter.not);
|
||||
}
|
||||
|
||||
return Object.entries(filter).every(([field, conditions]) => {
|
||||
const value = item[field];
|
||||
|
||||
if (isOperator(conditions)) {
|
||||
return evaluateCondition(value, conditions as FilterOperator);
|
||||
}
|
||||
|
||||
if (value === undefined && !hasNullCheck(conditions as FilterCondition)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return evaluateNestedConditions(value, conditions as FilterCondition);
|
||||
});
|
||||
};
|
||||
|
||||
const hasNullCheck = (conditions: FilterCondition): boolean => {
|
||||
const operator = conditions as FilterOperator;
|
||||
|
||||
if ('is' in operator && operator.is === 'NULL') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.values(conditions).some(
|
||||
(value) =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
hasNullCheck(value as FilterCondition),
|
||||
);
|
||||
};
|
||||
|
||||
const evaluateNestedConditions = (
|
||||
value: unknown,
|
||||
conditions: FilterCondition,
|
||||
): boolean => {
|
||||
const operator = conditions as FilterOperator;
|
||||
|
||||
if ('is' in operator && operator.is === 'NULL') {
|
||||
return value === null || value === undefined;
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.entries(conditions).every(([field, nestedConditions]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const nestedValue = (value as Record<string, any>)[field];
|
||||
|
||||
if (isOperator(nestedConditions)) {
|
||||
return evaluateCondition(nestedValue, nestedConditions as FilterOperator);
|
||||
}
|
||||
|
||||
return evaluateNestedConditions(
|
||||
nestedValue,
|
||||
nestedConditions as FilterCondition,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const evaluateCondition = (value: any, condition: FilterOperator): boolean => {
|
||||
const [[operator, targetValue]] = Object.entries(condition);
|
||||
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return value === targetValue;
|
||||
|
||||
case 'ne':
|
||||
return value !== targetValue;
|
||||
|
||||
case 'gt':
|
||||
return value > targetValue;
|
||||
|
||||
case 'gte':
|
||||
return value >= targetValue;
|
||||
|
||||
case 'lt':
|
||||
return value < targetValue;
|
||||
|
||||
case 'lte':
|
||||
return value <= targetValue;
|
||||
|
||||
case 'like':
|
||||
return matchesLike(value, targetValue as string);
|
||||
|
||||
case 'ilike':
|
||||
return matchesILike(value, targetValue as string);
|
||||
|
||||
case 'in':
|
||||
if (!Array.isArray(targetValue)) return false;
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((v) => targetValue.includes(v));
|
||||
}
|
||||
|
||||
return targetValue.includes(value);
|
||||
|
||||
case 'is':
|
||||
return targetValue === 'NULL'
|
||||
? value === null || value === undefined
|
||||
: value === targetValue;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const matchesLike = (value: any, pattern: string): boolean => {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const regexPattern = pattern.replace(/%/g, '.*').replace(/_/g, '.');
|
||||
|
||||
return new RegExp(`^${regexPattern}$`).test(value);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const matchesILike = (value: any, pattern: string): boolean => {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const regexPattern = pattern.replace(/%/g, '.*').replace(/_/g, '.');
|
||||
|
||||
return new RegExp(`^${regexPattern}$`, 'i').test(value);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const isOperator = (obj: any): boolean => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'eq',
|
||||
'ne',
|
||||
'gt',
|
||||
'gte',
|
||||
'lt',
|
||||
'lte',
|
||||
'like',
|
||||
'ilike',
|
||||
'in',
|
||||
'is',
|
||||
].some((op) => op in obj);
|
||||
};
|
||||
@ -0,0 +1,155 @@
|
||||
import {
|
||||
Filter,
|
||||
FilterGroup,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type';
|
||||
|
||||
type ResolvedFilter = Omit<Filter, 'value' | 'stepOutputKey'> & {
|
||||
rightOperand: unknown;
|
||||
leftOperand: unknown;
|
||||
};
|
||||
|
||||
function evaluateFilter(filter: ResolvedFilter): boolean {
|
||||
const leftValue = filter.leftOperand;
|
||||
const rightValue = filter.rightOperand;
|
||||
|
||||
switch (filter.operand) {
|
||||
case 'eq':
|
||||
return leftValue == rightValue;
|
||||
|
||||
case 'ne':
|
||||
return leftValue != rightValue;
|
||||
|
||||
case 'gt':
|
||||
return Number(leftValue) > Number(rightValue);
|
||||
|
||||
case 'gte':
|
||||
return Number(leftValue) >= Number(rightValue);
|
||||
|
||||
case 'lt':
|
||||
return Number(leftValue) < Number(rightValue);
|
||||
|
||||
case 'lte':
|
||||
return Number(leftValue) <= Number(rightValue);
|
||||
|
||||
case 'like':
|
||||
return String(leftValue).includes(String(rightValue));
|
||||
|
||||
case 'ilike':
|
||||
return String(leftValue)
|
||||
.toLowerCase()
|
||||
.includes(String(rightValue).toLowerCase());
|
||||
|
||||
case 'in':
|
||||
try {
|
||||
const values = JSON.parse(String(rightValue));
|
||||
|
||||
return Array.isArray(values) && values.includes(leftValue);
|
||||
} catch {
|
||||
const values = String(rightValue)
|
||||
.split(',')
|
||||
.map((v) => v.trim());
|
||||
|
||||
return values.includes(String(leftValue));
|
||||
}
|
||||
|
||||
case 'is':
|
||||
if (String(rightValue).toLowerCase() === 'null') {
|
||||
return leftValue === null || leftValue === undefined;
|
||||
}
|
||||
if (String(rightValue).toLowerCase() === 'not null') {
|
||||
return leftValue !== null && leftValue !== undefined;
|
||||
}
|
||||
|
||||
return leftValue === rightValue;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown operand: ${filter.operand}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively evaluates a filter group and its children
|
||||
*/
|
||||
function evaluateFilterGroup(
|
||||
groupId: string,
|
||||
filterGroups: FilterGroup[],
|
||||
filters: ResolvedFilter[],
|
||||
): boolean {
|
||||
const group = filterGroups.find((g) => g.id === groupId);
|
||||
|
||||
if (!group) {
|
||||
throw new Error(`Filter group with id ${groupId} not found`);
|
||||
}
|
||||
|
||||
// Get all direct child groups
|
||||
const childGroups = filterGroups
|
||||
.filter((g) => g.parentRecordFilterGroupId === groupId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.positionInRecordFilterGroup || 0) -
|
||||
(b.positionInRecordFilterGroup || 0),
|
||||
);
|
||||
|
||||
const groupFilters = filters.filter((f) => f.recordFilterGroupId === groupId);
|
||||
|
||||
const filterResults = groupFilters.map((filter) => evaluateFilter(filter));
|
||||
|
||||
const childGroupResults = childGroups.map((childGroup) =>
|
||||
evaluateFilterGroup(childGroup.id, filterGroups, filters),
|
||||
);
|
||||
|
||||
const allResults = [...filterResults, ...childGroupResults];
|
||||
|
||||
if (allResults.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (group.logicalOperator) {
|
||||
case 'AND':
|
||||
return allResults.every((result) => result);
|
||||
|
||||
case 'OR':
|
||||
return allResults.some((result) => result);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown logical operator: ${group.logicalOperator}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateFilterConditions({
|
||||
filterGroups = [],
|
||||
filters = [],
|
||||
}: {
|
||||
filterGroups?: FilterGroup[];
|
||||
filters?: ResolvedFilter[];
|
||||
}): boolean {
|
||||
if (filterGroups.length === 0 && filters.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filterGroups.length > 0) {
|
||||
const groupIds = new Set(filterGroups.map((g) => g.id));
|
||||
|
||||
for (const filter of filters) {
|
||||
if (!groupIds.has(filter.recordFilterGroupId)) {
|
||||
throw new Error(
|
||||
`Filter group with id ${filter.recordFilterGroupId} not found`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootGroups = filterGroups.filter((g) => !g.parentRecordFilterGroupId);
|
||||
|
||||
if (rootGroups.length === 0 && filters.length > 0) {
|
||||
const filterResults = filters.map((filter) => evaluateFilter(filter));
|
||||
|
||||
return filterResults.every((result) => result);
|
||||
}
|
||||
|
||||
const rootResults = rootGroups.map((rootGroup) =>
|
||||
evaluateFilterGroup(rootGroup.id, filterGroups, filters),
|
||||
);
|
||||
|
||||
return rootResults.every((result) => result);
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
import {
|
||||
WorkflowStepExecutorException,
|
||||
WorkflowStepExecutorExceptionCode,
|
||||
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
|
||||
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
export const getPreviousStepOutput = (
|
||||
steps: WorkflowAction[],
|
||||
currentStepId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: Record<string, any>,
|
||||
) => {
|
||||
const previousSteps = steps.filter((step) =>
|
||||
step?.nextStepIds?.includes(currentStepId),
|
||||
);
|
||||
|
||||
if (previousSteps.length === 0) {
|
||||
throw new WorkflowStepExecutorException(
|
||||
'Filter action must have a previous step',
|
||||
WorkflowStepExecutorExceptionCode.FAILED_TO_EXECUTE_STEP,
|
||||
);
|
||||
}
|
||||
|
||||
if (previousSteps.length > 1) {
|
||||
throw new WorkflowStepExecutorException(
|
||||
'Filter action must have only one previous step',
|
||||
WorkflowStepExecutorExceptionCode.FAILED_TO_EXECUTE_STEP,
|
||||
);
|
||||
}
|
||||
|
||||
const previousStep = previousSteps[0];
|
||||
const previousStepOutput = context[previousStep.id];
|
||||
|
||||
if (!previousStepOutput) {
|
||||
throw new WorkflowStepExecutorException(
|
||||
'Previous step output not found',
|
||||
WorkflowStepExecutorExceptionCode.FAILED_TO_EXECUTE_STEP,
|
||||
);
|
||||
}
|
||||
|
||||
return previousStepOutput;
|
||||
};
|
||||
@ -69,8 +69,16 @@ describe('deleteManyObjectRecordsPermissions', () => {
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.data.deletePeople).toBeDefined();
|
||||
expect(response.body.data.deletePeople).toHaveLength(2);
|
||||
expect(response.body.data.deletePeople[0].id).toBe(personId1);
|
||||
expect(response.body.data.deletePeople[1].id).toBe(personId2);
|
||||
expect(
|
||||
response.body.data.deletePeople.some(
|
||||
(person: { id: string }) => person.id === personId1,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
response.body.data.deletePeople.some(
|
||||
(person: { id: string }) => person.id === personId2,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete multiple object records when executed by api key', async () => {
|
||||
|
||||
@ -68,7 +68,15 @@ describe('destroyManyObjectRecordsPermissions', () => {
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.data.destroyPeople).toBeDefined();
|
||||
expect(response.body.data.destroyPeople).toHaveLength(2);
|
||||
expect(response.body.data.destroyPeople[0].id).toBe(personId1);
|
||||
expect(response.body.data.destroyPeople[1].id).toBe(personId2);
|
||||
expect(
|
||||
response.body.data.destroyPeople.some(
|
||||
(person: { id: string }) => person.id === personId1,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
response.body.data.destroyPeople.some(
|
||||
(person: { id: string }) => person.id === personId2,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user