diff --git a/api/core/workflow/nodes/node_factory.py b/api/core/workflow/nodes/node_factory.py index 1ba049425982b6..d03862a73182bd 100644 --- a/api/core/workflow/nodes/node_factory.py +++ b/api/core/workflow/nodes/node_factory.py @@ -11,6 +11,11 @@ from core.workflow.nodes.base.node import Node from core.workflow.nodes.code.code_node import CodeNode from core.workflow.nodes.code.limits import CodeNodeLimits +from core.workflow.nodes.template_transform.template_renderer import ( + CodeExecutorJinja2TemplateRenderer, + Jinja2TemplateRenderer, +) +from core.workflow.nodes.template_transform.template_transform_node import TemplateTransformNode from libs.typing import is_str, is_str_dict from .node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING @@ -37,6 +42,7 @@ def __init__( code_executor: type[CodeExecutor] | None = None, code_providers: Sequence[type[CodeNodeProvider]] | None = None, code_limits: CodeNodeLimits | None = None, + template_renderer: Jinja2TemplateRenderer | None = None, ) -> None: self.graph_init_params = graph_init_params self.graph_runtime_state = graph_runtime_state @@ -54,6 +60,7 @@ def __init__( max_string_array_length=dify_config.CODE_MAX_STRING_ARRAY_LENGTH, max_object_array_length=dify_config.CODE_MAX_OBJECT_ARRAY_LENGTH, ) + self._template_renderer = template_renderer or CodeExecutorJinja2TemplateRenderer(code_executor) @override def create_node(self, node_config: dict[str, object]) -> Node: @@ -107,6 +114,15 @@ def create_node(self, node_config: dict[str, object]) -> Node: code_limits=self._code_limits, ) + if node_type == NodeType.TEMPLATE_TRANSFORM: + return TemplateTransformNode( + id=node_id, + config=node_config, + graph_init_params=self.graph_init_params, + graph_runtime_state=self.graph_runtime_state, + template_renderer=self._template_renderer, + ) + return node_class( id=node_id, config=node_config, diff --git a/api/core/workflow/nodes/template_transform/template_renderer.py b/api/core/workflow/nodes/template_transform/template_renderer.py new file mode 100644 index 00000000000000..404e52f5d7922b --- /dev/null +++ b/api/core/workflow/nodes/template_transform/template_renderer.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Protocol + +from core.helper.code_executor.code_executor import CodeExecutionError, CodeExecutor, CodeLanguage + + +class TemplateRenderError(ValueError): + """Raised when rendering a Jinja2 template fails.""" + + +class Jinja2TemplateRenderer(Protocol): + """Render Jinja2 templates for template transform nodes.""" + + def render_template(self, template: str, variables: Mapping[str, Any]) -> str: + """Render a Jinja2 template with provided variables.""" + raise NotImplementedError + + +class CodeExecutorJinja2TemplateRenderer(Jinja2TemplateRenderer): + """Adapter that renders Jinja2 templates via CodeExecutor.""" + + _code_executor: type[CodeExecutor] + + def __init__(self, code_executor: type[CodeExecutor] | None = None) -> None: + self._code_executor = code_executor or CodeExecutor + + def render_template(self, template: str, variables: Mapping[str, Any]) -> str: + try: + result = self._code_executor.execute_workflow_code_template( + language=CodeLanguage.JINJA2, code=template, inputs=variables + ) + except CodeExecutionError as exc: + raise TemplateRenderError(str(exc)) from exc + + rendered = result.get("result") + if rendered is not None and not isinstance(rendered, str): + raise TemplateRenderError("Template render result must be a string.") + return rendered diff --git a/api/core/workflow/nodes/template_transform/template_transform_node.py b/api/core/workflow/nodes/template_transform/template_transform_node.py index 22743239605c0f..8cfd3e4e22f01b 100644 --- a/api/core/workflow/nodes/template_transform/template_transform_node.py +++ b/api/core/workflow/nodes/template_transform/template_transform_node.py @@ -1,18 +1,44 @@ from collections.abc import Mapping, Sequence -from typing import Any +from typing import TYPE_CHECKING, Any from configs import dify_config -from core.helper.code_executor.code_executor import CodeExecutionError, CodeExecutor, CodeLanguage from core.workflow.enums import NodeType, WorkflowNodeExecutionStatus from core.workflow.node_events import NodeRunResult from core.workflow.nodes.base.node import Node from core.workflow.nodes.template_transform.entities import TemplateTransformNodeData +from core.workflow.nodes.template_transform.template_renderer import ( + CodeExecutorJinja2TemplateRenderer, + Jinja2TemplateRenderer, + TemplateRenderError, +) + +if TYPE_CHECKING: + from core.workflow.entities import GraphInitParams + from core.workflow.runtime import GraphRuntimeState MAX_TEMPLATE_TRANSFORM_OUTPUT_LENGTH = dify_config.TEMPLATE_TRANSFORM_MAX_LENGTH class TemplateTransformNode(Node[TemplateTransformNodeData]): node_type = NodeType.TEMPLATE_TRANSFORM + _template_renderer: Jinja2TemplateRenderer + + def __init__( + self, + id: str, + config: Mapping[str, Any], + graph_init_params: "GraphInitParams", + graph_runtime_state: "GraphRuntimeState", + *, + template_renderer: Jinja2TemplateRenderer | None = None, + ) -> None: + super().__init__( + id=id, + config=config, + graph_init_params=graph_init_params, + graph_runtime_state=graph_runtime_state, + ) + self._template_renderer = template_renderer or CodeExecutorJinja2TemplateRenderer() @classmethod def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]: @@ -39,21 +65,18 @@ def _run(self) -> NodeRunResult: variables[variable_name] = value.to_object() if value else None # Run code try: - result = CodeExecutor.execute_workflow_code_template( - language=CodeLanguage.JINJA2, code=self.node_data.template, inputs=variables - ) - except CodeExecutionError as e: + rendered = self._template_renderer.render_template(self.node_data.template, variables) + if len(rendered) > MAX_TEMPLATE_TRANSFORM_OUTPUT_LENGTH: + return NodeRunResult( + inputs=variables, + status=WorkflowNodeExecutionStatus.FAILED, + error=f"Output length exceeds {MAX_TEMPLATE_TRANSFORM_OUTPUT_LENGTH} characters", + ) + except TemplateRenderError as e: return NodeRunResult(inputs=variables, status=WorkflowNodeExecutionStatus.FAILED, error=str(e)) - if len(result["result"]) > MAX_TEMPLATE_TRANSFORM_OUTPUT_LENGTH: - return NodeRunResult( - inputs=variables, - status=WorkflowNodeExecutionStatus.FAILED, - error=f"Output length exceeds {MAX_TEMPLATE_TRANSFORM_OUTPUT_LENGTH} characters", - ) - return NodeRunResult( - status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=variables, outputs={"output": result["result"]} + status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=variables, outputs={"output": rendered} ) @classmethod diff --git a/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py b/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py index 1a67d5c3e3d918..11072fd0c271bf 100644 --- a/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py +++ b/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py @@ -5,8 +5,8 @@ from core.workflow.graph_engine.entities.graph_init_params import GraphInitParams from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState -from core.helper.code_executor.code_executor import CodeExecutionError from core.workflow.enums import ErrorStrategy, NodeType, WorkflowNodeExecutionStatus +from core.workflow.nodes.template_transform.template_renderer import TemplateRenderError from core.workflow.nodes.template_transform.template_transform_node import TemplateTransformNode from models.workflow import WorkflowType @@ -127,7 +127,9 @@ def test_version(self): """Test version class method.""" assert TemplateTransformNode.version() == "1" - @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) def test_run_simple_template( self, mock_execute, basic_node_data, mock_graph, mock_graph_runtime_state, graph_init_params ): @@ -145,7 +147,7 @@ def test_run_simple_template( mock_graph_runtime_state.variable_pool.get.side_effect = lambda selector: variable_map.get(tuple(selector)) # Setup mock executor - mock_execute.return_value = {"result": "Hello Alice, you are 30 years old!"} + mock_execute.return_value = "Hello Alice, you are 30 years old!" node = TemplateTransformNode( id="test_node", @@ -162,7 +164,9 @@ def test_run_simple_template( assert result.inputs["name"] == "Alice" assert result.inputs["age"] == 30 - @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) def test_run_with_none_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): """Test _run with None variable values.""" node_data = { @@ -172,7 +176,7 @@ def test_run_with_none_values(self, mock_execute, mock_graph, mock_graph_runtime } mock_graph_runtime_state.variable_pool.get.return_value = None - mock_execute.return_value = {"result": "Value: "} + mock_execute.return_value = "Value: " node = TemplateTransformNode( id="test_node", @@ -187,13 +191,15 @@ def test_run_with_none_values(self, mock_execute, mock_graph, mock_graph_runtime assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.inputs["value"] is None - @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) def test_run_with_code_execution_error( self, mock_execute, basic_node_data, mock_graph, mock_graph_runtime_state, graph_init_params ): """Test _run when code execution fails.""" mock_graph_runtime_state.variable_pool.get.return_value = MagicMock() - mock_execute.side_effect = CodeExecutionError("Template syntax error") + mock_execute.side_effect = TemplateRenderError("Template syntax error") node = TemplateTransformNode( id="test_node", @@ -208,14 +214,16 @@ def test_run_with_code_execution_error( assert result.status == WorkflowNodeExecutionStatus.FAILED assert "Template syntax error" in result.error - @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) @patch("core.workflow.nodes.template_transform.template_transform_node.MAX_TEMPLATE_TRANSFORM_OUTPUT_LENGTH", 10) def test_run_output_length_exceeds_limit( self, mock_execute, basic_node_data, mock_graph, mock_graph_runtime_state, graph_init_params ): """Test _run when output exceeds maximum length.""" mock_graph_runtime_state.variable_pool.get.return_value = MagicMock() - mock_execute.return_value = {"result": "This is a very long output that exceeds the limit"} + mock_execute.return_value = "This is a very long output that exceeds the limit" node = TemplateTransformNode( id="test_node", @@ -230,7 +238,9 @@ def test_run_output_length_exceeds_limit( assert result.status == WorkflowNodeExecutionStatus.FAILED assert "Output length exceeds" in result.error - @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) def test_run_with_complex_jinja2_template( self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params ): @@ -257,7 +267,7 @@ def test_run_with_complex_jinja2_template( ("sys", "show_total"): mock_show_total, } mock_graph_runtime_state.variable_pool.get.side_effect = lambda selector: variable_map.get(tuple(selector)) - mock_execute.return_value = {"result": "apple, banana, orange (Total: 3)"} + mock_execute.return_value = "apple, banana, orange (Total: 3)" node = TemplateTransformNode( id="test_node", @@ -292,7 +302,9 @@ def test_extract_variable_selector_to_variable_mapping(self): assert mapping["node_123.var1"] == ["sys", "input1"] assert mapping["node_123.var2"] == ["sys", "input2"] - @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) def test_run_with_empty_variables(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): """Test _run with no variables (static template).""" node_data = { @@ -301,7 +313,7 @@ def test_run_with_empty_variables(self, mock_execute, mock_graph, mock_graph_run "template": "This is a static message.", } - mock_execute.return_value = {"result": "This is a static message."} + mock_execute.return_value = "This is a static message." node = TemplateTransformNode( id="test_node", @@ -317,7 +329,9 @@ def test_run_with_empty_variables(self, mock_execute, mock_graph, mock_graph_run assert result.outputs["output"] == "This is a static message." assert result.inputs == {} - @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) def test_run_with_numeric_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): """Test _run with numeric variable values.""" node_data = { @@ -339,7 +353,7 @@ def test_run_with_numeric_values(self, mock_execute, mock_graph, mock_graph_runt ("sys", "quantity"): mock_quantity, } mock_graph_runtime_state.variable_pool.get.side_effect = lambda selector: variable_map.get(tuple(selector)) - mock_execute.return_value = {"result": "Total: $31.5"} + mock_execute.return_value = "Total: $31.5" node = TemplateTransformNode( id="test_node", @@ -354,7 +368,9 @@ def test_run_with_numeric_values(self, mock_execute, mock_graph, mock_graph_runt assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs["output"] == "Total: $31.5" - @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) def test_run_with_dict_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): """Test _run with dictionary variable values.""" node_data = { @@ -367,7 +383,7 @@ def test_run_with_dict_values(self, mock_execute, mock_graph, mock_graph_runtime mock_user.to_object.return_value = {"name": "John Doe", "email": "john@example.com"} mock_graph_runtime_state.variable_pool.get.return_value = mock_user - mock_execute.return_value = {"result": "Name: John Doe, Email: john@example.com"} + mock_execute.return_value = "Name: John Doe, Email: john@example.com" node = TemplateTransformNode( id="test_node", @@ -383,7 +399,9 @@ def test_run_with_dict_values(self, mock_execute, mock_graph, mock_graph_runtime assert "John Doe" in result.outputs["output"] assert "john@example.com" in result.outputs["output"] - @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) def test_run_with_list_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): """Test _run with list variable values.""" node_data = { @@ -396,7 +414,7 @@ def test_run_with_list_values(self, mock_execute, mock_graph, mock_graph_runtime mock_tags.to_object.return_value = ["python", "ai", "workflow"] mock_graph_runtime_state.variable_pool.get.return_value = mock_tags - mock_execute.return_value = {"result": "Tags: #python #ai #workflow "} + mock_execute.return_value = "Tags: #python #ai #workflow " node = TemplateTransformNode( id="test_node", @@ -412,3 +430,722 @@ def test_run_with_list_values(self, mock_execute, mock_graph, mock_graph_runtime assert "#python" in result.outputs["output"] assert "#ai" in result.outputs["output"] assert "#workflow" in result.outputs["output"] + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_boolean_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with boolean variable values.""" + node_data = { + "title": "Boolean Template", + "variables": [{"variable": "is_active", "value_selector": ["sys", "active_status"]}], + "template": "{% if is_active %}Active{% else %}Inactive{% endif %}", + } + + mock_status = MagicMock() + mock_status.to_object.return_value = True + + mock_graph_runtime_state.variable_pool.get.return_value = mock_status + mock_execute.return_value = "Active" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "Active" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_nested_dict_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with nested dictionary variable values.""" + node_data = { + "title": "Nested Dict Template", + "variables": [{"variable": "data", "value_selector": ["sys", "nested_data"]}], + "template": "User: {{ data.user.name }}, Company: {{ data.company.name }}", + } + + mock_data = MagicMock() + mock_data.to_object.return_value = { + "user": {"name": "Alice", "id": 123}, + "company": {"name": "TechCorp", "id": 456}, + } + + mock_graph_runtime_state.variable_pool.get.return_value = mock_data + mock_execute.return_value = "User: Alice, Company: TechCorp" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert "Alice" in result.outputs["output"] + assert "TechCorp" in result.outputs["output"] + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_filter_upper(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with upper filter.""" + node_data = { + "title": "Filter Template", + "variables": [{"variable": "text", "value_selector": ["sys", "input_text"]}], + "template": "{{ text | upper }}", + } + + mock_text = MagicMock() + mock_text.to_object.return_value = "hello world" + + mock_graph_runtime_state.variable_pool.get.return_value = mock_text + mock_execute.return_value = "HELLO WORLD" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "HELLO WORLD" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_filter_lower(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with lower filter.""" + node_data = { + "title": "Filter Template", + "variables": [{"variable": "text", "value_selector": ["sys", "input_text"]}], + "template": "{{ text | lower }}", + } + + mock_text = MagicMock() + mock_text.to_object.return_value = "HELLO WORLD" + + mock_graph_runtime_state.variable_pool.get.return_value = mock_text + mock_execute.return_value = "hello world" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "hello world" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_filter_length(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with length filter.""" + node_data = { + "title": "Length Filter Template", + "variables": [{"variable": "items", "value_selector": ["sys", "list_items"]}], + "template": "Count: {{ items | length }}", + } + + mock_items = MagicMock() + mock_items.to_object.return_value = ["a", "b", "c", "d"] + + mock_graph_runtime_state.variable_pool.get.return_value = mock_items + mock_execute.return_value = "Count: 4" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "Count: 4" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_filter_default(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with default filter.""" + node_data = { + "title": "Default Filter Template", + "variables": [{"variable": "value", "value_selector": ["sys", "optional_value"]}], + "template": "{{ value | default('N/A') }}", + } + + mock_graph_runtime_state.variable_pool.get.return_value = None + mock_execute.return_value = "N/A" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "N/A" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_multiline_template(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with multiline template.""" + node_data = { + "title": "Multiline Template", + "variables": [ + {"variable": "name", "value_selector": ["sys", "user_name"]}, + {"variable": "email", "value_selector": ["sys", "user_email"]}, + ], + "template": """Hello {{ name }}, +Welcome to our service! +Your email: {{ email }}""", + } + + mock_name = MagicMock() + mock_name.to_object.return_value = "Bob" + mock_email = MagicMock() + mock_email.to_object.return_value = "bob@example.com" + + variable_map = { + ("sys", "user_name"): mock_name, + ("sys", "user_email"): mock_email, + } + mock_graph_runtime_state.variable_pool.get.side_effect = lambda selector: variable_map.get(tuple(selector)) + mock_execute.return_value = """Hello Bob, +Welcome to our service! +Your email: bob@example.com""" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert "Bob" in result.outputs["output"] + assert "bob@example.com" in result.outputs["output"] + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_string_concatenation( + self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params + ): + """Test _run with string concatenation.""" + node_data = { + "title": "Concatenation Template", + "variables": [ + {"variable": "first", "value_selector": ["sys", "first_name"]}, + {"variable": "last", "value_selector": ["sys", "last_name"]}, + ], + "template": "{{ first ~ ' ' ~ last }}", + } + + mock_first = MagicMock() + mock_first.to_object.return_value = "Jane" + mock_last = MagicMock() + mock_last.to_object.return_value = "Doe" + + variable_map = { + ("sys", "first_name"): mock_first, + ("sys", "last_name"): mock_last, + } + mock_graph_runtime_state.variable_pool.get.side_effect = lambda selector: variable_map.get(tuple(selector)) + mock_execute.return_value = "Jane Doe" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "Jane Doe" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_conditional_if_else( + self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params + ): + """Test _run with conditional if-else statements.""" + node_data = { + "title": "Conditional Template", + "variables": [{"variable": "score", "value_selector": ["sys", "test_score"]}], + "template": "{% if score >= 90 %}A{% elif score >= 80 %}B{% else %}C{% endif %}", + } + + mock_score = MagicMock() + mock_score.to_object.return_value = 85 + + mock_graph_runtime_state.variable_pool.get.return_value = mock_score + mock_execute.return_value = "B" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "B" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_loop_range(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with loop using range.""" + node_data = { + "title": "Range Loop Template", + "variables": [{"variable": "count", "value_selector": ["sys", "num_items"]}], + "template": "{% for i in range(count) %}{{ i }}{% if not loop.last %}, {% endif %}{% endfor %}", + } + + mock_count = MagicMock() + mock_count.to_object.return_value = 5 + + mock_graph_runtime_state.variable_pool.get.return_value = mock_count + mock_execute.return_value = "0, 1, 2, 3, 4" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "0, 1, 2, 3, 4" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_loop_index(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with loop using index.""" + node_data = { + "title": "Loop Index Template", + "variables": [{"variable": "items", "value_selector": ["sys", "list_items"]}], + "template": "{% for item in items %}{{ loop.index }}. {{ item }}\n{% endfor %}", + } + + mock_items = MagicMock() + mock_items.to_object.return_value = ["First", "Second", "Third"] + + mock_graph_runtime_state.variable_pool.get.return_value = mock_items + mock_execute.return_value = "1. First\n2. Second\n3. Third\n" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert "1. First" in result.outputs["output"] + assert "2. Second" in result.outputs["output"] + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_special_characters(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with special characters in variables.""" + node_data = { + "title": "Special Chars Template", + "variables": [{"variable": "text", "value_selector": ["sys", "input_text"]}], + "template": "{{ text }}", + } + + mock_text = MagicMock() + mock_text.to_object.return_value = "Hello @#$%^&* World!" + + mock_graph_runtime_state.variable_pool.get.return_value = mock_text + mock_execute.return_value = "Hello @#$%^&* World!" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "Hello @#$%^&* World!" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_unicode_characters(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with unicode characters.""" + node_data = { + "title": "Unicode Template", + "variables": [{"variable": "text", "value_selector": ["sys", "unicode_text"]}], + "template": "{{ text }}", + } + + mock_text = MagicMock() + mock_text.to_object.return_value = "Hello δΈ–η•Œ 🌍" + + mock_graph_runtime_state.variable_pool.get.return_value = mock_text + mock_execute.return_value = "Hello δΈ–η•Œ 🌍" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "Hello δΈ–η•Œ 🌍" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_empty_string(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with empty string variable.""" + node_data = { + "title": "Empty String Template", + "variables": [{"variable": "text", "value_selector": ["sys", "empty_text"]}], + "template": "Value: '{{ text }}'", + } + + mock_text = MagicMock() + mock_text.to_object.return_value = "" + + mock_graph_runtime_state.variable_pool.get.return_value = mock_text + mock_execute.return_value = "Value: ''" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "Value: ''" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_zero_value(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with zero value.""" + node_data = { + "title": "Zero Value Template", + "variables": [{"variable": "num", "value_selector": ["sys", "number"]}], + "template": "Number: {{ num }}", + } + + mock_num = MagicMock() + mock_num.to_object.return_value = 0 + + mock_graph_runtime_state.variable_pool.get.return_value = mock_num + mock_execute.return_value = "Number: 0" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "Number: 0" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_negative_number(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with negative number.""" + node_data = { + "title": "Negative Number Template", + "variables": [{"variable": "balance", "value_selector": ["sys", "account_balance"]}], + "template": "Balance: ${{ balance }}", + } + + mock_balance = MagicMock() + mock_balance.to_object.return_value = -50.25 + + mock_graph_runtime_state.variable_pool.get.return_value = mock_balance + mock_execute.return_value = "Balance: $-50.25" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "Balance: $-50.25" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_float_precision(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with float precision.""" + node_data = { + "title": "Float Precision Template", + "variables": [{"variable": "value", "value_selector": ["sys", "precise_value"]}], + "template": "Value: {{ '%.2f' % value }}", + } + + mock_value = MagicMock() + mock_value.to_object.return_value = 3.14159265 + + mock_graph_runtime_state.variable_pool.get.return_value = mock_value + mock_execute.return_value = "Value: 3.14" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "Value: 3.14" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_large_number(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with large number.""" + node_data = { + "title": "Large Number Template", + "variables": [{"variable": "population", "value_selector": ["sys", "world_population"]}], + "template": "Population: {{ population }}", + } + + mock_population = MagicMock() + mock_population.to_object.return_value = 8000000000 + + mock_graph_runtime_state.variable_pool.get.return_value = mock_population + mock_execute.return_value = "Population: 8000000000" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "Population: 8000000000" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_mixed_types_in_list(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with mixed types in list.""" + node_data = { + "title": "Mixed Types Template", + "variables": [{"variable": "items", "value_selector": ["sys", "mixed_items"]}], + "template": "{% for item in items %}{{ item }}{% if not loop.last %}, {% endif %}{% endfor %}", + } + + mock_items = MagicMock() + mock_items.to_object.return_value = [1, "two", 3.0, True] + + mock_graph_runtime_state.variable_pool.get.return_value = mock_items + mock_execute.return_value = "1, two, 3.0, True" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert "1, two, 3.0, True" == result.outputs["output"] + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_empty_list(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with empty list.""" + node_data = { + "title": "Empty List Template", + "variables": [{"variable": "items", "value_selector": ["sys", "empty_items"]}], + "template": "{% if items %}Has items{% else %}No items{% endif %}", + } + + mock_items = MagicMock() + mock_items.to_object.return_value = [] + + mock_graph_runtime_state.variable_pool.get.return_value = mock_items + mock_execute.return_value = "No items" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "No items" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_empty_dict(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with empty dictionary.""" + node_data = { + "title": "Empty Dict Template", + "variables": [{"variable": "data", "value_selector": ["sys", "empty_data"]}], + "template": "{% if data %}Has data{% else %}No data{% endif %}", + } + + mock_data = MagicMock() + mock_data.to_object.return_value = {} + + mock_graph_runtime_state.variable_pool.get.return_value = mock_data + mock_execute.return_value = "No data" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "No data" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_whitespace_control(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with whitespace control.""" + node_data = { + "title": "Whitespace Control Template", + "variables": [{"variable": "items", "value_selector": ["sys", "list_items"]}], + "template": "{%- for item in items -%}{{ item }}{%- endfor -%}", + } + + mock_items = MagicMock() + mock_items.to_object.return_value = ["a", "b", "c"] + + mock_graph_runtime_state.variable_pool.get.return_value = mock_items + mock_execute.return_value = "abc" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "abc" + + @patch( + "core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template" + ) + def test_run_with_escape_filter(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with escape filter for HTML.""" + node_data = { + "title": "Escape Filter Template", + "variables": [{"variable": "html", "value_selector": ["sys", "html_content"]}], + "template": "{{ html | escape }}", + } + + mock_html = MagicMock() + mock_html.to_object.return_value = "" + + mock_graph_runtime_state.variable_pool.get.return_value = mock_html + mock_execute.return_value = "<script>alert('xss')</script>" + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert "<script>" in result.outputs["output"]