diff --git a/packages/taco/src/conditions/condition-factory.ts b/packages/taco/src/conditions/condition-factory.ts index 273cbe74..806dc981 100644 --- a/packages/taco/src/conditions/condition-factory.ts +++ b/packages/taco/src/conditions/condition-factory.ts @@ -20,6 +20,16 @@ import { CompoundConditionType, } from './compound-condition'; import { Condition, ConditionProps } from './condition'; +import { + IfThenElseCondition, + IfThenElseConditionProps, + IfThenElseConditionType, +} from './if-then-else-condition'; +import { + SequentialCondition, + SequentialConditionProps, + SequentialConditionType, +} from './sequential'; const ERR_INVALID_CONDITION_TYPE = (type: string) => `Invalid condition type: ${type}`; @@ -27,16 +37,22 @@ const ERR_INVALID_CONDITION_TYPE = (type: string) => export class ConditionFactory { public static conditionFromProps(props: ConditionProps): Condition { switch (props.conditionType) { + // Base Conditions case RpcConditionType: return new RpcCondition(props as RpcConditionProps); case TimeConditionType: return new TimeCondition(props as TimeConditionProps); case ContractConditionType: return new ContractCondition(props as ContractConditionProps); - case CompoundConditionType: - return new CompoundCondition(props as CompoundConditionProps); case JsonApiConditionType: return new JsonApiCondition(props as JsonApiConditionProps); + // Logical Conditions + case CompoundConditionType: + return new CompoundCondition(props as CompoundConditionProps); + case SequentialConditionType: + return new SequentialCondition(props as SequentialConditionProps); + case IfThenElseConditionType: + return new IfThenElseCondition(props as IfThenElseConditionProps); default: throw new Error(ERR_INVALID_CONDITION_TYPE(props.conditionType)); } diff --git a/packages/taco/src/conditions/if-then-else-condition.ts b/packages/taco/src/conditions/if-then-else-condition.ts new file mode 100644 index 00000000..79d99b49 --- /dev/null +++ b/packages/taco/src/conditions/if-then-else-condition.ts @@ -0,0 +1,22 @@ +import { Condition } from './condition'; +import { + IfThenElseConditionProps, + ifThenElseConditionSchema, + IfThenElseConditionType, +} from './schemas/if-then-else'; +import { OmitConditionType } from './shared'; + +export { + IfThenElseConditionProps, + ifThenElseConditionSchema, + IfThenElseConditionType, +} from './schemas/if-then-else'; + +export class IfThenElseCondition extends Condition { + constructor(value: OmitConditionType) { + super(ifThenElseConditionSchema, { + conditionType: IfThenElseConditionType, + ...value, + }); + } +} diff --git a/packages/taco/src/conditions/index.ts b/packages/taco/src/conditions/index.ts index 3ec09a76..0c8c3d5c 100644 --- a/packages/taco/src/conditions/index.ts +++ b/packages/taco/src/conditions/index.ts @@ -6,5 +6,6 @@ export * as condition from './condition'; export * as conditionExpr from './condition-expr'; export { ConditionFactory } from './condition-factory'; export * as context from './context'; +export * as ifThenElse from './if-then-else-condition'; export * as sequential from './sequential'; export { base, predefined }; diff --git a/packages/taco/src/conditions/multi-condition.ts b/packages/taco/src/conditions/multi-condition.ts index 542b0933..434ab812 100644 --- a/packages/taco/src/conditions/multi-condition.ts +++ b/packages/taco/src/conditions/multi-condition.ts @@ -1,13 +1,15 @@ import { CompoundConditionType } from './compound-condition'; import { ConditionProps } from './condition'; +import { IfThenElseConditionType } from './if-then-else-condition'; import { ConditionVariableProps, SequentialConditionType } from './sequential'; export const maxNestedDepth = (maxDepth: number) => - (condition: ConditionProps, currentDepth = 1) => { + (condition: ConditionProps, currentDepth = 1): boolean => { if ( condition.conditionType === CompoundConditionType || - condition.conditionType === SequentialConditionType + condition.conditionType === SequentialConditionType || + condition.conditionType === IfThenElseConditionType ) { if (currentDepth > maxDepth) { // no more multi-condition types allowed at this level @@ -18,11 +20,22 @@ export const maxNestedDepth = return condition.operands.every((child: ConditionProps) => maxNestedDepth(maxDepth)(child, currentDepth + 1), ); - } else { + } else if (condition.conditionType === SequentialConditionType) { return condition.conditionVariables.every( (child: ConditionVariableProps) => maxNestedDepth(maxDepth)(child.condition, currentDepth + 1), ); + } else { + // if-then-else condition + const ifThenElseConditions = []; + ifThenElseConditions.push(condition.ifCondition); + ifThenElseConditions.push(condition.thenCondition); + if (typeof condition.elseCondition !== 'boolean') { + ifThenElseConditions.push(condition.elseCondition); + } + return ifThenElseConditions.every((child: ConditionProps) => + maxNestedDepth(maxDepth)(child, currentDepth + 1), + ); } } diff --git a/packages/taco/src/conditions/schemas/if-then-else.ts b/packages/taco/src/conditions/schemas/if-then-else.ts new file mode 100644 index 00000000..dc90ee99 --- /dev/null +++ b/packages/taco/src/conditions/schemas/if-then-else.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +import { maxNestedDepth } from '../multi-condition'; + +import { baseConditionSchema } from './common'; +import { anyConditionSchema } from './utils'; + +export const IfThenElseConditionType = 'if-then-else'; + +export const ifThenElseConditionSchema: z.ZodSchema = z.lazy(() => + baseConditionSchema + .extend({ + conditionType: z + .literal(IfThenElseConditionType) + .default(IfThenElseConditionType), + ifCondition: anyConditionSchema, + thenCondition: anyConditionSchema, + elseCondition: z.union([anyConditionSchema, z.boolean()]), + }) + .refine( + // already at 2nd level since checking member condition + (condition) => maxNestedDepth(2)(condition.ifCondition, 2), + { + message: 'Exceeded max nested depth of 2 for multi-condition type', + path: ['ifCondition'], + }, // Max nested depth of 2 + ) + .refine( + // already at 2nd level since checking member condition + (condition) => maxNestedDepth(2)(condition.thenCondition, 2), + { + message: 'Exceeded max nested depth of 2 for multi-condition type', + path: ['thenCondition'], + }, // Max nested depth of 2 + ) + .refine( + (condition) => { + if (typeof condition.elseCondition !== 'boolean') { + // already at 2nd level since checking member condition + return maxNestedDepth(2)(condition.elseCondition, 2); + } + return true; + }, + { + message: 'Exceeded max nested depth of 2 for multi-condition type', + path: ['elseCondition'], + }, // Max nested depth of 2 + ), +); + +export type IfThenElseConditionProps = z.infer< + typeof ifThenElseConditionSchema +>; diff --git a/packages/taco/src/conditions/schemas/utils.ts b/packages/taco/src/conditions/schemas/utils.ts index c84cc8d3..4f5e919e 100644 --- a/packages/taco/src/conditions/schemas/utils.ts +++ b/packages/taco/src/conditions/schemas/utils.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { compoundConditionSchema } from '../compound-condition'; import { contractConditionSchema } from './contract'; +import { ifThenElseConditionSchema } from './if-then-else'; import { jsonApiConditionSchema } from './json-api'; import { rpcConditionSchema } from './rpc'; import { sequentialConditionSchema } from './sequential'; @@ -16,5 +17,6 @@ export const anyConditionSchema: z.ZodSchema = z.lazy(() => compoundConditionSchema, jsonApiConditionSchema, sequentialConditionSchema, + ifThenElseConditionSchema, ]), ); diff --git a/packages/taco/test/conditions/if-then-else-condition.test.ts b/packages/taco/test/conditions/if-then-else-condition.test.ts new file mode 100644 index 00000000..50897ec9 --- /dev/null +++ b/packages/taco/test/conditions/if-then-else-condition.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; + +import { + IfThenElseCondition, + ifThenElseConditionSchema, + IfThenElseConditionType, +} from '../../src/conditions/if-then-else-condition'; +import { TimeConditionType } from '../../src/conditions/schemas/time'; +import { + testCompoundConditionObj, + testContractConditionObj, + testRpcConditionObj, + testSequentialConditionObj, + testTimeConditionObj, +} from '../test-utils'; + +describe('validation', () => { + it('infers default condition type from constructor', () => { + const condition = new IfThenElseCondition({ + ifCondition: testRpcConditionObj, + thenCondition: testTimeConditionObj, + elseCondition: testContractConditionObj, + }); + expect(condition.value.conditionType).toEqual(IfThenElseConditionType); + }); + + it('validates type', () => { + const result = IfThenElseCondition.validate(ifThenElseConditionSchema, { + conditionType: TimeConditionType, + ifCondition: testRpcConditionObj, + thenCondition: testTimeConditionObj, + elseCondition: testContractConditionObj, + }); + expect(result.error).toBeDefined(); + expect(result.data).toBeUndefined(); + expect(result.error?.format()).toMatchObject({ + conditionType: { + _errors: [ + `Invalid literal value, expected "${IfThenElseConditionType}"`, + ], + }, + }); + }); + + it('accepts recursive if-then-else conditions', () => { + const nestedIfThenElseConditionObj = { + conditionType: IfThenElseConditionType, + ifCondition: testRpcConditionObj, + thenCondition: testTimeConditionObj, + elseCondition: testContractConditionObj, + }; + + const conditionObj = { + conditionType: IfThenElseConditionType, + ifCondition: testTimeConditionObj, + thenCondition: nestedIfThenElseConditionObj, + elseCondition: nestedIfThenElseConditionObj, + }; + + const result = IfThenElseCondition.validate( + ifThenElseConditionSchema, + conditionObj, + ); + expect(result.error).toBeUndefined(); + expect(result.data).toEqual({ + conditionType: IfThenElseConditionType, + ifCondition: testTimeConditionObj, + thenCondition: nestedIfThenElseConditionObj, + elseCondition: nestedIfThenElseConditionObj, + }); + }); + + it('accepts nested sequential and compound conditions', () => { + const conditionObj = { + conditionType: IfThenElseConditionType, + ifCondition: testRpcConditionObj, + thenCondition: testSequentialConditionObj, + elseCondition: testCompoundConditionObj, + }; + const result = IfThenElseCondition.validate( + ifThenElseConditionSchema, + conditionObj, + ); + expect(result.error).toBeUndefined(); + expect(result.data).toEqual({ + conditionType: IfThenElseConditionType, + ifCondition: testRpcConditionObj, + thenCondition: testSequentialConditionObj, + elseCondition: testCompoundConditionObj, + }); + }); + + it.each([true, false])( + 'accepts boolean for else condition', + (booleanValue) => { + const conditionObj = { + conditionType: IfThenElseConditionType, + ifCondition: testTimeConditionObj, + thenCondition: testRpcConditionObj, + elseCondition: booleanValue, + }; + + const result = IfThenElseCondition.validate( + ifThenElseConditionSchema, + conditionObj, + ); + expect(result.error).toBeUndefined(); + expect(result.data).toEqual({ + conditionType: IfThenElseConditionType, + ifCondition: testTimeConditionObj, + thenCondition: testRpcConditionObj, + elseCondition: booleanValue, + }); + }, + ); + + it('limits max depth of nested if condition', () => { + const result = IfThenElseCondition.validate(ifThenElseConditionSchema, { + ifCondition: { + conditionType: IfThenElseConditionType, + ifCondition: testRpcConditionObj, + thenCondition: testCompoundConditionObj, + elseCondition: testTimeConditionObj, + }, + thenCondition: testRpcConditionObj, + elseCondition: testTimeConditionObj, + }); + expect(result.error).toBeDefined(); + expect(result.data).toBeUndefined(); + expect(result.error?.format()).toMatchObject({ + ifCondition: { + _errors: [`Exceeded max nested depth of 2 for multi-condition type`], + }, + }); + }); + + it('limits max depth of nested then condition', () => { + const result = IfThenElseCondition.validate(ifThenElseConditionSchema, { + ifCondition: testRpcConditionObj, + thenCondition: { + conditionType: IfThenElseConditionType, + ifCondition: testRpcConditionObj, + thenCondition: testCompoundConditionObj, + elseCondition: true, + }, + elseCondition: testTimeConditionObj, + }); + expect(result.error).toBeDefined(); + expect(result.data).toBeUndefined(); + expect(result.error?.format()).toMatchObject({ + thenCondition: { + _errors: [`Exceeded max nested depth of 2 for multi-condition type`], + }, + }); + }); + + it('limits max depth of nested else condition', () => { + const result = IfThenElseCondition.validate(ifThenElseConditionSchema, { + ifCondition: testRpcConditionObj, + thenCondition: testTimeConditionObj, + elseCondition: { + conditionType: IfThenElseConditionType, + ifCondition: testRpcConditionObj, + thenCondition: testSequentialConditionObj, + elseCondition: testTimeConditionObj, + }, + }); + expect(result.error).toBeDefined(); + expect(result.data).toBeUndefined(); + expect(result.error?.format()).toMatchObject({ + elseCondition: { + _errors: [`Exceeded max nested depth of 2 for multi-condition type`], + }, + }); + }); +}); diff --git a/packages/taco/test/conditions/lingo.test.ts b/packages/taco/test/conditions/lingo.test.ts new file mode 100644 index 00000000..bf3c3771 --- /dev/null +++ b/packages/taco/test/conditions/lingo.test.ts @@ -0,0 +1,122 @@ +import { TEST_CHAIN_ID } from '@nucypher/test-utils'; +import { describe, expect, it } from 'vitest'; + +import { ConditionExpression } from '../../src/conditions/condition-expr'; + +describe('check that valid lingo in python is valid in typescript', () => { + const timeConditionProps = { + conditionType: 'time', + method: 'blocktime', + chain: TEST_CHAIN_ID, + returnValueTest: { value: 0, comparator: '>' }, + }; + + const contractConditionProps = { + conditionType: 'contract', + chain: TEST_CHAIN_ID, + method: 'isPolicyActive', + parameters: [':hrac'], + contractAddress: '0xA1bd3630a13D54EDF7320412B5C9F289230D260d', + functionAbi: { + type: 'function', + name: 'isPolicyActive', + stateMutability: 'view', + inputs: [ + { + name: '_policyID', + type: 'bytes16', + internalType: 'bytes16', + }, + ], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + }, + returnValueTest: { + comparator: '==', + value: true, + }, + }; + const rpcConditionProps = { + conditionType: 'rpc', + chain: TEST_CHAIN_ID, + method: 'eth_getBalance', + parameters: ['0x3d2Bed3259b165EB02A7F0D0753e7a01912A68f8', 'latest'], + returnValueTest: { + comparator: '>=', + value: 10000000000000, + }, + }; + const jsonApiConditionProps = { + conditionType: 'json-api', + endpoint: 'https://api.example.com/data', + query: '$.store.book[0].price', + parameters: { + ids: 'ethereum', + vs_currencies: 'usd', + }, + returnValueTest: { + comparator: '==', + value: 2, + }, + }; + const sequentialConditionProps = { + conditionType: 'sequential', + conditionVariables: [ + { + varName: 'timeValue', + condition: timeConditionProps, + }, + { + varName: 'rpcValue', + condition: rpcConditionProps, + }, + { + varName: 'contractValue', + condition: contractConditionProps, + }, + { + varName: 'jsonValue', + condition: jsonApiConditionProps, + }, + ], + }; + const ifThenElseConditionProps = { + conditionType: 'if-then-else', + ifCondition: rpcConditionProps, + thenCondition: jsonApiConditionProps, + elseCondition: timeConditionProps, + }; + + const compoundConditionProps = { + conditionType: 'compound', + operator: 'and', + operands: [ + contractConditionProps, + ifThenElseConditionProps, + sequentialConditionProps, + rpcConditionProps, + { + conditionType: 'compound', + operator: 'not', + operands: [timeConditionProps], + }, + ], + }; + + it.each([ + rpcConditionProps, + timeConditionProps, + contractConditionProps, + jsonApiConditionProps, + compoundConditionProps, + sequentialConditionProps, + ifThenElseConditionProps, + ])('parsing of all condition types', (conditionProps) => { + const conditionExprJSON = { + version: ConditionExpression.version, + condition: conditionProps, + }; + const conditionExpr = ConditionExpression.fromObj(conditionExprJSON); + expect(conditionExpr.toObj()).toBeDefined(); + expect(conditionExpr.condition.toObj()).toEqual(conditionProps); + }); +});