Repository: narwhals-dev/narwhals
Pull request #2168 (2025-03-08 14:59:21)

feat: add `rolling_sum` for sqlframe and pyspark
<!--
# 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
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #\<issue number\>

## Checklist

- [ ] Code follows style guide (ruff)
- [ ] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below



Patch:
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index a8563859..5b808617 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -545,11 +545,7 @@ class ArrowExpr(CompliantExpr["ArrowDataFrame", ArrowSeries]):
         return reuse_series_implementation(self, "cum_prod", reverse=reverse)
 
     def rolling_sum(
-        self: Self,
-        window_size: int,
-        *,
-        min_samples: int | None,
-        center: bool,
+        self: Self, window_size: int, *, min_samples: int, center: bool
     ) -> Self:
         return reuse_series_implementation(
             self,
@@ -563,7 +559,7 @@ class ArrowExpr(CompliantExpr["ArrowDataFrame", ArrowSeries]):
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
     ) -> Self:
         return reuse_series_implementation(
@@ -578,7 +574,7 @@ class ArrowExpr(CompliantExpr["ArrowDataFrame", ArrowSeries]):
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self:
@@ -595,7 +591,7 @@ class ArrowExpr(CompliantExpr["ArrowDataFrame", ArrowSeries]):
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self:
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 0b33da9d..de0d3533 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -898,7 +898,7 @@ class ArrowSeries(CompliantSeries, _StoresNative["ArrowChunkedArray"]):
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
     ) -> Self:
         min_samples = min_samples if min_samples is not None else window_size
@@ -932,7 +932,7 @@ class ArrowSeries(CompliantSeries, _StoresNative["ArrowChunkedArray"]):
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
     ) -> Self:
         min_samples = min_samples if min_samples is not None else window_size
@@ -969,7 +969,7 @@ class ArrowSeries(CompliantSeries, _StoresNative["ArrowChunkedArray"]):
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self:
@@ -1022,7 +1022,7 @@ class ArrowSeries(CompliantSeries, _StoresNative["ArrowChunkedArray"]):
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self:
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index bb1c1eed..1158830f 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -381,6 +381,16 @@ class DaskExpr(CompliantExpr["DaskLazyFrame", "dx.Series"]):  # pyright: ignore[
 
         return self._from_call(lambda _input: _input.cumprod(), "cum_prod")
 
+    def rolling_sum(
+        self: Self, window_size: int, *, min_samples: int, center: bool
+    ) -> Self:
+        return self._from_call(
+            lambda _input: _input.rolling(
+                window=window_size, min_periods=min_samples, center=center
+            ).sum(),
+            "rolling_sum",
+        )
+
     def sum(self: Self) -> Self:
         return self._from_call(lambda _input: _input.sum().to_series(), "sum")
 
@@ -566,7 +576,7 @@ class DaskExpr(CompliantExpr["DaskLazyFrame", "dx.Series"]):  # pyright: ignore[
             except KeyError:
                 # window functions are unsupported: https://github.com/dask/dask/issues/11806
                 msg = (
-                    f"Unsupported function: {function_name} in `over` context.\n\n."
+                    f"Unsupported function: {function_name} in `over` context.\n\n"
                     f"Supported functions are {', '.join(AGGREGATIONS_TO_PANDAS_EQUIVALENT)}\n"
                 )
                 raise NotImplementedError(msg) from None
@@ -634,7 +644,6 @@ class DaskExpr(CompliantExpr["DaskLazyFrame", "dx.Series"]):  # pyright: ignore[
     sample = not_implemented()
     map_batches = not_implemented()
     ewm_mean = not_implemented()
