Repository: ibis-project/ibis

Issue:
BUG: Range window with order by breaks for base_sql backend
What's wrong
=====
When compiling a range_window in backend based on `base_sql` (BigQuery in the example here), an exception will be thrown as:

```
in /ibis/backends/base_sql/__init__.py in format_window(translator, op, window)
--> 833  if window.max_lookback is not None:

AttributeError: 'NoneType' object has no attribute 'max_lookback'
```
It turns out to be `window` is a `None` object. 
By following the stacktrace, in https://github.com/ibis-project/ibis/blob/master/ibis/backends/base_sql/__init__.py#L821 function `time_range_to_range_window` doesn't have a return value but it is referenced here: https://github.com/ibis-project/ibis/blob/master/ibis/backends/base_sql/__init__.py#L981 as ` window = time_range_to_range_window(translator, window)`

Seems a `window` object should be returned in ` time_range_to_range_window`.

Code to Reproduce
=====
```
import datetime
import ibis
from ibis.expr.types import TableExpr
import ibis.expr.operations as ops

def _get_ibis_client():
    ibis_client = ibis.bigquery.BigQueryClient
    setattr(ibis_client, "__init__", lambda t: None)
    ibis_client = ibis_client()
    return ibis_client

def _get_test_table(ibis_client):
    names = ['Class',                 
  'end_of_period',         
  'M_Total_Dollars_Total',
            ]
    types = ['string', "date", 'float64']
    schema = ibis.Schema(names, types)
    t = TableExpr(ops.SQLQueryResult("select * from t", schema, ibis_client))
    return t

def test_window_compile():
    ibis_client = _get_ibis_client()
    t = _get_test_table(ibis_client)
    key_cols = ['Class', 'end_of_period']
    order_col = 'end_of_period'
    window = ibis.range_window(
        preceding=ibis.interval(days=62),
        order_by=t[order_col],
        group_by=[t['Class']],
    )
    t = t.mutate(window=t["M_Total_Dollars_Total"].sum().over(window))
    t.compile()
    return t

test_window_compile()
```



Discussion:

Resolving patch:
diff --git a/ibis/backends/base_sql/__init__.py b/ibis/backends/base_sql/__init__.py
--- a/ibis/backends/base_sql/__init__.py
+++ b/ibis/backends/base_sql/__init__.py
@@ -8,7 +8,8 @@
 import itertools
 import math
 from io import StringIO
-from typing import Optional
+from operator import add, mul, sub
+from typing import Optional, Union
 
 import ibis
 import ibis.common.exceptions as com
@@ -733,6 +734,77 @@ def from_unixtime(translator, expr):
 # Semi/anti-join supports
 
 
+_map_interval_to_microseconds = {
+    'W': 604800000000,
+    'D': 86400000000,
+    'h': 3600000000,
+    'm': 60000000,
+    's': 1000000,
+    'ms': 1000,
+    'us': 1,
+    'ns': 0.001,
+}
+
+
+_map_interval_op_to_op = {
+    # Literal Intervals have two args, i.e.
+    # Literal(1, Interval(value_type=int8, unit='D', nullable=True))
+    # Parse both args and multipy 1 * _map_interval_to_microseconds['D']
+    ops.Literal: mul,
+    ops.IntervalMultiply: mul,
+    ops.IntervalAdd: add,
+    ops.IntervalSubtract: sub,
+}
+
+
+def _replace_interval_with_scalar(
+    expr: Union[ir.Expr, dt.Interval, float]
+) -> Union[ir.FloatingScalar, float]:
+    """
+    Good old Depth-First Search to identify the Interval and IntervalValue
+    components of the expression and return a comparable scalar expression.
+
+    Parameters
+    ----------
+    expr : float or expression of intervals
+        For example, ``ibis.interval(days=1) + ibis.interval(hours=5)``
+
+    Returns
+    -------
+    preceding : float or ir.FloatingScalar, depending upon the expr
+    """
+    try:
+        expr_op = expr.op()
+    except AttributeError:
+        expr_op = None
+
+    if not isinstance(expr, (dt.Interval, ir.IntervalValue)):
+        # Literal expressions have op method but native types do not.
+        if isinstance(expr_op, ops.Literal):
+            return expr_op.value
+        else:
+            return expr
+    elif isinstance(expr, dt.Interval):
+        try:
+            microseconds = _map_interval_to_microseconds[expr.unit]
+            return microseconds
+        except KeyError:
+            raise ValueError(
+                "Expected preceding values of week(), "
+                + "day(), hour(), minute(), second(), millisecond(), "
+                + "microseconds(), nanoseconds(); got {}".format(expr)
+            )
+    elif expr_op.args and isinstance(expr, ir.IntervalValue):
+        if len(expr_op.args) > 2:
+            raise com.NotImplementedError(
+                "'preceding' argument cannot be parsed."
+            )
+        left_arg = _replace_interval_with_scalar(expr_op.args[0])
+        right_arg = _replace_interval_with_scalar(expr_op.args[1])
+        method = _map_interval_op_to_op[type(expr_op)]
+        return method(left_arg, right_arg)
+
+
 def exists_subquery(translator, expr):
     op = expr.op()
     ctx = translator.context
@@ -826,6 +898,18 @@ def time_range_to_range_window(translator, window):
             "Expected 1 order-by variable, got {}".format(len(order_by_vars))
         )
 
