Repository: ibis-project/ibis

Issue:
bug: cumulative window vanishes when grouping
The `RANGE BETWEEN` statement created by `cumulative_window` seems to be lost in a `group_by`:

    In [1]: import ibis

    In [2]: print(ibis.__version__)
    0.13.0

    In [3]: t = ibis.table([('one', 'string'), ('two', 'double'), ('three', 'int32')], 'my_data')

    In [4]: w = ibis.cumulative_window(order_by=t.one)

    In [5]: print(ibis.impala.compile(t.mutate(four=t.two.sum().over(w))))
    SELECT *,
           sum(`two`) OVER (ORDER BY `one` RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS `four`
    FROM my_data
    
    In [6]: print(ibis.impala.compile(t.group_by(t.three).mutate(four=t.two.sum().over(w))))
    SELECT *, sum(`two`) OVER (PARTITION BY `three` ORDER BY `one`) AS `four`
    FROM my_data

Maybe a check somewhere for falsey-ness of `window.following` is conflating `0` with `None`? Truthy values seem to work:

    In [7]: w2 = ibis.window(following=1, order_by=t.one)
    
    In [8]: print(ibis.impala.compile(t.group_by(t.three).mutate(four=t.two.sum().over(w2))))
    SELECT *,
           sum(`two`) OVER (PARTITION BY `three` ORDER BY `one` ROWS BETWEEN UNBOUNDED PRECEDING AND 1 FOLLOWING) AS `four`
    FROM my_data


Discussion:
This appears to be a bug in the sqlite and postgres compilers as well, though it manifests a little differently:

```
In [22]: print(ibis.sqlite.compile(t.group_by(t.three).mutate(four=t.two.sum().over(w))))
SELECT t0.one, t0.two, t0.three, sum(t0.two) OVER (PARTITION BY t0.three ORDER BY t0.one ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS four
FROM my_data AS t0
```

`UNBOUNDED FOLLOWING` is incorrect and should be `CURRENT ROW` for a cumulative window.
The immediate workaround is to pass the partitioning column into the window construction:

```
In [27]: w = ibis.cumulative_window(order_by=t.one, group_by=t.three)

In [28]: print(ibis.sqlite.compile(t.mutate(four=t.two.sum().over(w))))
SELECT t0.one, t0.two, t0.three, sum(t0.two) OVER (PARTITION BY t0.three ORDER BY t0.one ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS four
FROM my_data AS t0
```
This also works for the impala backend:

```
In [29]: print(ibis.impala.compile(t.mutate(four=t.two.sum().over(w))))
SELECT *,
       sum(`two`) OVER (PARTITION BY `three` ORDER BY `one` RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS `four`
FROM my_data
```

Resolving patch:
diff --git a/ibis/expr/analysis.py b/ibis/expr/analysis.py
--- a/ibis/expr/analysis.py
+++ b/ibis/expr/analysis.py
@@ -806,7 +806,7 @@ def _windowize(x, w):
             return walked.over(w)
         elif isinstance(op, ops.WindowOp):
             if w is not None:
-                return walked.over(w)
+                return walked.over(w.combine(op.window))
             else:
                 return walked
         else:
diff --git a/ibis/expr/window.py b/ibis/expr/window.py
--- a/ibis/expr/window.py
+++ b/ibis/expr/window.py
@@ -5,6 +5,7 @@
 
 import numpy as np
 import pandas as pd
+import toolz
 
 import ibis.common.exceptions as com
 import ibis.expr.types as ir
@@ -103,21 +104,24 @@ def __init__(
     ):
         import ibis.expr.operations as ops
 
-        if group_by is None:
-            group_by = []
-
-        if order_by is None:
-            order_by = []
+        self._group_by = list(
+            toolz.unique(
+                util.promote_list([] if group_by is None else group_by),
+                key=lambda value: getattr(value, "_key", value),
+            )
+        )
 
-        self._group_by = util.promote_list(group_by)
+        _order_by = []
+        for expr in util.promote_list([] if order_by is None else order_by):
+            if isinstance(expr, ir.Expr) and not isinstance(expr, ir.SortExpr):
+                expr = ops.SortKey(expr).to_expr()
+            _order_by.append(expr)
 
-        self._order_by = []
-        for x in util.promote_list(order_by):
-            if isinstance(x, ir.SortExpr):
-                pass
-            elif isinstance(x, ir.Expr):
-                x = ops.SortKey(x).to_expr()
-            self._order_by.append(x)
+        self._order_by = list(
+            toolz.unique(
+                _order_by, key=lambda value: getattr(value, "_key", value)
+            )
+        )
 
         if isinstance(preceding, RowsWithMaxLookback):
             # the offset interval is used as the 'preceding' value of a window


Test patch:
diff --git a/ibis/backends/impala/tests/test_sql.py b/ibis/backends/impala/tests/test_sql.py
--- a/ibis/backends/impala/tests/test_sql.py
+++ b/ibis/backends/impala/tests/test_sql.py
@@ -551,3 +551,18 @@ def test_join_key_name2(tpch):
   INNER JOIN t1 t2
     ON t1.`year` = t2.`year`"""
     assert result == expected
+
+
+def test_group_by_with_window_preserves_range():
+    t = ibis.table(
+        [('one', 'string'), ('two', 'double'), ('three', 'int32')],
+        name='my_data',
+    )
+    w = ibis.cumulative_window(order_by=t.one)
+    expr = t.group_by(t.three).mutate(four=t.two.sum().over(w))
+    result = ibis.impala.compile(expr)
+    expected = """\
+SELECT *,
+       sum(`two`) OVER (PARTITION BY `three` ORDER BY `one` RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS `four`
+FROM my_data"""  # noqa: E501
+    assert result == expected
diff --git a/ibis/tests/expr/test_window_functions.py b/ibis/tests/expr/test_window_functions.py
--- a/ibis/tests/expr/test_window_functions.py
+++ b/ibis/tests/expr/test_window_functions.py
@@ -349,3 +349,13 @@ def test_determine_how():
 
     with pytest.raises(TypeError):
         _determine_how([3, 5])
+
+
+def test_combine_preserves_existing_window():
+    t = ibis.table(
+        [('one', 'string'), ('two', 'double'), ('three', 'int32')],
+        name='my_data',
+    )
+    w = ibis.cumulative_window(order_by=t.one)
+    mut = t.group_by(t.three).mutate(four=t.two.sum().over(w))
+    assert mut.op().selections[1].op().window.following == 0