-    rolling_sum = not_implemented()
     rolling_mean = not_implemented()
     rolling_var = not_implemented()
     rolling_std = not_implemented()
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 8aa4a15a..f23b1996 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -43,6 +43,7 @@ WINDOW_FUNCTIONS_TO_PANDAS_EQUIVALENT = {
     # Pandas cumcount counts nulls while Polars does not
     # So, instead of using "cumcount" we use "cumsum" on notna() to get the same result
     "cum_count": "cumsum",
+    "rolling_sum": "sum",
     "shift": "shift",
     "rank": "rank",
     "diff": "diff",
@@ -64,6 +65,12 @@ def window_kwargs_to_pandas_equivalent(
         }
     elif function_name.startswith("cum_"):  # Cumulative operation
         pandas_kwargs = {"skipna": True}
+    elif function_name.startswith("rolling_"):  # Rolling operation
+        pandas_kwargs = {
+            "min_periods": kwargs["min_samples"],
+            "window": kwargs["window_size"],
+            "center": kwargs["center"],
+        }
     else:  # e.g. std, var
         pandas_kwargs = kwargs
     return pandas_kwargs
@@ -487,11 +494,11 @@ class PandasLikeExpr(CompliantExpr["PandasLikeDataFrame", PandasLikeSeries]):
             raise NotImplementedError(msg)
         else:
             function_name: str = re.sub(r"(\w+->)", "", self._function_name)
-            if pandas_function_name := WINDOW_FUNCTIONS_TO_PANDAS_EQUIVALENT.get(
-                function_name, AGGREGATIONS_TO_PANDAS_EQUIVALENT.get(function_name, None)
-            ):
-                pass
-            else:
+            pandas_function_name = WINDOW_FUNCTIONS_TO_PANDAS_EQUIVALENT.get(
+                function_name,
+                AGGREGATIONS_TO_PANDAS_EQUIVALENT.get(function_name),
+            )
+            if pandas_function_name is None:
                 msg = (
                     f"Unsupported function: {function_name} in `over` context.\n\n"
                     f"Supported functions are {', '.join(WINDOW_FUNCTIONS_TO_PANDAS_EQUIVALENT)}\n"
@@ -504,7 +511,6 @@ class PandasLikeExpr(CompliantExpr["PandasLikeDataFrame", PandasLikeSeries]):
 
             def func(df: PandasLikeDataFrame) -> Sequence[PandasLikeSeries]:
                 output_names, aliases = evaluate_output_names_and_aliases(self, df, [])
-
                 if function_name == "cum_count":
                     plx = self.__narwhals_namespace__()
                     df = df.with_columns(~plx.col(*output_names).is_null())
@@ -527,9 +533,16 @@ class PandasLikeExpr(CompliantExpr["PandasLikeDataFrame", PandasLikeSeries]):
                 elif reverse:
                     columns = list(set(partition_by).union(output_names))
                     df = df[columns][::-1]
-                res_native = df._native_frame.groupby(partition_by)[
-                    list(output_names)
-                ].transform(pandas_function_name, **pandas_kwargs)
+                if function_name.startswith("rolling"):
+                    rolling = df._native_frame.groupby(partition_by)[
+                        list(output_names)
+                    ].rolling(**pandas_kwargs)
+                    assert pandas_function_name is not None  # help mypy  # noqa: S101
+                    res_native = getattr(rolling, pandas_function_name)()
+                else:
+                    res_native = df._native_frame.groupby(partition_by)[
+                        list(output_names)
+                    ].transform(pandas_function_name, **pandas_kwargs)
                 result_frame = df._from_native_frame(res_native).rename(
                     dict(zip(output_names, aliases))
                 )
@@ -650,25 +663,23 @@ class PandasLikeExpr(CompliantExpr["PandasLikeDataFrame", PandasLikeSeries]):
         )
 
     def rolling_sum(
-        self: Self,
-        window_size: int,
-        *,
-        min_samples: int | None,
-        center: bool,
+        self: Self, window_size: int, *, min_samples: int, center: bool
     ) -> Self:
         return reuse_series_implementation(
             self,
             "rolling_sum",
-            window_size=window_size,
-            min_samples=min_samples,
-            center=center,
+            call_kwargs={
+                "window_size": window_size,
+                "min_samples": min_samples,
+                "center": center,
+            },
         )
 
     def rolling_mean(
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
     ) -> Self:
         return reuse_series_implementation(
@@ -683,7 +694,7 @@ class PandasLikeExpr(CompliantExpr["PandasLikeDataFrame", PandasLikeSeries]):
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self:
@@ -700,7 +711,7 @@ class PandasLikeExpr(CompliantExpr["PandasLikeDataFrame", PandasLikeSeries]):
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self:
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 87057c3d..7a0a0d3c 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -922,7 +922,7 @@ class PandasLikeSeries(CompliantSeries):
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
     ) -> Self:
         result = self._native_series.rolling(
@@ -934,7 +934,7 @@ class PandasLikeSeries(CompliantSeries):
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
     ) -> Self:
         result = self._native_series.rolling(
@@ -946,7 +946,7 @@ class PandasLikeSeries(CompliantSeries):
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self:
@@ -959,7 +959,7 @@ class PandasLikeSeries(CompliantSeries):
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self:
diff --git a/narwhals/_polars/expr.py b/narwhals/_polars/expr.py
index 7ebbe4ff..fbb96447 100644
--- a/narwhals/_polars/expr.py
+++ b/narwhals/_polars/expr.py
@@ -118,7 +118,7 @@ class PolarsExpr:
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self:
@@ -144,7 +144,7 @@ class PolarsExpr:
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self:
@@ -167,11 +167,7 @@ class PolarsExpr:
         )
 
     def rolling_sum(
-        self: Self,
-        window_size: int,
-        *,
-        min_samples: int | None,
-        center: bool,
+        self: Self, window_size: int, *, min_samples: int, center: bool
     ) -> Self:
         extra_kwargs = (
             {"min_periods": min_samples}
@@ -191,7 +187,7 @@ class PolarsExpr:
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
     ) -> Self:
         extra_kwargs = (
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index 23e9ab66..1db4d8bb 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -309,7 +309,7 @@ class PolarsSeries:
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self:
@@ -336,7 +336,7 @@ class PolarsSeries:
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self:
@@ -363,7 +363,7 @@ class PolarsSeries:
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
     ) -> Self:
         extra_kwargs = (
@@ -384,7 +384,7 @@ class PolarsSeries:
         self: Self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
     ) -> Self:
         extra_kwargs = (
diff --git a/narwhals/_spark_like/expr.py b/narwhals/_spark_like/expr.py
index 69148eca..32f1923e 100644
--- a/narwhals/_spark_like/expr.py
+++ b/narwhals/_spark_like/expr.py
@@ -569,12 +569,38 @@ class SparkLikeExpr(CompliantExpr["SparkLikeLazyFrame", "Column"]):  # type: ign
                 self._Window()
                 .partitionBy(list(partition_by))
                 .orderBy(order_by_cols)
-                .rangeBetween(self._Window().unboundedPreceding, 0)
+                .rowsBetween(self._Window().unboundedPreceding, 0)
             )
             return self._F.sum(_input).over(window)
 
         return self._with_window_function(func)
 
+    def rolling_sum(self, window_size: int, *, min_samples: int, center: bool) -> Self:
+        if center:
+            half = (window_size - 1) // 2
+            remainder = (window_size - 1) % 2
+            start = self._Window().currentRow - half - remainder
+            end = self._Window().currentRow + half
+        else:
+            start = self._Window().currentRow - window_size + 1
+            end = self._Window().currentRow
+
+        def func(
+            _input: Column, partition_by: Sequence[str], order_by: Sequence[str]
+        ) -> Column:
+            window = (
+                self._Window()
+                .partitionBy(list(partition_by))
+                .orderBy([self._F.col(x).asc_nulls_first() for x in order_by])
+                .rowsBetween(start, end)
+            )
+            return self._F.when(
+                self._F.count(_input).over(window) >= min_samples,
+                self._F.sum(_input).over(window),
+            )
+
+        return self._with_window_function(func)
+
     @property
     def str(self: Self) -> SparkLikeExprStringNamespace:
         return SparkLikeExprStringNamespace(self)
@@ -602,7 +628,6 @@ class SparkLikeExpr(CompliantExpr["SparkLikeLazyFrame", "Column"]):  # type: ign
     sample = not_implemented()
     map_batches = not_implemented()
     ewm_mean = not_implemented()
-    rolling_sum = not_implemented()
     rolling_mean = not_implemented()
     rolling_var = not_implemented()
     rolling_std = not_implemented()
diff --git a/narwhals/expr.py b/narwhals/expr.py
index e6c6b5bd..d7aae157 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -2191,14 +2191,14 @@ class Expr:
             |3  4.0            6.0|
             └─────────────────────┘
         """
-        window_size, min_samples = _validate_rolling_arguments(
+        window_size, min_samples_int = _validate_rolling_arguments(
             window_size=window_size, min_samples=min_samples
         )
 
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx).rolling_sum(
                 window_size=window_size,
-                min_samples=min_samples,
+                min_samples=min_samples_int,
                 center=center,
             ),
             self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
diff --git a/narwhals/series.py b/narwhals/series.py
index 64c967d1..749b5d1a 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -2287,7 +2287,7 @@ class Series(Generic[IntoSeriesT]):
             3    7.0
             dtype: float64
         """
-        window_size, min_samples = _validate_rolling_arguments(
+        window_size, min_samples_int = _validate_rolling_arguments(
             window_size=window_size, min_samples=min_samples
         )
 
@@ -2297,7 +2297,7 @@ class Series(Generic[IntoSeriesT]):
         return self._from_compliant_series(
             self._compliant_series.rolling_sum(
                 window_size=window_size,
-                min_samples=min_samples,
+                min_samples=min_samples_int,
                 center=center,
             )
         )
diff --git a/narwhals/typing.py b/narwhals/typing.py
index 3c9f1da1..d6bd8d3d 100644
--- a/narwhals/typing.py
+++ b/narwhals/typing.py
@@ -244,7 +244,7 @@ class CompliantExpr(Protocol38[CompliantFrameT, CompliantSeriesT_co]):
         self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
     ) -> Self: ...
 
@@ -253,7 +253,7 @@ class CompliantExpr(Protocol38[CompliantFrameT, CompliantSeriesT_co]):
         self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
     ) -> Self: ...
 
