Browse Source

Add datasource support to conditional variables (#6270)

pull/6278/head
mohamed yahia 1 year ago
committed by GitHub
parent
commit
e43dcfcff7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      packages/core/src/data_sources/model/DataVariableListenerManager.ts
  2. 106
      packages/core/src/data_sources/model/conditional_variables/Condition.ts
  3. 59
      packages/core/src/data_sources/model/conditional_variables/DataCondition.ts
  4. 15
      packages/core/src/data_sources/model/conditional_variables/LogicalGroupStatement.ts
  5. 15
      packages/core/src/data_sources/model/utils.ts
  6. 177
      packages/core/test/specs/data_sources/model/conditional_variables/DataCondition.ts

4
packages/core/src/data_sources/model/DataVariableListenerManager.ts

@ -71,4 +71,8 @@ export default class DynamicVariableListenerManager {
this.dataListeners.forEach((ls) => model.stopListening(ls.obj, ls.event, this.onChange));
this.dataListeners = [];
}
destroy() {
this.removeListeners();
}
}

106
packages/core/src/data_sources/model/conditional_variables/Condition.ts

@ -0,0 +1,106 @@
import EditorModel from '../../../editor/model/Editor';
import DataVariable from '../DataVariable';
import { evaluateVariable, isDataVariable } from '../utils';
import { Expression, LogicGroup } from './DataCondition';
import { LogicalGroupStatement } from './LogicalGroupStatement';
import { Operator } from './operators';
import { GenericOperation, GenericOperator } from './operators/GenericOperator';
import { LogicalOperator } from './operators/LogicalOperator';
import { NumberOperator, NumberOperation } from './operators/NumberOperator';
import { StringOperator, StringOperation } from './operators/StringOperations';
export class Condition {
private condition: Expression | LogicGroup | boolean;
private em: EditorModel;
constructor(condition: Expression | LogicGroup | boolean, opts: { em: EditorModel }) {
this.condition = condition;
this.em = opts.em;
}
evaluate(): boolean {
return this.evaluateCondition(this.condition);
}
/**
* Recursively evaluates conditions and logic groups.
*/
private evaluateCondition(condition: any): boolean {
if (typeof condition === 'boolean') return condition;
if (this.isLogicGroup(condition)) {
const { logicalOperator, statements } = condition;
const operator = new LogicalOperator(logicalOperator);
const logicalGroup = new LogicalGroupStatement(operator, statements, { em: this.em });
return logicalGroup.evaluate();
}
if (this.isExpression(condition)) {
const { left, operator, right } = condition;
const evaluateLeft = evaluateVariable(left, this.em);
const evaluateRight = evaluateVariable(right, this.em);
const op = this.getOperator(evaluateLeft, operator);
const evaluated = op.evaluate(evaluateLeft, evaluateRight);
return evaluated;
}
throw new Error('Invalid condition type.');
}
/**
* Factory method for creating operators based on the data type.
*/
private getOperator(left: any, operator: string): Operator {
if (this.isOperatorInEnum(operator, GenericOperation)) {
return new GenericOperator(operator as GenericOperation);
} else if (typeof left === 'number') {
return new NumberOperator(operator as NumberOperation);
} else if (typeof left === 'string') {
return new StringOperator(operator as StringOperation);
}
throw new Error(`Unsupported data type: ${typeof left}`);
}
/**
* Extracts all data variables from the condition, including nested ones.
*/
getDataVariables(): DataVariable[] {
const variables: DataVariable[] = [];
this.extractVariables(this.condition, variables);
return variables;
}
/**
* Recursively extracts variables from expressions or logic groups.
*/
private extractVariables(condition: boolean | LogicGroup | Expression, variables: DataVariable[]): void {
if (this.isExpression(condition)) {
if (isDataVariable(condition.left)) variables.push(condition.left);
if (isDataVariable(condition.right)) variables.push(condition.right);
} else if (this.isLogicGroup(condition)) {
condition.statements.forEach((stmt) => this.extractVariables(stmt, variables));
}
}
/**
* Checks if a condition is a LogicGroup.
*/
private isLogicGroup(condition: any): condition is LogicGroup {
return condition && typeof condition.logicalOperator !== 'undefined' && Array.isArray(condition.statements);
}
/**
* Checks if a condition is an Expression.
*/
private isExpression(condition: any): condition is Expression {
return condition && typeof condition.left !== 'undefined' && typeof condition.operator === 'string';
}
/**
* Checks if an operator exists in a specific enum.
*/
private isOperatorInEnum(operator: string, enumObject: any): boolean {
return Object.values(enumObject).includes(operator);
}
}

59
packages/core/src/data_sources/model/conditional_variables/DataCondition.ts

