Repository: ibis-project/ibis

Issue:
bug: Issue with `ibis.range_window(following=(1, None), order_by="timestamp")` when following How to `asof_join`
### What happened?

I'm following https://github.com/ibis-project/ibis/wiki/%60asof_join%60-in-SQL to create a How to `asof_join` guide on the website (per #4385), but am running into an error with the `range_window` function.

code:

```python
import ibis
from ibis import _

path = "https://storage.googleapis.com/ibis-tutorial-data/wowah_data/wowah_data.csv"
data = ibis.read(path, timestampformat='%m/%d/%y %H:%M:%S')

win = ibis.range_window(following=(1, None), order_by="timestamp")
class_ = data.mutate(timeend=data.timestamp.min().over(win))

df = class_.execute()
```

yields a type error in python (`int(` accepts str, bytes, numeric and cannot be tuple)

setting `following` to a non-tuple (int, 1) yields an invalid type error in the engine (function types not defined [timestamp, tinyint →timestamp]).

Let me know if you need the second case's S/T.  It's not included because that's not how the guide is written.

### What version of ibis are you using?

latest/main

### What backend(s) are you using, if any?

DuckDB (`ibis.read`) and Postgres (`ibis.table`)

### Relevant log output

```sh
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In [1], line 10
      7 win = ibis.range_window(following=(1, None), order_by="timestamp")
      8 class_ = data.mutate(timeend=data.timestamp.min().over(win))
---> 10 df = class_.execute()

File ~/dev/repos/ibis/ibis/expr/types/core.py:274, in Expr.execute(self, limit, timecontext, params, **kwargs)
    249 def execute(
    250     self,
    251     limit: int | str | None = 'default',
   (...)
    254     **kwargs: Any,
    255 ):
    256     """Execute an expression against its backend if one exists.
    257 
    258     Parameters
   (...)
    272         Mapping of scalar parameter expressions to value
    273     """
--> 274     return self._find_backend(use_default=True).execute(
    275         self, limit=limit, timecontext=timecontext, params=params, **kwargs
    276     )

File ~/dev/repos/ibis/ibis/backends/base/sql/__init__.py:251, in BaseSQLBackend.execute(self, expr, params, limit, **kwargs)
    249 kwargs.pop('timecontext', None)
    250 query_ast = self.compiler.to_ast_ensure_limit(expr, limit, params=params)
--> 251 sql = query_ast.compile()
    252 self._log(sql)
    254 schema = self.ast_schema(query_ast, **kwargs)

File ~/dev/repos/ibis/ibis/backends/base/sql/compiler/base.py:39, in QueryAST.compile(self)
     37 def compile(self):
     38     compiled_setup_queries = [q.compile() for q in self.setup_queries]
---> 39     compiled_queries = [q.compile() for q in self.queries]
     40     compiled_teardown_queries = [q.compile() for q in self.teardown_queries]
     41     return self.context.collapse(
     42         list(
     43             chain(
   (...)
     48         )
     49     )

File ~/dev/repos/ibis/ibis/backends/base/sql/compiler/base.py:39, in <listcomp>(.0)
     37 def compile(self):
     38     compiled_setup_queries = [q.compile() for q in self.setup_queries]
---> 39     compiled_queries = [q.compile() for q in self.queries]
     40     compiled_teardown_queries = [q.compile() for q in self.teardown_queries]
     41     return self.context.collapse(
     42         list(
     43             chain(
   (...)
     48         )
     49     )

File ~/dev/repos/ibis/ibis/backends/base/sql/alchemy/query_builder.py:161, in AlchemySelect.compile(self)
    152 steps = [
    153     self._add_select,
    154     self._add_group_by,
   (...)
    157     self._add_limit,
    158 ]
    160 for step in steps:
--> 161     frag = step(frag)
    163 return frag

File ~/dev/repos/ibis/ibis/backends/base/sql/alchemy/query_builder.py:191, in AlchemySelect._add_select(self, table_set)
    189 for op in self.select_set:
    190     if isinstance(op, ops.Value):
--> 191         arg = self._translate(op, named=True)
    192     elif isinstance(op, ops.TableNode):
    193         if op.equals(self.table_set):

File ~/dev/repos/ibis/ibis/backends/base/sql/compiler/query_builder.py:221, in Select._translate(self, expr, named, permit_subquery)
    214 def _translate(self, expr, named=False, permit_subquery=False):
    215     translator = self.translator_class(
    216         expr,
    217         context=self.context,
    218         named=named,
    219         permit_subquery=permit_subquery,
    220     )
--> 221     return translator.get_result()

File ~/dev/repos/ibis/ibis/backends/base/sql/compiler/translator.py:210, in ExprTranslator.get_result(self)
    208 def get_result(self):
    209     """Compile SQL expression into a string."""
--> 210     translated = self.translate(self.node)
    211     if self._needs_name(self.node):
    212         # TODO: this could fail in various ways
    213         translated = self.name(translated, self.node.name)

File ~/dev/repos/ibis/ibis/backends/base/sql/compiler/translator.py:242, in ExprTranslator.translate(self, op)
    240 elif type(op) in self._registry:
    241     formatter = self._registry[type(op)]
--> 242     return formatter(self, op)
    243 else:
    244     raise com.OperationNotDefinedError(f'No translation rule for {type(op)}')

File ~/dev/repos/ibis/ibis/backends/base/sql/alchemy/registry.py:213, in _alias(t, op)
    210 def _alias(t, op):
    211     # just compile the underlying argument because the naming is handled
    212     # by the translator for the top level expression
--> 213     return t.translate(op.arg)

File ~/dev/repos/ibis/ibis/backends/base/sql/compiler/translator.py:242, in ExprTranslator.translate(self, op)
    240 elif type(op) in self._registry:
    241     formatter = self._registry[type(op)]
--> 242     return formatter(self, op)
    243 else:
    244     raise com.OperationNotDefinedError(f'No translation rule for {type(op)}')

File ~/dev/repos/ibis/ibis/backends/base/sql/alchemy/registry.py:357, in _window(t, op)
    346 preceding = window.preceding
    347 additional_params = (
    348     {}
    349     if t._forbids_frame_clause and isinstance(window_op, t._forbids_frame_clause)
   (...)
    355     }
    356 )
--> 357 result = reduction.over(
    358     partition_by=partition_by, order_by=order_by, **additional_params
    359 )
    361 if isinstance(window_op, (ops.RowNumber, ops.DenseRank, ops.MinRank, ops.NTile)):
    362     return result - 1

File ~/dev/envs/dev/lib/python3.10/site-packages/sqlalchemy/sql/functions.py:359, in FunctionElement.over(self, partition_by, order_by, rows, range_)
    335 def over(self, partition_by=None, order_by=None, rows=None, range_=None):
    336     """Produce an OVER clause against this function.
    337 
    338     Used against aggregate or so-called "window" functions,
   (...)
    357 
    358     """
--> 359     return Over(
    360         self,
    361         partition_by=partition_by,
    362         order_by=order_by,
    363         rows=rows,
    364         range_=range_,
    365     )

File ~/dev/envs/dev/lib/python3.10/site-packages/sqlalchemy/sql/elements.py:4204, in Over.__init__(self, element, partition_by, order_by, range_, rows)
   4198     self.partition_by = ClauseList(
   4199         *util.to_list(partition_by),
   4200         _literal_as_text_role=roles.ByOfRole
   4201     )
   4203 if range_:
-> 4204     self.range_ = self._interpret_range(range_)
   4205     if rows:
   4206         raise exc.ArgumentError(
   4207             "'range_' and 'rows' are mutually exclusive"
   4208         )

File ~/dev/envs/dev/lib/python3.10/site-packages/sqlalchemy/sql/elements.py:4250, in Over._interpret_range(self, range_)
   4248 else:
   4249     try:
-> 4250         upper = int(range_[1])
   4251     except ValueError as err:
   4252         util.raise_(
   4253             exc.ArgumentError(
   4254                 "Integer or None expected for range value"
   4255             ),
   4256             replace_context=err,
   4257         )

TypeError: int() argument must be a string, a bytes-like object or a real number, not 'tuple'
```


### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
Thanks @p-a-a-a-trick. This looks like a bug. We'll fix it up for 4.0.0.

Resolving patch:
diff --git a/ibis/backends/base/sql/alchemy/registry.py b/ibis/backends/base/sql/alchemy/registry.py
--- a/ibis/backends/base/sql/alchemy/registry.py
+++ b/ibis/backends/base/sql/alchemy/registry.py
@@ -333,6 +333,18 @@ def _window(t, op):
             'for SQLAlchemy-based backends.'
         )
 
+    # Checks for invalid user input e.g. passing in tuple for preceding and
+    # non-None value for following are caught and raised in expr/window.py
+    # if we're here, then the input is valid, we just need to interpret it
+    # correctly
+    if isinstance(window.preceding, tuple):
+        start, end = map(lambda x: -1 * x if x is not None else None, window.preceding)
+    elif isinstance(window.following, tuple):
+        start, end = window.following
+    else:
+        start = -window.preceding if window.preceding is not None else window.preceding
+        end = window.following
+
     # Some analytic functions need to have the expression of interest in
     # the ORDER BY part of the window clause
     if isinstance(window_op, t._require_order_by) and not window._order_by:
@@ -343,16 +355,10 @@ def _window(t, op):
     partition_by = [t.translate(arg) for arg in window._group_by]
 
     how = {'range': 'range_'}.get(window.how, window.how)
