Repository: narwhals-dev/narwhals
Pull request #312 (2024-06-17 22:46:06)

feat: add left join
<!--
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [x] ✅ Test
- [ ] 🐳 Other

## Related issues 

- Related issue # 
- Closes #

## Checklist

- [ ] Code follows style guide (ruff)
- [x] Tests added 
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below.



MarcoGorelli: thanks for updating - could you also make sure to fetch and merge upstream/main, and resolve the merge conflicts?

Patch:
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 0f32c5c8..88bd32c3 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
 
 
 class ArrowExpr:
-    def __init__(  # noqa: PLR0913
+    def __init__(
         self,
         call: Callable[[ArrowDataFrame], list[ArrowSeries]],
         *,
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index c7c94973..36af9f1f 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -38,7 +38,7 @@ class ArrowNamespace:
     Duration = dtypes.Duration
     Date = dtypes.Date
 
-    def _create_expr_from_callable(  # noqa: PLR0913
+    def _create_expr_from_callable(
         self,
         func: Callable[[ArrowDataFrame], list[ArrowSeries]],
         *,
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 0dfd5d00..a889885c 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -305,8 +305,8 @@ class PandasLikeDataFrame:
         other: Self,
         *,
         how: Literal["left", "inner", "outer", "cross", "anti", "semi"] = "inner",
-        left_on: str | list[str] | None = None,
-        right_on: str | list[str] | None = None,
+        left_on: str | list[str] | None,
+        right_on: str | list[str] | None,
     ) -> Self:
         if isinstance(left_on, str):
             left_on = [left_on]
@@ -365,7 +365,6 @@ class PandasLikeDataFrame:
                 )
                 .loc[lambda t: t[indicator_token] == "left_only"]
                 .drop(columns=[indicator_token])
-                .reset_index(drop=True)
             )
 
         if how == "semi":
@@ -382,8 +381,25 @@ class PandasLikeDataFrame:
                     how="inner",
                     left_on=left_on,
                     right_on=left_on,
-                ).reset_index(drop=True)
+                )
+            )
+
+        if how == "left":
+            other_native = other._native_dataframe
+            result_native = self._native_dataframe.merge(
+                other_native,
+                how="left",
+                left_on=left_on,
+                right_on=right_on,
+                suffixes=("", "_right"),
             )