@ -3,9 +3,13 @@ import { StringOperation } from './operators/StringOperations';
import { GenericOperation } from './operators/GenericOperator';
import { Model } from '../../../common';
import { LogicalOperation } from './operators/LogicalOperator';
import { evaluateCondition } from './evaluateCondition';
import DynamicVariableListenerManager from '../DataVariableListenerManager';
import EditorModel from '../../../editor/model/Editor';
import { Condition } from './Condition';
import DataVariable from '../DataVariable';
import { evaluateVariable, isDataVariable } from '../utils';
export const ConditionalVariableType = 'conditional-variable';
export const DataConditionType = 'conditional-variable';
export type Expression = {
left: any;
operator: GenericOperation | StringOperation | NumberOperation;
@ -19,32 +23,75 @@ export type LogicGroup = {
export class DataCondition extends Model {
private conditionResult: boolean;
private condition: Condition;
private em: EditorModel;
private variableListeners: DynamicVariableListenerManager[] = [];
defaults() {
return {
type: ConditionalVariableType,
type: DataConditionType,
condition: false,
};
}
constructor(
private condition: Expression | LogicGroup | boolean,
condition: Expression | LogicGroup | boolean,
private ifTrue: any,
private ifFalse: any,
opts: { em: EditorModel },
) {
super();
this.condition = new Condition(condition, { em: opts.em });
this.em = opts.em;
this.conditionResult = this.evaluate();
this.listenToDataVariables();
}
evaluate() {
return evaluateCondition(this.condition);
return this.condition.evaluate();
}
getDataValue(): any {
return this.conditionResult ? this.ifTrue : this.ifFalse;
return this.conditionResult ? evaluateVariable(this.ifTrue, this.em) : evaluateVariable(this.ifFalse, this.em);
}
reevaluate(): void {
this.conditionResult = this.evaluate();
}
toJSON() {
return {
condition: this.condition,
ifTrue: this.ifTrue,
ifFalse: this.ifFalse,
};
}
private listenToDataVariables() {
if (!this.em) return;
// Clear previous listeners to avoid memory leaks
this.cleanupListeners();
const dataVariables = this.condition.getDataVariables();
if (isDataVariable(this.ifTrue)) dataVariables.push(this.ifTrue);
if (isDataVariable(this.ifFalse)) dataVariables.push(this.ifFalse);
dataVariables.forEach((variable) => {
const variableInstance = new DataVariable(variable, { em: this.em });
const listener = new DynamicVariableListenerManager({
model: this as any,
em: this.em!,
dataVariable: variableInstance,
updateValueFromDataVariable: this.reevaluate.bind(this),
});
this.variableListeners.push(listener);
});
}
private cleanupListeners() {
this.variableListeners.forEach((listener) => listener.destroy());
this.variableListeners = [];
}
}

15
packages/core/src/data_sources/model/conditional_variables/LogicalGroupStatement.ts

@ -1,15 +1,24 @@
import { LogicalOperator } from './operators/LogicalOperator';
import { Expression, LogicGroup } from './DataCondition';
import { evaluateCondition } from './evaluateCondition';
import { Condition } from './Condition';
import EditorModel from '../../../editor/model/Editor';
export class LogicalGroupStatement {
private em: EditorModel;
constructor(
private operator: LogicalOperator,
private statements: (Expression | LogicGroup | boolean)[],
) {}
opts: { em: EditorModel },
) {
this.em = opts.em;
}
evaluate(): boolean {
const results = this.statements.map((statement) => evaluateCondition(statement));
const results = this.statements.map((statement) => {
const condition = new Condition(statement, { em: this.em });
return condition.evaluate();
});
return this.operator.evaluate(results);
}
}

15
packages/core/src/data_sources/model/utils.ts

@ -0,0 +1,15 @@
import EditorModel from '../../editor/model/Editor';
import { DataConditionType } from './conditional_variables/DataCondition';
import DataVariable, { DataVariableType } from './DataVariable';
export function isDataVariable(variable: any) {
return variable?.type === DataVariableType;
}
export function isDataCondition(variable: any) {
return variable?.type === DataConditionType;
}
export function evaluateVariable(variable: any, em: EditorModel) {
return isDataVariable(variable) ? new DataVariable(variable, { em }).getDataValue() : variable;
}

177
packages/core/test/specs/data_sources/model/conditional_variables/DataCondition.ts

@ -1,3 +1,4 @@
import { DataSourceManager } from '../../../../../src';
import {
DataCondition,
Expression,
@ -7,19 +8,43 @@ import { GenericOperation } from '../../../../../src/data_sources/model/conditio
import { LogicalOperation } from '../../../../../src/data_sources/model/conditional_variables/operators/LogicalOperator';
import { NumberOperation } from '../../../../../src/data_sources/model/conditional_variables/operators/NumberOperator';
import { StringOperation } from '../../../../../src/data_sources/model/conditional_variables/operators/StringOperations';
import { DataVariableType } from '../../../../../src/data_sources/model/DataVariable';
import { DataSourceProps } from '../../../../../src/data_sources/types';
import Editor from '../../../../../src/editor/model/Editor';
import EditorModel from '../../../../../src/editor/model/Editor';
describe('DataCondition', () => {
let em: EditorModel;
let dsm: DataSourceManager;
const dataSource: DataSourceProps = {
id: 'USER_STATUS_SOURCE',
records: [
{ id: 'USER_1', age: 25, status: 'active' },
{ id: 'USER_2', age: 12, status: 'inactive' },
],
};
beforeEach(() => {
em = new Editor();
dsm = em.DataSources;
dsm.add(dataSource);
});
afterEach(() => {
em.destroy();
});
describe('Basic Functionality Tests', () => {
test('should evaluate a simple boolean condition', () => {
const condition = true;
const dataCondition = new DataCondition(condition, 'Yes', 'No');
const dataCondition = new DataCondition(condition, 'Yes', 'No', { em });
expect(dataCondition.getDataValue()).toBe('Yes');
});
test('should return ifFalse when condition evaluates to false', () => {
const condition = false;
const dataCondition = new DataCondition(condition, 'Yes', 'No');
const dataCondition = new DataCondition(condition, 'Yes', 'No', { em });
expect(dataCondition.getDataValue()).toBe('No');
});
@ -28,7 +53,7 @@ describe('DataCondition', () => {
describe('Operator Tests', () => {
test('should evaluate using GenericOperation operators', () => {
const condition: Expression = { left: 5, operator: GenericOperation.equals, right: 5 };
const dataCondition = new DataCondition(condition, 'Equal', 'Not Equal');
const dataCondition = new DataCondition(condition, 'Equal', 'Not Equal', { em });
expect(dataCondition.getDataValue()).toBe('Equal');
});
@ -39,20 +64,20 @@ describe('DataCondition', () => {
operator: GenericOperation.equals,
right: 'world',
};
const dataCondition = new DataCondition(condition, 'true', 'false');
const dataCondition = new DataCondition(condition, 'true', 'false', { em });
expect(dataCondition.evaluate()).toBe(false);
});
test('should evaluate using StringOperation operators', () => {
const condition: Expression = { left: 'apple', operator: StringOperation.contains, right: 'app' };
const dataCondition = new DataCondition(condition, 'Contains', "Doesn't contain");
const dataCondition = new DataCondition(condition, 'Contains', "Doesn't contain", { em });
expect(dataCondition.getDataValue()).toBe('Contains');
});
test('should evaluate using NumberOperation operators', () => {
const condition: Expression = { left: 10, operator: NumberOperation.lessThan, right: 15 };
const dataCondition = new DataCondition(condition, 'Valid', 'Invalid');
const dataCondition = new DataCondition(condition, 'Valid', 'Invalid', { em });
expect(dataCondition.getDataValue()).toBe('Valid');
});
@ -66,7 +91,7 @@ describe('DataCondition', () => {
],
};
const dataCondition = new DataCondition(logicGroup, 'Pass', 'Fail');
const dataCondition = new DataCondition(logicGroup, 'Pass', 'Fail', { em });
expect(dataCondition.getDataValue()).toBe('Pass');
});
});
@ -74,7 +99,7 @@ describe('DataCondition', () => {
describe('Edge Case Tests', () => {
test('should throw error for invalid condition type', () => {
const invalidCondition: any = { randomField: 'randomValue' };
expect(() => new DataCondition(invalidCondition, 'Yes', 'No')).toThrow('Invalid condition type.');
expect(() => new DataCondition(invalidCondition, 'Yes', 'No', { em })).toThrow('Invalid condition type.');
});
test('should evaluate complex nested conditions', () => {
@ -92,7 +117,7 @@ describe('DataCondition', () => {
],
};
const dataCondition = new DataCondition(nestedLogicGroup, 'Nested Pass', 'Nested Fail');
const dataCondition = new DataCondition(nestedLogicGroup, 'Nested Pass', 'Nested Fail', { em });
expect(dataCondition.getDataValue()).toBe('Nested Pass');
});
});
@ -107,7 +132,7 @@ describe('DataCondition', () => {
],
};
const dataCondition = new DataCondition(logicGroup, 'All true', 'One or more false');
const dataCondition = new DataCondition(logicGroup, 'All true', 'One or more false', { em });
expect(dataCondition.getDataValue()).toBe('All true');
});
@ -120,7 +145,7 @@ describe('DataCondition', () => {
],
};
const dataCondition = new DataCondition(logicGroup, 'At least one true', 'All false');
const dataCondition = new DataCondition(logicGroup, 'At least one true', 'All false', { em });
expect(dataCondition.getDataValue()).toBe('At least one true');
});
@ -134,7 +159,7 @@ describe('DataCondition', () => {
],
};
const dataCondition = new DataCondition(logicGroup, 'Exactly one true', 'Multiple true or all false');
const dataCondition = new DataCondition(logicGroup, 'Exactly one true', 'Multiple true or all false', { em });
expect(dataCondition.getDataValue()).toBe('Exactly one true');
});
@ -153,7 +178,7 @@ describe('DataCondition', () => {
],
};
const dataCondition = new DataCondition(logicGroup, 'All true', 'One or more false');
const dataCondition = new DataCondition(logicGroup, 'All true', 'One or more false', { em });
expect(dataCondition.getDataValue()).toBe('All true');
});
@ -167,8 +192,132 @@ describe('DataCondition', () => {
],
};
const dataCondition = new DataCondition(logicGroup, 'All true', 'One or more false');
const dataCondition = new DataCondition(logicGroup, 'All true', 'One or more false', { em });
expect(dataCondition.getDataValue()).toBe('One or more false');
});
});
describe('Conditions with dataVariables', () => {
test('should return "Yes" when dataVariable matches expected value', () => {
const condition: Expression = {
left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.status' },
operator: GenericOperation.equals,
right: 'active',
};
const dataCondition = new DataCondition(condition, 'Yes', 'No', { em });
expect(dataCondition.getDataValue()).toBe('Yes');
});
test('should return "No" when dataVariable does not match expected value', () => {
const condition: Expression = {
left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.status' },
operator: GenericOperation.equals,
right: 'inactive',
};
const dataCondition = new DataCondition(condition, 'Yes', 'No', { em });
expect(dataCondition.getDataValue()).toBe('No');
});
// TODO: unskip after adding UndefinedOperator
test.skip('should handle missing data variable gracefully', () => {
const condition: Expression = {
left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.not_a_user.status' },
operator: GenericOperation.isDefined,
right: undefined,
};
const dataCondition = new DataCondition(condition, 'Found', 'Not Found', { em });
expect(dataCondition.getDataValue()).toBe('Not Found');
});
test('should correctly compare numeric values from dataVariables', () => {
const condition: Expression = {
left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.age' },
operator: NumberOperation.greaterThan,
right: 24,
};
const dataCondition = new DataCondition(condition, 'Valid', 'Invalid', { em });
expect(dataCondition.getDataValue()).toBe('Valid');
});
test('should evaluate logical operators with multiple data sources', () => {
const dataSource2: DataSourceProps = {
id: 'SECOND_DATASOURCE_ID',
records: [{ id: 'RECORD_2', status: 'active', age: 22 }],
};
dsm.add(dataSource2);
const logicGroup: LogicGroup = {
logicalOperator: LogicalOperation.and,
statements: [
{
left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.status' },
operator: GenericOperation.equals,
right: 'active',
},
{
left: { type: DataVariableType, path: 'SECOND_DATASOURCE_ID.RECORD_2.age' },
operator: NumberOperation.greaterThan,
right: 18,
},
],
};
const dataCondition = new DataCondition(logicGroup, 'All conditions met', 'Some conditions failed', { em });
expect(dataCondition.getDataValue()).toBe('All conditions met');
});
test('should handle nested logical conditions with data variables', () => {
const logicGroup: LogicGroup = {
logicalOperator: LogicalOperation.or,
statements: [
{
logicalOperator: LogicalOperation.and,
statements: [
{
left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_2.status' },
operator: GenericOperation.equals,
right: 'inactive',
},
{
left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_2.age' },
operator: NumberOperation.lessThan,
right: 14,
},
],
},
{
left: { type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.status' },
operator: GenericOperation.equals,
right: 'inactive',
},
],
};
const dataCondition = new DataCondition(logicGroup, 'Condition met', 'Condition failed', { em });
expect(dataCondition.getDataValue()).toBe('Condition met');
});
test('should handle data variables as an ifTrue return value', () => {
const dataCondition = new DataCondition(
true,
{ type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.status' },
'No',
{ em },
);
expect(dataCondition.getDataValue()).toBe('active');
});
test('should handle data variables as an ifFalse return value', () => {
const dataCondition = new DataCondition(
false,
'Yes',
{ type: DataVariableType, path: 'USER_STATUS_SOURCE.USER_1.status' },
{ em },
);
expect(dataCondition.getDataValue()).toBe('active');
});
});
});

Loading…
Cancel
Save