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:
Thomas Trompette
2025-07-02 17:29:52 +02:00
committed by GitHub
parent 9f0b8809cb
commit b59235409e
11 changed files with 1036 additions and 643 deletions

View File

@ -585,7 +585,8 @@ export class WorkflowVersionStepWorkspaceService {
settings: {
...BASE_STEP_DEFINITION,
input: {
filter: {},
filterGroups: [],
filters: [],
},
},
};

View File

@ -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,
},
};
}
}

View File

@ -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[];
};
};

View File

@ -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);
});
});
});

View File

@ -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');
});
});
});
});

View File

@ -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);
});
});

View File

@ -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);
};

View File

@ -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);
}

View File

@ -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;
};

View File

@ -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 () => {

View File

@ -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);
});
});