Repository: deepset-ai/haystack

Issue:
Refactor `DocumentJoiner` to use enums for join modes
`DocumentJoiner` now takes bare strings for its `join_mode` parameter. This should rewritten to use the enum pattern seen elsewhere in the codebase.



Discussion:
I'd like to take this up if no one is working on it
Thanks for offering! Please feel free to open a PR.
Hey @AnushreeBannadabhavi,
Please confirm if you are still working on this. Otherwise, I'd like to pick it up.
Hi @nvzard 
Yes, I'm working on this. Will open a PR in a day or two 

Resolving patch:
diff --git a/haystack/components/joiners/document_joiner.py b/haystack/components/joiners/document_joiner.py
--- a/haystack/components/joiners/document_joiner.py
+++ b/haystack/components/joiners/document_joiner.py
@@ -4,15 +4,42 @@
 
 import itertools
 from collections import defaultdict
+from enum import Enum
 from math import inf
-from typing import List, Optional
+from typing import Any, Dict, List, Optional, Union
 
-from haystack import Document, component, logging
+from haystack import Document, component, default_from_dict, default_to_dict, logging
 from haystack.core.component.types import Variadic
 
 logger = logging.getLogger(__name__)
 
 
+class JoinMode(Enum):
+    """
+    Enum for join mode.
+    """
+
+    CONCATENATE = "concatenate"
+    MERGE = "merge"
+    RECIPROCAL_RANK_FUSION = "reciprocal_rank_fusion"
+    DISTRIBUTION_BASED_RANK_FUSION = "distribution_based_rank_fusion"
+
+    def __str__(self):
+        return self.value
+
+    @staticmethod
+    def from_str(string: str) -> "JoinMode":
+        """
+        Convert a string to a JoinMode enum.
+        """
+        enum_map = {e.value: e for e in JoinMode}
+        mode = enum_map.get(string)
+        if mode is None:
+            msg = f"Unknown join mode '{string}'. Supported modes in DocumentJoiner are: {list(enum_map.keys())}"
+            raise ValueError(msg)
+        return mode
+
+
 @component
 class DocumentJoiner:
     """
@@ -45,7 +72,7 @@ class DocumentJoiner:
 
     def __init__(
         self,
-        join_mode: str = "concatenate",
+        join_mode: Union[str, JoinMode] = JoinMode.CONCATENATE,
         weights: Optional[List[float]] = None,
         top_k: Optional[int] = None,
         sort_by_score: bool = True,
@@ -68,8 +95,15 @@ def __init__(
             If True sorts the Documents by score in descending order.
             If a Document has no score, it is handled as if its score is -infinity.
         """
-        if join_mode not in ["concatenate", "merge", "reciprocal_rank_fusion", "distribution_based_rank_fusion"]:
-            raise ValueError(f"DocumentJoiner component does not support '{join_mode}' join_mode.")
+        if isinstance(join_mode, str):
+            join_mode = JoinMode.from_str(join_mode)
+        join_mode_functions = {
+            JoinMode.CONCATENATE: self._concatenate,
+            JoinMode.MERGE: self._merge,
+            JoinMode.RECIPROCAL_RANK_FUSION: self._reciprocal_rank_fusion,
+            JoinMode.DISTRIBUTION_BASED_RANK_FUSION: self._distribution_based_rank_fusion,
+        }
+        self.join_mode_function = join_mode_functions[join_mode]
         self.join_mode = join_mode
         self.weights = [float(i) / sum(weights) for i in weights] if weights else None
         self.top_k = top_k
@@ -92,14 +126,7 @@ def run(self, documents: Variadic[List[Document]], top_k: Optional[int] = None):
         output_documents = []
 
         documents = list(documents)
