From 118dc5a07800e6d2d07b4249d252e6e9a5fe1c52 Mon Sep 17 00:00:00 2001 From: Florian Burelli Date: Wed, 7 Oct 2026 17:15:02 +0200 Subject: [PATCH] fix: do not evaluate expressions of nested activities on the branch not taken `Activity._evaluate_expressions` skips nested activities by looking for "activities" in the attribute or key name, case-sensitively. `activities` (ForEach, Switch cases) was skipped, but `ifTrueActivities`, `ifFalseActivities` and `defaultActivities` were not, so the expressions of both IfCondition branches and of the Switch default branch were evaluated eagerly in the scope of the container, before the branch was chosen. An expression that is only valid on one branch then failed the whole evaluation. Make the check case-insensitive so every nested activity list is left to the control activity, which evaluates the branch it takes in its own scope. Fixes #176 Co-Authored-By: Claude Fable 5.1 --- .../models/activities/_activity.py | 10 ++- .../pipeline/if_condition_branch.json | 61 +++++++++++++++ .../pipeline/switch_default_branch.json | 70 +++++++++++++++++ .../branch_not_taken/test_branch_not_taken.py | 78 +++++++++++++++++++ .../models/activities/base/test_activity.py | 37 +++++++++ 5 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 tests/functional/branch_not_taken/pipeline/if_condition_branch.json create mode 100644 tests/functional/branch_not_taken/pipeline/switch_default_branch.json create mode 100644 tests/functional/branch_not_taken/test_branch_not_taken.py diff --git a/src/data_factory_testing_framework/models/activities/_activity.py b/src/data_factory_testing_framework/models/activities/_activity.py index cc72dfbf..a61e1b58 100644 --- a/src/data_factory_testing_framework/models/activities/_activity.py +++ b/src/data_factory_testing_framework/models/activities/_activity.py @@ -86,7 +86,7 @@ def _evaluate_expressions( if not attribute.startswith("_") and not callable(getattr(obj, attribute)) ] for attribute_name in attribute_names: - if "activities" in attribute_name: + if self._is_nested_activities_name(attribute_name): continue attribute = getattr(obj, attribute_name) @@ -98,7 +98,7 @@ def _evaluate_expressions( # Dictionary if isinstance(obj, dict): for key in obj.keys(): - if "activities" in key: + if self._is_nested_activities_name(key): continue self._evaluate_expressions(obj[key], state, visited, types_to_ignore) @@ -116,6 +116,12 @@ def _evaluate_expressions( self._evaluate_expressions(item, state, visited, types_to_ignore) + @staticmethod + def _is_nested_activities_name(name: str) -> bool: + # Nested activities (activities, ifTrueActivities, ifFalseActivities, defaultActivities, ...) + # are evaluated by the control activity in their own scope, never eagerly with the parent. + return "activities" in name.lower() + def set_result(self, result: DependencyCondition, output: Optional[Any] = None) -> None: # noqa: ANN401 self.status = result self.output = output diff --git a/tests/functional/branch_not_taken/pipeline/if_condition_branch.json b/tests/functional/branch_not_taken/pipeline/if_condition_branch.json new file mode 100644 index 00000000..fd23d520 --- /dev/null +++ b/tests/functional/branch_not_taken/pipeline/if_condition_branch.json @@ -0,0 +1,61 @@ +{ + "name": "if_condition_branch", + "properties": { + "activities": [ + { + "name": "Has_Date", + "type": "IfCondition", + "dependsOn": [], + "userProperties": [], + "typeProperties": { + "expression": { + "value": "@empty(pipeline().parameters.date)", + "type": "Expression" + }, + "ifTrueActivities": [ + { + "name": "Set_Today", + "type": "SetVariable", + "dependsOn": [], + "userProperties": [], + "typeProperties": { + "variableName": "day", + "value": { + "value": "@formatDateTime(utcNow(), 'yyyy-MM-dd')", + "type": "Expression" + } + } + } + ], + "ifFalseActivities": [ + { + "name": "Set_Given_Date", + "type": "SetVariable", + "dependsOn": [], + "userProperties": [], + "typeProperties": { + "variableName": "day", + "value": { + "value": "@formatDateTime(pipeline().parameters.date, 'yyyy-MM-dd')", + "type": "Expression" + } + } + } + ] + } + } + ], + "parameters": { + "date": { + "type": "string", + "defaultValue": "" + } + }, + "variables": { + "day": { + "type": "String" + } + }, + "annotations": [] + } +} diff --git a/tests/functional/branch_not_taken/pipeline/switch_default_branch.json b/tests/functional/branch_not_taken/pipeline/switch_default_branch.json new file mode 100644 index 00000000..f2620a6d --- /dev/null +++ b/tests/functional/branch_not_taken/pipeline/switch_default_branch.json @@ -0,0 +1,70 @@ +{ + "name": "switch_default_branch", + "properties": { + "activities": [ + { + "name": "On_Mode", + "type": "Switch", + "dependsOn": [], + "userProperties": [], + "typeProperties": { + "on": { + "value": "@pipeline().parameters.mode", + "type": "Expression" + }, + "cases": [ + { + "value": "today", + "activities": [ + { + "name": "Set_Today", + "type": "SetVariable", + "dependsOn": [], + "userProperties": [], + "typeProperties": { + "variableName": "day", + "value": { + "value": "@formatDateTime(utcNow(), 'yyyy-MM-dd')", + "type": "Expression" + } + } + } + ] + } + ], + "defaultActivities": [ + { + "name": "Set_Given_Date", + "type": "SetVariable", + "dependsOn": [], + "userProperties": [], + "typeProperties": { + "variableName": "day", + "value": { + "value": "@formatDateTime(pipeline().parameters.date, 'yyyy-MM-dd')", + "type": "Expression" + } + } + } + ] + } + } + ], + "parameters": { + "mode": { + "type": "string", + "defaultValue": "today" + }, + "date": { + "type": "string", + "defaultValue": "" + } + }, + "variables": { + "day": { + "type": "String" + } + }, + "annotations": [] + } +} diff --git a/tests/functional/branch_not_taken/test_branch_not_taken.py b/tests/functional/branch_not_taken/test_branch_not_taken.py new file mode 100644 index 00000000..5fd85013 --- /dev/null +++ b/tests/functional/branch_not_taken/test_branch_not_taken.py @@ -0,0 +1,78 @@ +import pytest +from data_factory_testing_framework import TestFramework, TestFrameworkType +from data_factory_testing_framework.state import RunParameter, RunParameterType + + +@pytest.mark.parametrize( + "date,expected_activity_name,expected_day", + [ + ("", "Set_Today", None), + ("2024-01-31T00:00:00Z", "Set_Given_Date", "2024-01-31"), + ], +) +def test_if_condition_only_evaluates_the_branch_taken( + date: str, + expected_activity_name: str, + expected_day: str, + request: pytest.FixtureRequest, +) -> None: + # Arrange + test_framework = TestFramework( + framework_type=TestFrameworkType.DataFactory, + root_folder_path=request.fspath.dirname, + ) + pipeline = test_framework.get_pipeline_by_name("if_condition_branch") + + # Act + activities = test_framework.evaluate_pipeline( + pipeline, + [ + RunParameter(RunParameterType.Pipeline, "date", date), + ], + ) + + # Assert + activity = next(activities) + assert activity.name == expected_activity_name + if expected_day is not None: + assert activity.type_properties["value"].result == expected_day + + with pytest.raises(StopIteration): + next(activities) + + +@pytest.mark.parametrize( + "mode,date,expected_activity_name", + [ + ("today", "", "Set_Today"), + ("given", "2024-01-31T00:00:00Z", "Set_Given_Date"), + ], +) +def test_switch_only_evaluates_the_case_taken( + mode: str, + date: str, + expected_activity_name: str, + request: pytest.FixtureRequest, +) -> None: + # Arrange + test_framework = TestFramework( + framework_type=TestFrameworkType.DataFactory, + root_folder_path=request.fspath.dirname, + ) + pipeline = test_framework.get_pipeline_by_name("switch_default_branch") + + # Act + activities = test_framework.evaluate_pipeline( + pipeline, + [ + RunParameter(RunParameterType.Pipeline, "mode", mode), + RunParameter(RunParameterType.Pipeline, "date", date), + ], + ) + + # Assert + activity = next(activities) + assert activity.name == expected_activity_name + + with pytest.raises(StopIteration): + next(activities) diff --git a/tests/unit/models/activities/base/test_activity.py b/tests/unit/models/activities/base/test_activity.py index 9eb080f6..5c951735 100644 --- a/tests/unit/models/activities/base/test_activity.py +++ b/tests/unit/models/activities/base/test_activity.py @@ -105,3 +105,40 @@ def test_evaluate_is_evaluating_expressions_inside_dict() -> None: # Assert assert pipeline_activity.type_properties["parameters"]["url"].result == "example.com" + + +@pytest.mark.parametrize( + "nested_activities_key", + ["activities", "ifTrueActivities", "ifFalseActivities", "defaultActivities"], +) +def test_evaluate_does_not_evaluate_expressions_of_nested_activities(nested_activities_key: str) -> None: + # Arrange + activity = Activity( + name="activity", + type="Container", + typeProperties={ + "url": DataFactoryElement("@pipeline().parameters.url"), + nested_activities_key: [ + { + "name": "nested", + "type": "SetVariable", + "typeProperties": { + "variableName": "variable", + "value": DataFactoryElement("@pipeline().parameters.not_in_scope"), + }, + }, + ], + }, + ) + state = PipelineRunState( + parameters=[ + RunParameter(RunParameterType.Pipeline, "url", "example.com"), + ], + ) + + # Act + activity.evaluate(state) + + # Assert + assert activity.type_properties["url"].result == "example.com" + assert activity.type_properties[nested_activities_key][0]["typeProperties"]["value"].result is None