+            extra = []
+            for left_key, right_key in zip(left_on, right_on):  # type: ignore[arg-type]
+                if right_key != left_key and right_key not in self.columns:
+                    extra.append(right_key)
+                elif right_key != left_key:
+                    extra.append(f"{right_key}_right")
+            return self._from_native_dataframe(result_native.drop(columns=extra))
 
         return self._from_native_dataframe(
             self._native_dataframe.merge(
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index d507730f..30719f26 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -18,7 +18,7 @@ if TYPE_CHECKING:
 
 
 class PandasLikeExpr:
-    def __init__(  # noqa: PLR0913
+    def __init__(
         self,
         call: Callable[[PandasLikeDataFrame], list[PandasLikeSeries]],
         *,
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index 864268e7..f5511785 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -91,7 +91,7 @@ class PandasLikeGroupBy:
         )
 
 
-def agg_pandas(  # noqa: PLR0913
+def agg_pandas(
     grouped: Any,
     exprs: list[PandasLikeExpr],
     keys: list[str],
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 6f54423f..da237aa0 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -56,7 +56,7 @@ class PandasLikeNamespace:
         self._implementation = implementation
         self._backend_version = backend_version
 
-    def _create_expr_from_callable(  # noqa: PLR0913
+    def _create_expr_from_callable(
         self,
         func: Callable[[PandasLikeDataFrame], list[PandasLikeSeries]],
         *,
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index b14d4c86..7b8cf0d7 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -131,7 +131,7 @@ class PandasLikeSeries:
         )
 
     @classmethod
-    def _from_iterable(  # noqa: PLR0913
+    def _from_iterable(
         cls: type[Self],
         data: Iterable[Any],
         name: str,
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index b78d04ec..e255844a 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -174,11 +174,11 @@ class BaseFrame(Generic[FrameT]):
         self,
         other: Self,
         *,
-        how: Literal["inner", "cross", "semi", "anti"] = "inner",
+        how: Literal["inner", "left", "cross", "semi", "anti"] = "inner",
         left_on: str | list[str] | None = None,
         right_on: str | list[str] | None = None,
     ) -> Self:
-        _supported_joins = ("inner", "cross", "anti", "semi")
+        _supported_joins = ("inner", "left", "cross", "anti", "semi")
 
         if how not in _supported_joins:
             msg = f"Only the following join stragies are supported: {_supported_joins}; found '{how}'."
@@ -1569,7 +1569,7 @@ class DataFrame(BaseFrame[FrameT]):
         self,
         other: Self,
         *,
-        how: Literal["inner", "cross", "semi", "anti"] = "inner",
+        how: Literal["inner", "left", "cross", "semi", "anti"] = "inner",
         left_on: str | list[str] | None = None,
         right_on: str | list[str] | None = None,
     ) -> Self:
@@ -2995,7 +2995,7 @@ class LazyFrame(BaseFrame[FrameT]):
         self,
         other: Self,
         *,
-        how: Literal["inner", "cross", "semi", "anti"] = "inner",
+        how: Literal["inner", "left", "cross", "semi", "anti"] = "inner",
         left_on: str | list[str] | None = None,
         right_on: str | list[str] | None = None,
     ) -> Self:
diff --git a/pyproject.toml b/pyproject.toml
index 60b09c7f..71bd9616 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -58,6 +58,7 @@ lint.ignore = [
   'PD901',  # This is a auxiliary library so dataframe variables have no concrete business meaning
   'PLR0911',
   'PLR0912',
+  'PLR0913',
   'PLR2004',
   'PTH',
   'RET505',


Test patch:
diff --git a/tests/frame/join_test.py b/tests/frame/join_test.py
index a8339277..de91f5b2 100644
--- a/tests/frame/join_test.py
+++ b/tests/frame/join_test.py
@@ -96,7 +96,7 @@ def test_semi_join(
     compare_dicts(result, expected)
 
 
-@pytest.mark.parametrize("how", ["left", "right", "full"])
+@pytest.mark.parametrize("how", ["right", "full"])
 def test_join_not_implemented(constructor_with_lazy: Any, how: str) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
     df = nw.from_native(constructor_with_lazy(data))
@@ -104,7 +104,46 @@ def test_join_not_implemented(constructor_with_lazy: Any, how: str) -> None:
     with pytest.raises(
         NotImplementedError,
         match=re.escape(
-            f"Only the following join stragies are supported: ('inner', 'cross', 'anti', 'semi'); found '{how}'."
+            f"Only the following join stragies are supported: ('inner', 'left', 'cross', 'anti', 'semi'); found '{how}'."
         ),
     ):
         df.join(df, left_on="a", right_on="a", how=how)  # type: ignore[arg-type]
+
+
+@pytest.mark.filterwarnings("ignore:the default coalesce behavior")
+def test_left_join(constructor: Any) -> None:
+    data_left = {"a": [1, 2, 3], "b": [4, 5, 6]}
+    data_right = {"a": [1, 2, 3], "c": [4, 5, 6]}
+    df_left = nw.from_native(constructor(data_left), eager_only=True)
+    df_right = nw.from_native(constructor(data_right), eager_only=True)
+    result = df_left.join(df_right, left_on="b", right_on="c", how="left")
+    expected = {"a": [1, 2, 3], "b": [4, 5, 6], "a_right": [1, 2, 3]}
+    compare_dicts(result, expected)
+
+
+@pytest.mark.filterwarnings("ignore: the defaultcoalesce behavior")
+def test_left_join_multiple_column(constructor: Any) -> None:
+    data_left = {"a": [1, 2, 3], "b": [4, 5, 6]}
+    data_right = {"a": [1, 2, 3], "c": [4, 5, 6]}
+    df_left = nw.from_native(constructor(data_left), eager_only=True)
+    df_right = nw.from_native(constructor(data_right), eager_only=True)
+    result = df_left.join(df_right, left_on=["a", "b"], right_on=["a", "c"], how="left")
+    expected = {"a": [1, 2, 3], "b": [4, 5, 6]}
+    compare_dicts(result, expected)
+
+
+@pytest.mark.filterwarnings("ignore: the defaultcoalesce behavior")
+def test_left_join_overlapping_column(constructor: Any) -> None:
+    data_left = {"a": [1, 2, 3], "b": [4, 5, 6], "d": [1, 4, 2]}
+    data_right = {"a": [1, 2, 3], "c": [4, 5, 6], "d": [1, 4, 2]}
+    df_left = nw.from_native(constructor(data_left), eager_only=True)
+    df_right = nw.from_native(constructor(data_right), eager_only=True)
+    result = df_left.join(df_right, left_on="b", right_on="c", how="left")
+    expected = {
+        "a": [1, 2, 3],
+        "b": [4, 5, 6],
+        "d": [1, 4, 2],
+        "a_right": [1, 2, 3],
+        "d_right": [1, 4, 2],
+    }
+    compare_dicts(result, expected)
diff --git a/tests/hypothesis/test_join.py b/tests/hypothesis/test_join.py
index cf019583..d5db53e3 100644
--- a/tests/hypothesis/test_join.py
+++ b/tests/hypothesis/test_join.py
@@ -3,12 +3,14 @@ from __future__ import annotations
 import pandas as pd
 import polars as pl
 import pytest
+from hypothesis import assume
 from hypothesis import given
 from hypothesis import strategies as st
 from pandas.testing import assert_frame_equal
 
 import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
+from tests.utils import compare_dicts
 
 pl_version = parse_version(pl.__version__)
 pd_version = parse_version(pd.__version__)
@@ -116,3 +118,48 @@ def test_cross_join(  # pragma: no cover
     )
 
     assert_frame_equal(dframe_pd1, dframe_pd2)
+
+
+@given(  # type: ignore[misc]
+    a_left_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    b_left_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    c_left_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    a_right_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    b_right_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    d_right_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    left_key=st.lists(
+        st.sampled_from(["a", "b", "c"]), min_size=1, max_size=3, unique=True
+    ),
+    right_key=st.lists(
+        st.sampled_from(["a", "b", "d"]), min_size=1, max_size=3, unique=True
+    ),
+)
+@pytest.mark.slow()
+def test_left_join(  # pragma: no cover
+    a_left_data: list[int],
+    b_left_data: list[int],
+    c_left_data: list[int],
+    a_right_data: list[int],
+    b_right_data: list[int],
+    d_right_data: list[int],
+    left_key: list[str],
+    right_key: list[str],
+) -> None:
+    assume(len(left_key) == len(right_key))
+    data_left = {"a": a_left_data, "b": b_left_data, "c": c_left_data}
+    data_right = {"a": a_right_data, "b": b_right_data, "d": d_right_data}
+    result_pd = nw.from_native(pd.DataFrame(data_left), eager_only=True).join(
+        nw.from_native(pd.DataFrame(data_right), eager_only=True),
+        how="left",
+        left_on=left_key,
+        right_on=right_key,
+    )
+    result_pl = nw.to_native(
+        nw.from_native(pl.DataFrame(data_left), eager_only=True).join(
+            nw.from_native(pl.DataFrame(data_right), eager_only=True),
+            how="left",
+            left_on=left_key,
+            right_on=right_key,
+        )
+    ).select(pl.all().fill_null(float("nan")))
+    compare_dicts(result_pd.to_dict(as_series=False), result_pl.to_dict(as_series=False))