-        if self.join_mode == "concatenate":
-            output_documents = self._concatenate(documents)
-        elif self.join_mode == "merge":
-            output_documents = self._merge(documents)
-        elif self.join_mode == "reciprocal_rank_fusion":
-            output_documents = self._reciprocal_rank_fusion(documents)
-        elif self.join_mode == "distribution_based_rank_fusion":
-            output_documents = self._distribution_based_rank_fusion(documents)
+        output_documents = self.join_mode_function(documents)
 
         if self.sort_by_score:
             output_documents = sorted(
@@ -204,3 +231,30 @@ def _distribution_based_rank_fusion(self, document_lists: List[List[Document]])
         output = self._concatenate(document_lists=document_lists)
 
         return output
+
+    def to_dict(self) -> Dict[str, Any]:
+        """
+        Serializes the component to a dictionary.
+
+        :returns:
+            Dictionary with serialized data.
+        """
+        return default_to_dict(
+            self,
+            join_mode=str(self.join_mode),
+            weights=self.weights,
+            top_k=self.top_k,
+            sort_by_score=self.sort_by_score,
+        )
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "DocumentJoiner":
+        """
+        Deserializes the component from a dictionary.
+
+        :param data:
+            The dictionary to deserialize from.
+        :returns:
+            The deserialized component.
+        """
+        return default_from_dict(cls, data)


Test patch:
diff --git a/test/components/joiners/test_document_joiner.py b/test/components/joiners/test_document_joiner.py
--- a/test/components/joiners/test_document_joiner.py
+++ b/test/components/joiners/test_document_joiner.py
@@ -2,28 +2,64 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 import logging
+import re
 
 import pytest
 
 from haystack import Document
-from haystack.components.joiners.document_joiner import DocumentJoiner
+from haystack.components.joiners.document_joiner import DocumentJoiner, JoinMode
 
 
 class TestDocumentJoiner:
     def test_init(self):
         joiner = DocumentJoiner()
-        assert joiner.join_mode == "concatenate"
+        assert joiner.join_mode == JoinMode.CONCATENATE
         assert joiner.weights is None
         assert joiner.top_k is None
         assert joiner.sort_by_score
 
     def test_init_with_custom_parameters(self):
         joiner = DocumentJoiner(join_mode="merge", weights=[0.4, 0.6], top_k=5, sort_by_score=False)
-        assert joiner.join_mode == "merge"
+        assert joiner.join_mode == JoinMode.MERGE
         assert joiner.weights == [0.4, 0.6]
         assert joiner.top_k == 5
         assert not joiner.sort_by_score
 
+    def test_to_dict(self):
+        joiner = DocumentJoiner()
+        data = joiner.to_dict()
+        assert data == {
+            "type": "haystack.components.joiners.document_joiner.DocumentJoiner",
+            "init_parameters": {"join_mode": "concatenate", "sort_by_score": True, "top_k": None, "weights": None},
+        }
+
+    def test_to_dict_custom_parameters(self):
+        joiner = DocumentJoiner("merge", weights=[0.4, 0.6], top_k=4, sort_by_score=False)
+        data = joiner.to_dict()
+        assert data == {
+            "type": "haystack.components.joiners.document_joiner.DocumentJoiner",
+            "init_parameters": {"join_mode": "merge", "weights": [0.4, 0.6], "top_k": 4, "sort_by_score": False},
+        }
+
+    def test_from_dict(self):
+        data = {"type": "haystack.components.joiners.document_joiner.DocumentJoiner", "init_parameters": {}}
+        document_joiner = DocumentJoiner.from_dict(data)
+        assert document_joiner.join_mode == JoinMode.CONCATENATE
+        assert document_joiner.weights == None
+        assert document_joiner.top_k == None
+        assert document_joiner.sort_by_score
+
+    def test_from_dict_customs_parameters(self):
+        data = {
+            "type": "haystack.components.joiners.document_joiner.DocumentJoiner",
+            "init_parameters": {"join_mode": "merge", "weights": [0.5, 0.6], "top_k": 6, "sort_by_score": False},
+        }
+        document_joiner = DocumentJoiner.from_dict(data)
+        assert document_joiner.join_mode == JoinMode.MERGE
+        assert document_joiner.weights == pytest.approx([0.5, 0.6], rel=0.1)
+        assert document_joiner.top_k == 6
+        assert not document_joiner.sort_by_score
+
     def test_empty_list(self):
         joiner = DocumentJoiner()
         result = joiner.run([])
@@ -41,8 +77,13 @@ def test_list_with_one_empty_list(self):
         assert result == {"documents": documents}
 
     def test_unsupported_join_mode(self):
-        with pytest.raises(ValueError, match="DocumentJoiner component does not support 'unsupported_mode' join_mode."):
-            DocumentJoiner(join_mode="unsupported_mode")
+        unsupported_mode = "unsupported_mode"
+        expected_error_pattern = (
+            re.escape(f"Unknown join mode '{unsupported_mode}'") + r".*Supported modes in DocumentJoiner are: \[.*\]"
+        )
+
+        with pytest.raises(ValueError, match=expected_error_pattern):
+            DocumentJoiner(join_mode=unsupported_mode)
 
     def test_run_with_concatenate_join_mode_and_top_k(self):
         joiner = DocumentJoiner(top_k=6)