diff --git a/api/app/core/workflow/engine/variable_pool.py b/api/app/core/workflow/engine/variable_pool.py index 08d10e22..b34efe15 100644 --- a/api/app/core/workflow/engine/variable_pool.py +++ b/api/app/core/workflow/engine/variable_pool.py @@ -201,12 +201,15 @@ class VariablePool: @staticmethod def _extract_field(struct: "VariableStruct", field: str | None) -> Any: - """If field is given, drill into a dict/object variable's value.""" + """If field is given, drill into a dict/object/array[file] variable's value.""" if field is None: return struct.instance.get_value() value = struct.instance.get_value() + # array[file]: extract the field from every element, return a list + if isinstance(value, list): + return [item.get(field) if isinstance(item, dict) else getattr(item, field, None) for item in value] if not isinstance(value, dict): - raise KeyError(f"Variable is not an object, cannot access field '{field}'") + raise KeyError(f"Variable is not an object or array, cannot access field '{field}'") return value.get(field) def get_instance( diff --git a/api/app/core/workflow/nodes/if_else/config.py b/api/app/core/workflow/nodes/if_else/config.py index 638e4b2d..302d469f 100644 --- a/api/app/core/workflow/nodes/if_else/config.py +++ b/api/app/core/workflow/nodes/if_else/config.py @@ -6,6 +6,20 @@ from app.core.workflow.nodes.base_config import BaseNodeConfig from app.core.workflow.nodes.enums import ComparisonOperator, LogicOperator, ValueInputType +class SubVariableConditionItem(BaseModel): + """A single condition on a file object's field, used inside sub_variable_condition.""" + key: str = Field(..., description="Field name of the file object, e.g. type, size, name") + operator: ComparisonOperator = Field(..., description="Comparison operator") + value: Any = Field(default=None, description="Value to compare with") + var_type: str = Field(default="string", description="Field value type: string or number") + + +class SubVariableCondition(BaseModel): + """Sub-conditions applied to each file element in an array[file] variable.""" + logical_operator: LogicOperator = Field(default=LogicOperator.AND) + conditions: list[SubVariableConditionItem] = Field(default_factory=list) + + class ConditionDetail(BaseModel): operator: ComparisonOperator = Field( ..., @@ -14,12 +28,12 @@ class ConditionDetail(BaseModel): left: str = Field( ..., - description="Value to compare against" + description="Variable selector, e.g. {{sys.files}}" ) right: Any = Field( default=None, - description="Value to compare with" + description="Value to compare with (unused when sub_variable_condition is set)" ) input_type: ValueInputType = Field( @@ -27,6 +41,11 @@ class ConditionDetail(BaseModel): description="Value input type for comparison" ) + sub_variable_condition: SubVariableCondition | None = Field( + default=None, + description="Sub-conditions for array[file] fields. When set, operator must be contains/not_contains." + ) + @field_validator("input_type", mode="before") @classmethod def lower_input_type(cls, v): @@ -39,16 +58,19 @@ class ConditionDetail(BaseModel): class ConditionBranchConfig(BaseModel): - """Configuration for a conditional branch""" + """Configuration for a conditional branch. + + logical_operator controls how all expressions are combined (AND/OR). + """ logical_operator: LogicOperator = Field( default=LogicOperator.AND, - description="Logical operator used to combine multiple condition expressions" + description="Logical operator used to combine all conditions" ) expressions: list[ConditionDetail] = Field( - ..., - description="List of condition expressions within this branch" + default_factory=list, + description="List of conditions within this branch" ) diff --git a/api/app/core/workflow/nodes/if_else/node.py b/api/app/core/workflow/nodes/if_else/node.py index ec46b20b..faecd87c 100644 --- a/api/app/core/workflow/nodes/if_else/node.py +++ b/api/app/core/workflow/nodes/if_else/node.py @@ -7,7 +7,7 @@ from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.enums import ComparisonOperator, LogicOperator, ValueInputType from app.core.workflow.nodes.if_else import IfElseNodeConfig -from app.core.workflow.nodes.operators import ConditionExpressionResolver, CompareOperatorInstance +from app.core.workflow.nodes.operators import ConditionExpressionResolver, CompareOperatorInstance, ArrayFileContainsOperator from app.core.workflow.variable.base_variable import VariableType logger = logging.getLogger(__name__) @@ -90,11 +90,9 @@ class IfElseNode(BaseNode): list[str]: A list of Python boolean expression strings, ordered by branch priority. """ - branch_index = 0 conditions = [] for case_branch in self.typed_config.cases: - branch_index += 1 branch_result = [] for expression in case_branch.expressions: pattern = r"\{\{\s*(.*?)\s*\}\}" @@ -103,13 +101,18 @@ class IfElseNode(BaseNode): left_value = self.get_variable(left_string, variable_pool) except KeyError: left_value = None - evaluator = ConditionExpressionResolver.resolve_by_value(left_value)( - variable_pool, - expression.left, - expression.right, - expression.input_type - ) + + if expression.sub_variable_condition is not None and isinstance(left_value, list): + evaluator = ArrayFileContainsOperator(left_value, expression.sub_variable_condition) + else: + evaluator = ConditionExpressionResolver.resolve_by_value(left_value)( + variable_pool, + expression.left, + expression.right, + expression.input_type + ) branch_result.append(self._evaluate(expression.operator, evaluator)) + if case_branch.logical_operator == LogicOperator.AND: conditions.append(all(branch_result)) else: diff --git a/api/app/core/workflow/nodes/operators.py b/api/app/core/workflow/nodes/operators.py index 14fc9d9f..30634bbe 100644 --- a/api/app/core/workflow/nodes/operators.py +++ b/api/app/core/workflow/nodes/operators.py @@ -395,11 +395,71 @@ class NoneObjectComparisonOperator: return lambda *args, **kwargs: False +class ArrayFileContainsOperator: + """Handles contains/not_contains on array[file] with sub_variable_condition. + + Evaluates whether any (contains) or no (not_contains) file element + in the array satisfies all sub-conditions. + """ + + def __init__(self, left_value: list[dict], sub_variable_condition: Any): + self.left_value = left_value + self.sub_variable_condition = sub_variable_condition + + def _match_item(self, file_item: dict) -> bool: + """Check if a single file dict satisfies all sub-conditions.""" + results = [] + for cond in self.sub_variable_condition.conditions: + field_val = file_item.get(cond.key) + result = self._eval_sub(field_val, cond) + results.append(result) + if self.sub_variable_condition.logical_operator.value == "and": + return all(results) + return any(results) + + @staticmethod + def _eval_sub(field_val: Any, cond: Any) -> bool: + op = cond.operator.value + expected = cond.value + if field_val is None: + return op in ("empty", "not_empty") and op == "empty" + match op: + case "eq": return str(field_val) == str(expected) + case "ne": return str(field_val) != str(expected) + case "contains": return isinstance(field_val, str) and str(expected) in field_val + case "not_contains": return isinstance(field_val, str) and str(expected) not in field_val + case "in": return field_val in (expected if isinstance(expected, list) else [expected]) + case "not_in": return field_val not in (expected if isinstance(expected, list) else [expected]) + case "gt": return isinstance(field_val, (int, float)) and field_val > float(expected) + case "ge": return isinstance(field_val, (int, float)) and field_val >= float(expected) + case "lt": return isinstance(field_val, (int, float)) and field_val < float(expected) + case "le": return isinstance(field_val, (int, float)) and field_val <= float(expected) + case "empty": return field_val in (None, "", 0) + case "not_empty": return field_val not in (None, "", 0) + case _: return False + + def contains(self) -> bool: + return any(self._match_item(f) for f in self.left_value if isinstance(f, dict)) + + def not_contains(self) -> bool: + return not self.contains() + + def empty(self) -> bool: + return not self.left_value + + def not_empty(self) -> bool: + return bool(self.left_value) + + def __getattr__(self, name): + return lambda *args, **kwargs: False + + CompareOperatorInstance = Union[ StringComparisonOperator, NumberComparisonOperator, BooleanComparisonOperator, ArrayComparisonOperator, + ArrayFileContainsOperator, ObjectComparisonOperator ] CompareOperatorType = Type[CompareOperatorInstance]