+    order_var = window._order_by[0].op().args[0]
+    timestamp_order_var = order_var.cast('int64')
+    window = window._replace(order_by=timestamp_order_var, how='range')
+
+    # Need to change preceding interval expression to scalars
+    preceding = window.preceding
+    if isinstance(preceding, ir.IntervalScalar):
+        new_preceding = _replace_interval_with_scalar(preceding)
+        window = window._replace(preceding=new_preceding)
+
+    return window
+
 
 def format_window(translator, op, window):
     components = []
diff --git a/ibis/backends/base_sql/compiler.py b/ibis/backends/base_sql/compiler.py
--- a/ibis/backends/base_sql/compiler.py
+++ b/ibis/backends/base_sql/compiler.py
@@ -1,10 +1,5 @@
-from operator import add, mul, sub
-
 import ibis.backends.base_sqlalchemy.compiler as comp
-import ibis.common.exceptions as com
-import ibis.expr.datatypes as dt
 import ibis.expr.operations as ops
-import ibis.expr.types as ir
 from ibis.backends.base_sql import (
     binary_infix_ops,
     operation_registry,
@@ -92,75 +87,6 @@ def _quote_identifier(self, name):
         return quote_identifier(name)
 
 
-_map_interval_to_microseconds = {
-    'W': 604800000000,
-    'D': 86400000000,
-    'h': 3600000000,
-    'm': 60000000,
-    's': 1000000,
-    'ms': 1000,
-    'us': 1,
-    'ns': 0.001,
-}
-
-
-_map_interval_op_to_op = {
-    # Literal Intervals have two args, i.e.
-    # Literal(1, Interval(value_type=int8, unit='D', nullable=True))
-    # Parse both args and multipy 1 * _map_interval_to_microseconds['D']
-    ops.Literal: mul,
-    ops.IntervalMultiply: mul,
-    ops.IntervalAdd: add,
-    ops.IntervalSubtract: sub,
-}
-
-
-def _replace_interval_with_scalar(expr):
-    """
-    Good old Depth-First Search to identify the Interval and IntervalValue
-    components of the expression and return a comparable scalar expression.
-
-    Parameters
-    ----------
-    expr : float or expression of intervals
-        For example, ``ibis.interval(days=1) + ibis.interval(hours=5)``
-
-    Returns
-    -------
-    preceding : float or ir.FloatingScalar, depending upon the expr
-    """
-    try:
-        expr_op = expr.op()
-    except AttributeError:
-        expr_op = None
-
-    if not isinstance(expr, (dt.Interval, ir.IntervalValue)):
-        # Literal expressions have op method but native types do not.
-        if isinstance(expr_op, ops.Literal):
-            return expr_op.value
-        else:
-            return expr
-    elif isinstance(expr, dt.Interval):
-        try:
-            microseconds = _map_interval_to_microseconds[expr.unit]
-            return microseconds
-        except KeyError:
-            raise ValueError(
-                "Expected preceding values of week(), "
-                + "day(), hour(), minute(), second(), millisecond(), "
-                + "microseconds(), nanoseconds(); got {}".format(expr)
-            )
-    elif expr_op.args and isinstance(expr, ir.IntervalValue):
-        if len(expr_op.args) > 2:
-            raise com.NotImplementedError(
-                "'preceding' argument cannot be parsed."
-            )
-        left_arg = _replace_interval_with_scalar(expr_op.args[0])
-        right_arg = _replace_interval_with_scalar(expr_op.args[1])
-        method = _map_interval_op_to_op[type(expr_op)]
-        return method(left_arg, right_arg)
-
-
 _operation_registry = {**operation_registry, **binary_infix_ops}
 
 


Test patch:
diff --git a/ibis/backends/tests/test_window.py b/ibis/backends/tests/test_window.py
--- a/ibis/backends/tests/test_window.py
+++ b/ibis/backends/tests/test_window.py
@@ -488,3 +488,51 @@ def test_ungrouped_unbounded_window(
     left, right = result.val, expected.val
 
     backend.assert_series_equal(left, right)
+
+
+@pytest.mark.xfail_unsupported
+# Postgres and Impala do not support range window bounded on both sides
+@pytest.mark.xfail_backends(['postgres', 'impala'])
+@pytest.mark.skip_backends(
+    ['pandas', 'csv', 'parquet', 'pyspark'], reason='Issue #2709'
+)
+def test_grouped_bounded_range_window(backend, alltypes, df, con):
+    if not backend.supports_window_operations:
+        pytest.skip(
+            'Backend {} does not support window operations'.format(backend)
+        )
+
+    # Explanation of the range window spec below:
+    #
+    # `preceding=10, following=0, order_by='id'``:
+    #     The window at a particular row (call its `id` value x) will contain
+    #     some other row (call its `id` value y) if x-10 <= y <= x.
+    # `group_by='string_col'`:
+    #     The window at a particular row will only contain other rows that
+    #     have the same 'string_col' value.
+    #
+    window = ibis.range_window(
+        preceding=10, following=0, order_by='id', group_by='string_col',
+    )
+    expr = alltypes.mutate(val=alltypes.double_col.sum().over(window))
+    result = expr.execute().set_index('id').sort_index()
+
+    expected = (
+        df.assign(
+            # Mimic our range window spec using .apply()
+            val=df.apply(
+                lambda x: df.double_col[
+                    (df.string_col == x.string_col)  # Grouping by string_col
+                    & ((x.id - 10) <= df.id)  # Corresponds to `preceding=10`
+                    & (df.id <= x.id)  # Corresponds to `following=0`
+                ].sum(),
+                axis=1,
+            )
+        )
+        .set_index('id')
+        .sort_index()
+    )
+
+    left, right = result.val, expected.val
+
+    backend.assert_series_equal(left, right)