Repository: ibis-project/ibis

Issue:
TypeError when calling `.order_by(ibis.desc(ibis._.x))`
Context: the `ibis._` deferred attribute resolver was introduced in #3804. However it does not always interact with other ibis constructs as one would have expected:

Reproducer:

The following works as expected (ascending `order_by` on deferred column name):

```python
>>> import pandas as pd
... import ibis
... from ibis import _
... 
... table = ibis.memtable(pd.DataFrame({"x": list("abbac"), "y": range(5)}))
... (
...    table
...    .group_by(_.x)
...    .aggregate(mean_y=_.y.mean())
...    .order_by(_.mean_y)
... ).execute()
   x  mean_y
0  a     1.5
1  b     1.5
2  c     4.0
```

but combining this with `ibis.desc` leads to:

```python
>>> (
...    table
...    .group_by(_.x)
...    .aggregate(mean_y=_.y.mean())
...    .order_by(ibis.desc(_.mean_y))
... ).execute()
Traceback (most recent call last):
  Input In [38] in <cell line: 2>
    .order_by(ibis.desc(_.mean_y))
  File ~/code/ibis/ibis/expr/types/relations.py:479 in order_by
    return self.op().order_by(by).to_expr()
  File ~/code/ibis/ibis/expr/operations/relations.py:474 in order_by
    keys = rlz.tuple_of(rlz.sort_key_from(rlz.just(self)), sort_exprs)
  File ~/mambaforge/envs/dev/lib/python3.10/site-packages/toolz/functoolz.py:304 in __call__
    return self._partial(*args, **kwargs)
  File ~/code/ibis/ibis/common/validators.py:166 in container_of
    return type(inner(item, **kwargs) for item in arg)
  File ~/code/ibis/ibis/common/validators.py:166 in <genexpr>
    return type(inner(item, **kwargs) for item in arg)
  File ~/code/ibis/ibis/expr/rules.py:88 in __call__
    result = super().__call__(*args, **kwargs)
  File ~/mambaforge/envs/dev/lib/python3.10/site-packages/toolz/functoolz.py:304 in __call__
    return self._partial(*args, **kwargs)
  File ~/code/ibis/ibis/expr/rules.py:126 in sort_key_from
    key = function_of(table_ref, key, **kwargs)
  File ~/code/ibis/ibis/expr/rules.py:88 in __call__
    result = super().__call__(*args, **kwargs)
  File ~/mambaforge/envs/dev/lib/python3.10/site-packages/toolz/functoolz.py:304 in __call__
    return self._partial(*args, **kwargs)
  File ~/code/ibis/ibis/expr/rules.py:518 in function_of
    arg = fn.resolve(arg)
  File ~/code/ibis/ibis/expr/deferred.py:32 in resolve
    resolved_expr = self.resolve(expr)
  File ~/code/ibis/ibis/expr/deferred.py:32 in resolve
    resolved_expr = self.resolve(expr)
  File ~/code/ibis/ibis/expr/deferred.py:37 in resolve
    return func(resolved_expr, *resolved_args, **resolved_kwargs)
  File ~/code/ibis/ibis/expr/deferred.py:49 in <lambda>
    lambda expr, *args, **kwargs: expr(*args, **kwargs),
TypeError: 'FloatingColumn' object is not callable
```

Replacing the deferred resolver by the str name works:

```python
>>> (
...    table
...    .group_by(_.x)
...    .aggregate(mean_y=_.y.mean())
...    .order_by(ibis.desc("mean_y"))
... ).execute()
   x  mean_y
0  c     4.0
1  a     1.5
2  b     1.5
```

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/ibis-project/ibis/pull/4728?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project) Report
> Merging [#4728](https://codecov.io/gh/ibis-project/ibis/pull/4728?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project) (c539ad0) into [master](https://codecov.io/gh/ibis-project/ibis/commit/dbc54f8f9ba132c455263824632f2eb70fbf2671?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project) (dbc54f8) will **increase** coverage by `14.65%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/ibis-project/ibis/pull/4728/graphs/tree.svg?width=650&height=150&src=pr&token=sE9aT1Le0L&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project)](https://codecov.io/gh/ibis-project/ibis/pull/4728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project)

```diff
@@             Coverage Diff             @@
##           master    #4728       +/-   ##
===========================================
+ Coverage   77.11%   91.76%   +14.65%     
===========================================
  Files         192      192               
  Lines       21419    21421        +2     
  Branches     2965     2964        -1     
===========================================
+ Hits        16518    19658     +3140     
+ Misses       4537     1353     -3184     
- Partials      364      410       +46     
```

| [Impacted Files](https://codecov.io/gh/ibis-project/ibis/pull/4728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project) | Coverage Δ | |
|---|---|---|
| [ibis/expr/api.py](https://codecov.io/gh/ibis-project/ibis/pull/4728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL2FwaS5weQ==) | `96.65% <100.00%> (+0.02%)` | :arrow_up: |
| [ibis/expr/operations/relations.py](https://codecov.io/gh/ibis-project/ibis/pull/4728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL29wZXJhdGlvbnMvcmVsYXRpb25zLnB5) | `98.04% <0.00%> (+0.32%)` | :arrow_up: |
| [ibis/expr/types/generic.py](https://codecov.io/gh/ibis-project/ibis/pull/4728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL3R5cGVzL2dlbmVyaWMucHk=) | `91.32% <0.00%> (+0.41%)` | :arrow_up: |
| [ibis/expr/types/numeric.py](https://codecov.io/gh/ibis-project/ibis/pull/4728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL3R5cGVzL251bWVyaWMucHk=) | `98.20% <0.00%> (+0.44%)` | :arrow_up: |
| [ibis/backends/base/sql/alchemy/registry.py](https://codecov.io/gh/ibis-project/ibis/pull/4728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9iYWNrZW5kcy9iYXNlL3NxbC9hbGNoZW15L3JlZ2lzdHJ5LnB5) | `97.01% <0.00%> (+0.74%)` | :arrow_up: |
| [ibis/expr/types/strings.py](https://codecov.io/gh/ibis-project/ibis/pull/4728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL3R5cGVzL3N0cmluZ3MucHk=) | `90.59% <0.00%> (+0.85%)` | :arrow_up: |
| [ibis/backends/base/\_\_init\_\_.py](https://codecov.io/gh/ibis-project/ibis/pull/4728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9iYWNrZW5kcy9iYXNlL19faW5pdF9fLnB5) | `89.61% <0.00%> (+0.86%)` | :arrow_up: |
| [ibis/expr/operations/reductions.py](https://codecov.io/gh/ibis-project/ibis/pull/4728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL29wZXJhdGlvbnMvcmVkdWN0aW9ucy5weQ==) | `98.78% <0.00%> (+1.21%)` | :arrow_up: |
| [ibis/expr/datatypes/core.py](https://codecov.io/gh/ibis-project/ibis/pull/4728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL2RhdGF0eXBlcy9jb3JlLnB5) | `95.66% <0.00%> (+1.53%)` | :arrow_up: |
| [ibis/expr/datatypes/cast.py](https://codecov.io/gh/ibis-project/ibis/pull/4728/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL2RhdGF0eXBlcy9jYXN0LnB5) | `94.53% <0.00%> (+1.56%)` | :arrow_up: |
| ... and [38 more](https://codecov.io/gh/ibis-project/ibis/pull/4728/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project) | |

github-actions[bot]: ## Test Results
         6 files           6 suites   6m 23s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "duration of all tests")
  3 258 tests   3 232 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests")   26 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") 0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests")
19 494 runs  19 338 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests") 156 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") 0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests")

Results for commit c539ad00.

[test-results]:data:application/gzip;base64,H4sIAHWAWWMC/03MSw6DIBSF4a0Yxh1cuYDQzTQ8JCH10SCMmu69aIU6/L+TnDfxYRo3cu/ErSNbDqmFy1GnsC4lUWKBMqV9RMplzceWrT0M6d+e4VWMiiZeh6kINBhjXOMpMS/7ba+YYmfW214hyma/256LKpfbo6+vdp3nkEoQy1FpBzAAR0OBG6VRejcww0aHTgIzwoOy5PMF/XnqZA4BAAA=


Patch:
diff --git a/ibis/expr/api.py b/ibis/expr/api.py
index 3f28548ec..003029062 100644
--- a/ibis/expr/api.py
+++ b/ibis/expr/api.py
@@ -6,7 +6,7 @@ import datetime
 import functools
 import itertools
 import operator
-from typing import Iterable, Mapping, Sequence
+from typing import Iterable, Literal, Mapping, Sequence
 from typing import Tuple as _Tuple
 from typing import TypeVar
 from typing import Union as _Union
@@ -481,6 +481,19 @@ def _memtable_from_dataframe(
     return op.to_expr()
 
 
+def _sort_order(expr, order: Literal["desc"] | Literal["asc"]):
+    method = operator.methodcaller(order)
+    if isinstance(expr, str):
+        value = _[expr]
+    elif isinstance(expr, Deferred):
+        value = expr
+    elif callable(expr):
+        value = expr(_)
+    else:
+        value = expr
+    return method(value)
+
+
 def desc(expr: ir.Column | str) -> ir.Value:
     """Create a descending sort key from `expr` or column name.
 
@@ -510,12 +523,7 @@ def desc(expr: ir.Column | str) -> ir.Value:
     ir.ValueExpr
         An expression
     """
-    if isinstance(expr, str):
-        return _[expr].desc()
-    elif callable(expr):
-        return expr(_).desc()
-    else:
-        return expr.desc()
+    return _sort_order(expr, "desc")
 
 
 def asc(expr: ir.Column | str) -> ir.Value:
@@ -547,12 +555,7 @@ def asc(expr: ir.Column | str) -> ir.Value:
     ir.ValueExpr
         An expression
     """
-    if isinstance(expr, str):
-        return _[expr].asc()
-    elif callable(expr):
-        return expr(_).asc()
-    else:
-        return expr.asc()
+    return _sort_order(expr, "asc")
 
 
 def and_(*predicates: ir.BooleanValue) -> ir.BooleanValue:


Test patch:
diff --git a/ibis/tests/expr/test_value_exprs.py b/ibis/tests/expr/test_value_exprs.py
index d798a62e7..aa2927b0b 100644
--- a/ibis/tests/expr/test_value_exprs.py
+++ b/ibis/tests/expr/test_value_exprs.py
@@ -1649,3 +1649,12 @@ def test_case_rlz_incompatible_error(table):
         table.g.case().when(case == result, result).end()
 
     assert "a:int8 and Literal(foo):string" in str(exc_info.value)
+
+
+@pytest.mark.parametrize("func", [ibis.asc, ibis.desc])
+def test_group_by_order_by_deferred(func):
+    from ibis import _
+
+    table = ibis.table(dict(x="string", y="int"), name="t")
+    expr = table.group_by(_.x).aggregate(mean_y=_.y.mean()).order_by(func(_.mean_y))
+    assert isinstance(expr, ir.Table)