Repository: ibis-project/ibis

Issue:
bug(snowflake): `nth_value` behavior changed in `the-epic-split`
### What happened?

The `nth` APIs results changed for the Snowflake backend after `the-epic-split` was merged to `main`.

Let's get to the bottom of what happened.

### What version of ibis are you using?

`main`

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

Snowflake

```
pytest 'ibis/backends/tests/test_window.py::test_grouped_bounded_expanding_window[snowflake-nth]' --pdb --runxfail
```

should produce the failing test case.

### Relevant log output

_No response_

### Code of Conduct

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


Discussion:
Some information:

https://docs.snowflake.com/en/sql-reference/functions-analytic#window-frame-usage-notes

Snowflake's default window for rank-related functions deviates from the SQL standard **and** forcing it to be match the standard fails with an error from Snowflake saying range windows are unsupported in `NTH_VALUE`.

I'm looking into special casing those functions in `_minimize_spec` to see if we can insert something reasonable there.

It's possible I may need to override snowflake window function compilation rule entirely to handle this mess.

Resolving patch:
diff --git a/ibis/backends/snowflake/compiler.py b/ibis/backends/snowflake/compiler.py
--- a/ibis/backends/snowflake/compiler.py
+++ b/ibis/backends/snowflake/compiler.py
@@ -480,6 +480,48 @@ def visit_Xor(self, op, *, left, right):
         # boolxor accepts numerics ... and returns a boolean? wtf?
         return self.f.boolxor(self.cast(left, dt.int8), self.cast(right, dt.int8))
 
+    def visit_Window(self, op, *, how, func, start, end, group_by, order_by):
+        if start is None:
+            start = {}
+        if end is None:
+            end = {}
+
+        start_value = start.get("value", "UNBOUNDED")
+        start_side = start.get("side", "PRECEDING")
+        end_value = end.get("value", "UNBOUNDED")
+        end_side = end.get("side", "FOLLOWING")
+
+        if getattr(start_value, "this", None) == "0":
+            start_value = "CURRENT ROW"
+            start_side = None
+
+        if getattr(end_value, "this", None) == "0":
+            end_value = "CURRENT ROW"
+            end_side = None
+
+        spec = sge.WindowSpec(
+            kind=how.upper(),
+            start=start_value,
+            start_side=start_side,
+            end=end_value,
+            end_side=end_side,
+            over="OVER",
+        )
+        order = sge.Order(expressions=order_by) if order_by else None
+
+        orig_spec = spec
+        spec = self._minimize_spec(op.start, op.end, orig_spec)
+
+        # due to https://docs.snowflake.com/en/sql-reference/functions-analytic#window-frame-usage-notes
+        # we need to make the default window rows (since range isn't supported)
+        # and we need to make the default frame unbounded preceding to current
+        # row
+        if spec is None and isinstance(op.func, (ops.First, ops.Last, ops.NthValue)):
+            spec = orig_spec
+            spec.args["kind"] = "ROWS"
+
+        return sge.Window(this=func, partition_by=group_by, order=order, spec=spec)
+
     def visit_WindowBoundary(self, op, *, value, preceding):
         if not isinstance(op.value, ops.Literal):
             raise com.OperationNotDefinedError(


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
@@ -202,11 +202,6 @@ def calc_zscore(s):
                 pytest.mark.notimpl(["dask"], raises=com.OperationNotDefinedError),
                 pytest.mark.notimpl(["flink"], raises=com.OperationNotDefinedError),
                 pytest.mark.notimpl(["risingwave"], raises=PsycoPg2InternalError),
-                pytest.mark.broken(
-                    ["snowflake"],
-                    raises=AssertionError,
-                    reason="behavior of windows with nth_value has changed; unclear whether that is in ibis or in snowflake itself",
-                ),
             ],
         ),
         param(