-    preceding = window.preceding
     additional_params = (
         {}
         if t._forbids_frame_clause and isinstance(window_op, t._forbids_frame_clause)
-        else {
-            how: (
-                -preceding if preceding is not None else preceding,
-                window.following,
-            )
-        }
+        else {how: (start, end)}
     )
     result = reduction.over(
         partition_by=partition_by, order_by=order_by, **additional_params
diff --git a/ibis/backends/pyspark/compiler.py b/ibis/backends/pyspark/compiler.py
--- a/ibis/backends/pyspark/compiler.py
+++ b/ibis/backends/pyspark/compiler.py
@@ -1125,26 +1125,38 @@ def compile_window_op(t, op, **kwargs):
     aggcontext = AggregationContext.WINDOW
     pyspark_window = Window.partitionBy(grouping_keys).orderBy(ordering_keys)
 
+    # Checks for invalid user input e.g. passing in tuple for preceding and
+    # non-None value for following are caught and raised in expr/window.py
+    # if we're here, then the input is valid, we just need to interpret it
+    # correctly
+    if isinstance(window.preceding, tuple):
+        start, end = window.preceding
+    elif isinstance(window.following, tuple):
+        start, end = window.following
+    else:
+        start = window.preceding
+        end = window.following
+
     # If the operand is a shift op (e.g. lead, lag), Spark will set the window
     # bounds. Only set window bounds here if not a shift operation.
     if not isinstance(operand, ops.ShiftBase):
-        if window.preceding is None:
-            start = Window.unboundedPreceding
+        if start is None:
+            win_start = Window.unboundedPreceding
         else:
-            start = -_canonicalize_interval(t, window.preceding, **kwargs)
-        if window.following is None:
-            end = Window.unboundedFollowing
+            win_start = -_canonicalize_interval(t, start, **kwargs)
+        if end is None:
+            win_end = Window.unboundedFollowing
         else:
-            end = _canonicalize_interval(t, window.following, **kwargs)
+            win_end = _canonicalize_interval(t, end, **kwargs)
 
         if (
-            isinstance(window.preceding, ir.IntervalScalar)
-            or isinstance(window.following, ir.IntervalScalar)
+            isinstance(start, ir.IntervalScalar)
+            or isinstance(end, ir.IntervalScalar)
             or window.how == "range"
         ):
-            pyspark_window = pyspark_window.rangeBetween(start, end)
+            pyspark_window = pyspark_window.rangeBetween(win_start, win_end)
         else:
-            pyspark_window = pyspark_window.rowsBetween(start, end)
+            pyspark_window = pyspark_window.rowsBetween(win_start, win_end)
 
     res_op = operand
     if isinstance(res_op, (ops.NotAll, ops.NotAny)):


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
@@ -312,11 +312,18 @@ def test_ungrouped_bounded_expanding_window(
     backend.assert_series_equal(left, right)
 
 
+@pytest.mark.parametrize(
+    "preceding, following",
+    [
+        (0, 2),
+        (None, (0, 2)),
+    ],
+)
 @pytest.mark.notimpl(["dask", "datafusion", "pandas", "polars"])
-def test_grouped_bounded_following_window(backend, alltypes, df):
+def test_grouped_bounded_following_window(backend, alltypes, df, preceding, following):
     window = ibis.window(
-        preceding=0,
-        following=2,
+        preceding=preceding,
+        following=following,
         group_by=[alltypes.string_col],
         order_by=[alltypes.id],
     )
@@ -359,6 +366,16 @@ def test_grouped_bounded_following_window(backend, alltypes, df):
             ),
             id='preceding-2-following-0',
         ),
+        param(
+            lambda t: ibis.window(
+                preceding=(2, 0),
+                following=None,
+                group_by=[t.string_col],
+                order_by=[t.id],
+            ),
+            id='preceding-2-following-0-tuple',
+            marks=pytest.mark.notimpl(["pandas"]),
+        ),
         param(
             lambda t: ibis.trailing_window(
                 preceding=2, group_by=[t.string_col], order_by=[t.id]