Repository navigation
Expand file tree
/
Copy pathevaluate-branches.ts
More file actions
137 lines (116 loc) · 4.42 KB
/
Copy pathevaluate-branches.ts
File metadata and controls
137 lines (116 loc) · 4.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import { type ExecutionContext, PermanentNodeExecutionError } from '@workflowbuilder/temporal';
import type { SampleComparisonOperator, SampleCondition, SampleDecisionBranch } from './nodes';
type OperandContext = Pick<ExecutionContext, 'triggerPayload' | 'nodeOutputs'>;
const REFERENCE = /\{\{([^}]*)\}\}/g;
const PATH = /^\s*(nodes|trigger)\.([\w-]+(?:\.[\w-]+)*)\s*$/;
// The editor appends new branches after "Otherwise" and cannot reorder them, so a branch with no
// rows is the fallback wherever it sits: the first branch whose rows hold wins, else the first empty one.
export function pickBranch(branches: SampleDecisionBranch[], context: OperandContext): SampleDecisionBranch {
const winner =
branches.find((branch) => branch.conditions.length > 0 && conditionsHold(branch.conditions, context)) ??
branches.find((branch) => branch.conditions.length === 0);
if (!winner) {
throw new PermanentNodeExecutionError(
'no_branch_matched',
`None of the ${branches.length} branch(es) matched. Add a branch with no conditions to catch everything else.`,
);
}
return winner;
}
function conditionsHold(conditions: SampleCondition[], context: OperandContext): boolean {
let result = true;
for (const [index, condition] of conditions.entries()) {
const holds = compare(
resolveOperand(condition.x, context),
condition.comparisonOperator,
resolveOperand(condition.y, context),
);
if (index === 0) {
result = holds;
} else {
result = condition.logicalOperator === 'OR' ? result || holds : result && holds;
}
}
return result;
}
function resolveOperand(operand: string, context: OperandContext): string {
return operand.replaceAll(REFERENCE, (reference, body: string) => {
const match = PATH.exec(body);
if (!match) {
throw new PermanentNodeExecutionError(
'operand_invalid',
`Cannot read "${reference}". Expected {{trigger.<field>}} or {{nodes.<nodeId>.<field>}}.`,
);
}
const value = valueAtPath(match[1] === 'nodes' ? context.nodeOutputs : context.triggerPayload, match[2]!);
if (value === undefined) {
throw new PermanentNodeExecutionError('operand_unresolved', `Nothing at "${reference}" in this run.`);
}
return typeof value === 'string' ? value : JSON.stringify(value);
});
}
function valueAtPath(root: unknown, path: string): unknown {
let current = root;
for (const key of path.split('.')) {
// Own keys only, or `{{trigger.constructor}}` would resolve off Object.prototype.
if (current === null || typeof current !== 'object' || !Object.hasOwn(current, key)) return undefined;
current = (current as Record<string, unknown>)[key];
}
return current;
}
function compare(left: string, operator: SampleComparisonOperator, right: string): boolean {
switch (operator) {
case 'isEqual': {
return left === right;
}
case 'isNotEqual': {
return left !== right;
}
case 'isGreaterThan': {
return toNumber(left) > toNumber(right);
}
case 'isLessThan': {
return toNumber(left) < toNumber(right);
}
case 'isGreaterThanOrEqual': {
return toNumber(left) >= toNumber(right);
}
case 'isLessThanOrEqual': {
return toNumber(left) <= toNumber(right);
}
case 'isContaining': {
return left.toLowerCase().includes(right.toLowerCase());
}
case 'isNotContaining': {
return !left.toLowerCase().includes(right.toLowerCase());
}
case 'isBefore': {
return toDate(left) < toDate(right);
}
case 'isAfter': {
return toDate(left) > toDate(right);
}
default: {
throw new PermanentNodeExecutionError(
'operator_unknown',
`Unsupported comparison operator "${String(operator)}".`,
);
}
}
}
// Number('') is 0 and NaN compares false, so either would quietly pick the wrong branch.
function toNumber(operand: string): number {
const value = Number(operand);
if (operand.trim() === '' || Number.isNaN(value)) {
throw new PermanentNodeExecutionError('operand_not_a_number', `Expected a number, got "${operand}".`);
}
return value;
}
// Date.parse gives NaN for a non-date, which would quietly pick the wrong branch for the same reason.
function toDate(operand: string): number {
const value = Date.parse(operand);
if (Number.isNaN(value)) {
throw new PermanentNodeExecutionError('operand_not_a_date', `Expected a date, got "${operand}".`);
}
return value;
}