@@ -262,7 +262,7 @@ class CompliantExpr(Protocol38[CompliantFrameT, CompliantSeriesT_co]):
         self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self: ...
@@ -272,7 +272,7 @@ class CompliantExpr(Protocol38[CompliantFrameT, CompliantSeriesT_co]):
         self,
         window_size: int,
         *,
-        min_samples: int | None,
+        min_samples: int,
         center: bool,
         ddof: int,
     ) -> Self: ...
diff --git a/pyproject.toml b/pyproject.toml
index 21a252f2..713e107f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -195,7 +195,7 @@ filterwarnings = [
   # This warning was temporarily raised by pandas but then reverted.
   'ignore:.*Passing a BlockManager to DataFrame:DeprecationWarning',
   # This warning was temporarily raised by Polars but then reverted.
-  'ignore:.*The default coalesce behavior of left join will change:DeprecationWarning:polars',
+  'ignore:.*The default coalesce behavior of left join will change:DeprecationWarning',
   'ignore: unclosed <socket.socket',
   'ignore:.*The distutils package is deprecated and slated for removal in Python 3.12:DeprecationWarning:pyspark',
   'ignore:.*distutils Version classes are deprecated. Use packaging.version instead.*:DeprecationWarning:pyspark',
@@ -204,7 +204,9 @@ filterwarnings = [
   'ignore:.*Python binding for RankQuantileOptions not exposed:RuntimeWarning:pyarrow',
   'ignore:.*pandas only supports SQLAlchemy:UserWarning:sqlframe',
   'ignore:.*numpy.core is deprecated and has been renamed to numpy._core.*:DeprecationWarning:sqlframe',
-  "ignore:.*__array__ implementation doesn't accept a copy keyword, so passing copy=False failed:DeprecationWarning:modin"
+  "ignore:.*__array__ implementation doesn't accept a copy keyword, so passing copy=False failed:DeprecationWarning:modin",
+  # raised internally by pandas
+  "ignore:.*np.find_common_type is deprecated:DeprecationWarning:pandas",
 ]
 xfail_strict = true
 markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"]


Test patch:
diff --git a/tests/expr_and_series/rolling_sum_test.py b/tests/expr_and_series/rolling_sum_test.py
index 2b9e273a..bcace792 100644
--- a/tests/expr_and_series/rolling_sum_test.py
+++ b/tests/expr_and_series/rolling_sum_test.py
@@ -12,6 +12,8 @@ from hypothesis import given
 import narwhals.stable.v1 as nw
 from narwhals.exceptions import InvalidOperationError
 from tests.utils import PANDAS_VERSION
+from tests.utils import POLARS_VERSION
+from tests.utils import Constructor
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
@@ -54,6 +56,109 @@ def test_rolling_sum_expr(constructor_eager: ConstructorEager) -> None:
     assert_equal_data(result, expected)
 
 
+@pytest.mark.filterwarnings(
+    "ignore:`Expr.rolling_sum` is being called from the stable API although considered an unstable feature."
+)
+@pytest.mark.parametrize(
+    ("expected_a", "window_size", "min_samples", "center"),
+    [
+        ([None, None, 3, None, None, 10, 17], 2, None, False),
+        ([None, None, 3, None, None, 10, 17], 2, 2, False),
+        ([None, None, 3, 3, 6, 10, 21], 3, 2, False),
+        ([1, None, 3, 3, 6, 10, 21], 3, 1, False),
+        ([3, 1, 3, 6, 10, 21, 17], 3, 1, True),
+        ([3, 1, 3, 7, 12, 21, 21], 4, 1, True),
+        ([3, 3, 7, 13, 23, 21, 21], 5, 1, True),
+    ],
+)
+def test_rolling_sum_expr_lazy_ungrouped(
+    constructor: Constructor,
+    expected_a: list[float],
+    window_size: int,
+    min_samples: int,
+    request: pytest.FixtureRequest,
+    *,
+    center: bool,
+) -> None:
+    if "polars" in str(constructor) and POLARS_VERSION < (1, 10):
+        pytest.skip()
+    if "duckdb" in str(constructor):
+        request.applymarker(pytest.mark.xfail)
+    if "modin" in str(constructor):
+        # unreliable
+        pytest.skip()
+    data = {
+        "a": [1, None, 2, None, 4, 6, 11],
+        "b": [1, None, 2, 3, 4, 5, 6],
+        "i": list(range(7)),
+    }
+    df = nw.from_native(constructor(data))
+    result = (
+        df.with_columns(
+            nw.col("a")
+            .rolling_sum(window_size, min_samples=min_samples, center=center)
+            .over(_order_by="b")
+        )
+        .select("a", "i")
+        .sort("i")
+    )
+    expected = {"a": expected_a, "i": list(range(7))}
+    assert_equal_data(result, expected)
+
+
+@pytest.mark.filterwarnings(
+    "ignore:`Expr.rolling_sum` is being called from the stable API although considered an unstable feature."
+)
+@pytest.mark.parametrize(
+    ("expected_a", "window_size", "min_samples", "center"),
+    [
+        ([None, None, 3, None, None, 10, 17], 2, None, False),
+        ([None, None, 3, None, None, 10, 17], 2, 2, False),
+        ([None, None, 3, 3, None, 10, 21], 3, 2, False),
+        ([1, None, 3, 3, 4, 10, 21], 3, 1, False),
+        ([3, 1, 3, 2, 10, 21, 17], 3, 1, True),
+        ([3, 1, 3, 3, 10, 21, 21], 4, 1, True),
+        ([3, 3, 3, 3, 21, 21, 21], 5, 1, True),
+    ],
+)
+def test_rolling_sum_expr_lazy_grouped(
+    constructor: Constructor,
+    expected_a: list[float],
+    window_size: int,
+    min_samples: int,
+    request: pytest.FixtureRequest,
+    *,
+    center: bool,
+) -> None:
+    if "polars" in str(constructor) and POLARS_VERSION < (1, 10):
+        pytest.skip()
+    if "pandas" in str(constructor) and PANDAS_VERSION < (1, 2):
+        pytest.skip()
+    if any(x in str(constructor) for x in ("dask", "pyarrow_table", "duckdb")):
+        request.applymarker(pytest.mark.xfail)
+    if "modin" in str(constructor):
+        # unreliable
+        pytest.skip()
+    data = {
+        "a": [1, None, 2, None, 4, 6, 11],
+        "g": [1, 1, 1, 1, 2, 2, 2],
+        "b": [1, None, 2, 3, 4, 5, 6],
+        "i": list(range(7)),
+    }
+    df = nw.from_native(constructor(data))
+    result = (
+        df.with_columns(
+            nw.col("a")
+            .rolling_sum(window_size, min_samples=min_samples, center=center)
+            .over("g", _order_by="b")
+        )
+        .sort("i")
+        .select("a")
+    )
+    expected = {"a": expected_a}
+    assert_equal_data(result, expected)
+
+
 @pytest.mark.filterwarnings(
     "ignore:`Series.rolling_sum` is being called from the stable API although considered an unstable feature."
 )