Repository: ibis-project/ibis

Issue:
bug(api): make `date` work with deferred `_` api
Currently you can't use `date(_.foo)` to make a deferred expression in `ibis`. Right now you have to use a `lambda`:

```python
t.mutate(year=lambda _: ibis.date(_.date_column).year())
```

We should make `date(_)` work so you can instead do:

```python
t.mutate(year=ibis.date(_.date_column).year())
```


Discussion:
I think to make this work we need to bake in deferred handling to rules. I believe this can be done in one or two places to make it work everywhere.

Resolving patch:
diff --git a/ibis/expr/api.py b/ibis/expr/api.py
--- a/ibis/expr/api.py
+++ b/ibis/expr/api.py
@@ -231,6 +231,17 @@
 )
 
 
+def _deferred(fn):
+    @functools.wraps(fn)
+    def wrapper(self, *args, **kwargs):
+        if isinstance(self, Deferred):
+            method = getattr(self, fn.__name__)
+            return method(*args, **kwargs)
+        return fn(self, *args, **kwargs)
+
+    return wrapper
+
+
 def param(type: dt.DataType) -> ir.Scalar:
     """Create a deferred parameter of a given type.
 
@@ -518,6 +529,11 @@ def _date_from_string(value: StringValue) -> DateValue:
     return value.cast(dt.date)
 
 
+@date.register(Deferred)
+def _date_from_deferred(value: Deferred) -> Deferred:
+    return value.date()
+
+
 @functools.singledispatch
 def time(value) -> TimeValue:
     return literal(value, type=dt.time)
@@ -539,6 +555,11 @@ def _time_from_string(value: StringValue) -> TimeValue:
     return value.cast(dt.time)
 
 
+@time.register(Deferred)
+def _time_from_deferred(value: Deferred) -> Deferred:
+    return value.time()
+
+
 def interval(
     value: int | datetime.timedelta | None = None,
     unit: str = 's',
@@ -681,36 +702,12 @@ def _add_methods(klass, method_table):
         setattr(klass, k, v)
 
 
-def where(
-    boolean_expr: ir.BooleanValue,
-    true_expr: ir.Value,
-    false_null_expr: ir.Value,
-) -> ir.Value:
-    """Return `true_expr` if `boolean_expr` is `True` else `false_null_expr`.
-
-    Parameters
-    ----------
-    boolean_expr
-        A boolean expression
-    true_expr
-        Value returned if `boolean_expr` is `True`
-    false_null_expr
-        Value returned if `boolean_expr` is `False` or `NULL`
-
-    Returns
-    -------
-    ir.Value
-        An expression
-    """
-    op = ops.Where(boolean_expr, true_expr, false_null_expr)
-    return op.to_expr()
-
-
-coalesce = ir.Value.coalesce
-greatest = ir.Value.greatest
-least = ir.Value.least
+coalesce = _deferred(ir.Value.coalesce)
+greatest = _deferred(ir.Value.greatest)
+least = _deferred(ir.Value.least)
 
 
+@_deferred
 def category_label(
     arg: ir.CategoryValue,
     labels: Sequence[str],
@@ -736,61 +733,61 @@ def category_label(
     return op.to_expr()
 
 
-geo_area = ir.GeoSpatialValue.area
-geo_as_binary = ir.GeoSpatialValue.as_binary
-geo_as_ewkb = ir.GeoSpatialValue.as_ewkb
-geo_as_ewkt = ir.GeoSpatialValue.as_ewkt
-geo_as_text = ir.GeoSpatialValue.as_text
-geo_azimuth = ir.GeoSpatialValue.azimuth
-geo_buffer = ir.GeoSpatialValue.buffer
-geo_centroid = ir.GeoSpatialValue.centroid
-geo_contains = ir.GeoSpatialValue.contains
-geo_contains_properly = ir.GeoSpatialValue.contains_properly
-geo_covers = ir.GeoSpatialValue.covers
-geo_covered_by = ir.GeoSpatialValue.covered_by
-geo_crosses = ir.GeoSpatialValue.crosses
-geo_d_fully_within = ir.GeoSpatialValue.d_fully_within
-geo_difference = ir.GeoSpatialValue.difference
-geo_disjoint = ir.GeoSpatialValue.disjoint
-geo_distance = ir.GeoSpatialValue.distance
-geo_d_within = ir.GeoSpatialValue.d_within
-geo_end_point = ir.GeoSpatialValue.end_point
-geo_envelope = ir.GeoSpatialValue.envelope
-geo_equals = ir.GeoSpatialValue.geo_equals
-geo_geometry_n = ir.GeoSpatialValue.geometry_n
-geo_geometry_type = ir.GeoSpatialValue.geometry_type
-geo_intersection = ir.GeoSpatialValue.intersection
-geo_intersects = ir.GeoSpatialValue.intersects
-geo_is_valid = ir.GeoSpatialValue.is_valid
-geo_line_locate_point = ir.GeoSpatialValue.line_locate_point
-geo_line_merge = ir.GeoSpatialValue.line_merge
-geo_line_substring = ir.GeoSpatialValue.line_substring
-geo_length = ir.GeoSpatialValue.length
-geo_max_distance = ir.GeoSpatialValue.max_distance
-geo_n_points = ir.GeoSpatialValue.n_points
-geo_n_rings = ir.GeoSpatialValue.n_rings
-geo_ordering_equals = ir.GeoSpatialValue.ordering_equals
-geo_overlaps = ir.GeoSpatialValue.overlaps
-geo_perimeter = ir.GeoSpatialValue.perimeter
-geo_point = ir.NumericValue.point
-geo_point_n = ir.GeoSpatialValue.point_n
-geo_set_srid = ir.GeoSpatialValue.set_srid
-geo_simplify = ir.GeoSpatialValue.simplify
-geo_srid = ir.GeoSpatialValue.srid
-geo_start_point = ir.GeoSpatialValue.start_point
-geo_touches = ir.GeoSpatialValue.touches
-geo_transform = ir.GeoSpatialValue.transform
-geo_union = ir.GeoSpatialValue.union
-geo_within = ir.GeoSpatialValue.within
-geo_x = ir.GeoSpatialValue.x
-geo_x_max = ir.GeoSpatialValue.x_max
-geo_x_min = ir.GeoSpatialValue.x_min
-geo_y = ir.GeoSpatialValue.y
-geo_y_max = ir.GeoSpatialValue.y_max
-geo_y_min = ir.GeoSpatialValue.y_min
-geo_unary_union = ir.GeoSpatialColumn.unary_union
-
-ifelse = ir.BooleanValue.ifelse
+geo_area = _deferred(ir.GeoSpatialValue.area)
+geo_as_binary = _deferred(ir.GeoSpatialValue.as_binary)
+geo_as_ewkb = _deferred(ir.GeoSpatialValue.as_ewkb)
+geo_as_ewkt = _deferred(ir.GeoSpatialValue.as_ewkt)
+geo_as_text = _deferred(ir.GeoSpatialValue.as_text)
+geo_azimuth = _deferred(ir.GeoSpatialValue.azimuth)
+geo_buffer = _deferred(ir.GeoSpatialValue.buffer)
+geo_centroid = _deferred(ir.GeoSpatialValue.centroid)
+geo_contains = _deferred(ir.GeoSpatialValue.contains)
+geo_contains_properly = _deferred(ir.GeoSpatialValue.contains_properly)
+geo_covers = _deferred(ir.GeoSpatialValue.covers)
+geo_covered_by = _deferred(ir.GeoSpatialValue.covered_by)
+geo_crosses = _deferred(ir.GeoSpatialValue.crosses)
+geo_d_fully_within = _deferred(ir.GeoSpatialValue.d_fully_within)
+geo_difference = _deferred(ir.GeoSpatialValue.difference)
+geo_disjoint = _deferred(ir.GeoSpatialValue.disjoint)
+geo_distance = _deferred(ir.GeoSpatialValue.distance)
+geo_d_within = _deferred(ir.GeoSpatialValue.d_within)
+geo_end_point = _deferred(ir.GeoSpatialValue.end_point)
+geo_envelope = _deferred(ir.GeoSpatialValue.envelope)
+geo_equals = _deferred(ir.GeoSpatialValue.geo_equals)
+geo_geometry_n = _deferred(ir.GeoSpatialValue.geometry_n)
+geo_geometry_type = _deferred(ir.GeoSpatialValue.geometry_type)
+geo_intersection = _deferred(ir.GeoSpatialValue.intersection)
+geo_intersects = _deferred(ir.GeoSpatialValue.intersects)
+geo_is_valid = _deferred(ir.GeoSpatialValue.is_valid)
+geo_line_locate_point = _deferred(ir.GeoSpatialValue.line_locate_point)
+geo_line_merge = _deferred(ir.GeoSpatialValue.line_merge)
+geo_line_substring = _deferred(ir.GeoSpatialValue.line_substring)
+geo_length = _deferred(ir.GeoSpatialValue.length)
+geo_max_distance = _deferred(ir.GeoSpatialValue.max_distance)
+geo_n_points = _deferred(ir.GeoSpatialValue.n_points)
+geo_n_rings = _deferred(ir.GeoSpatialValue.n_rings)
+geo_ordering_equals = _deferred(ir.GeoSpatialValue.ordering_equals)
+geo_overlaps = _deferred(ir.GeoSpatialValue.overlaps)
+geo_perimeter = _deferred(ir.GeoSpatialValue.perimeter)
+geo_point = _deferred(ir.NumericValue.point)
+geo_point_n = _deferred(ir.GeoSpatialValue.point_n)
+geo_set_srid = _deferred(ir.GeoSpatialValue.set_srid)
+geo_simplify = _deferred(ir.GeoSpatialValue.simplify)
+geo_srid = _deferred(ir.GeoSpatialValue.srid)
+geo_start_point = _deferred(ir.GeoSpatialValue.start_point)
+geo_touches = _deferred(ir.GeoSpatialValue.touches)
+geo_transform = _deferred(ir.GeoSpatialValue.transform)
+geo_union = _deferred(ir.GeoSpatialValue.union)
+geo_within = _deferred(ir.GeoSpatialValue.within)
+geo_x = _deferred(ir.GeoSpatialValue.x)
+geo_x_max = _deferred(ir.GeoSpatialValue.x_max)
+geo_x_min = _deferred(ir.GeoSpatialValue.x_min)
+geo_y = _deferred(ir.GeoSpatialValue.y)
+geo_y_max = _deferred(ir.GeoSpatialValue.y_max)
+geo_y_min = _deferred(ir.GeoSpatialValue.y_min)
+geo_unary_union = _deferred(ir.GeoSpatialColumn.unary_union)
+
+where = ifelse = _deferred(ir.BooleanValue.ifelse)
 
 # ----------------------------------------------------------------------
 # Category API
diff --git a/ibis/expr/types/logical.py b/ibis/expr/types/logical.py
--- a/ibis/expr/types/logical.py
+++ b/ibis/expr/types/logical.py
@@ -41,12 +41,12 @@ def ifelse(
         SELECT CASE WHEN `is_person` THEN 'yes' ELSE 'no' END AS `tmp`
         FROM t
         """
-        import ibis
+        import ibis.expr.operations as ops
 
         # Result will be the result of promotion of true/false exprs. These
         # might be conflicting types; same type resolution as case expressions
         # must be used.
-        return ibis.case().when(self, true_expr).else_(false_expr).end()
+        return ops.Where(self, true_expr, false_expr).to_expr()
 
     def __and__(self, other: BooleanValue) -> BooleanValue:
         from ibis.expr import operations as ops


Test patch:
diff --git a/ibis/backends/clickhouse/tests/test_select.py b/ibis/backends/clickhouse/tests/test_select.py
--- a/ibis/backends/clickhouse/tests/test_select.py
+++ b/ibis/backends/clickhouse/tests/test_select.py
@@ -221,7 +221,7 @@ def test_isnull_case_expr_rewrite_failure(db, alltypes):
 
     result = ibis.clickhouse.compile(reduction)
     expected = """\
-SELECT sum(CASE WHEN isNull(`string_col`) THEN 1 ELSE 0 END) AS `sum`
+SELECT sum(if(isNull(`string_col`), 1, 0)) AS `sum`
 FROM {0}.`functional_alltypes`"""
     assert result == expected.format(db.name)
 
diff --git a/ibis/backends/impala/tests/test_case_exprs.py b/ibis/backends/impala/tests/test_case_exprs.py
--- a/ibis/backends/impala/tests/test_case_exprs.py
+++ b/ibis/backends/impala/tests/test_case_exprs.py
@@ -37,12 +37,12 @@ def test_isnull_1_0(table):
     expr = table.g.isnull().ifelse(1, 0)
 
     result = translate(expr)
-    expected = 'CASE WHEN `g` IS NULL THEN 1 ELSE 0 END'
+    expected = 'if(`g` IS NULL, 1, 0)'
     assert result == expected
 
     # inside some other function
     result = translate(expr.sum())
-    expected = 'sum(CASE WHEN `g` IS NULL THEN 1 ELSE 0 END)'
+    expected = 'sum(if(`g` IS NULL, 1, 0))'
     assert result == expected
 
 
diff --git a/ibis/backends/impala/tests/test_exprs.py b/ibis/backends/impala/tests/test_exprs.py
--- a/ibis/backends/impala/tests/test_exprs.py
+++ b/ibis/backends/impala/tests/test_exprs.py
@@ -704,8 +704,7 @@ def test_where_with_timestamp():
     )
     result = ibis.impala.compile(expr)
     expected = """\
-SELECT `uuid`,
-       min(CASE WHEN `search_level` = 1 THEN `ts` ELSE NULL END) AS `min_date`
+SELECT `uuid`, min(if(`search_level` = 1, `ts`, NULL)) AS `min_date`
 FROM t
 GROUP BY 1"""
     assert result == expected
@@ -766,7 +765,7 @@ def test_nunique_where():
     t = ibis.table([('key', 'string'), ('value', 'double')], name='t0')
     expr = t.key.nunique(where=t.value >= 1.0)
     expected = """\
-SELECT count(DISTINCT CASE WHEN `value` >= 1.0 THEN `key` ELSE NULL END) AS `nunique`
+SELECT count(DISTINCT if(`value` >= 1.0, `key`, NULL)) AS `nunique`
 FROM t0"""  # noqa: E501
     result = ibis.impala.compile(expr)
     assert result == expected
diff --git a/ibis/backends/impala/tests/test_unary_builtins.py b/ibis/backends/impala/tests/test_unary_builtins.py
--- a/ibis/backends/impala/tests/test_unary_builtins.py
+++ b/ibis/backends/impala/tests/test_unary_builtins.py
@@ -161,11 +161,7 @@ def test_hash(table):
 def test_reduction_where(table, expr_fn, func_name):
     expr = expr_fn(table)
     result = translate(expr)
-    expected = (
-        f'{func_name}'
-        '(CASE WHEN `bigint_col` < 70 THEN `double_col` '
-        'ELSE NULL END)'
-    )
+    expected = f'{func_name}(if(`bigint_col` < 70, `double_col`, NULL))'
     assert result == expected
 
 
diff --git a/ibis/tests/expr/test_value_exprs.py b/ibis/tests/expr/test_value_exprs.py
--- a/ibis/tests/expr/test_value_exprs.py
+++ b/ibis/tests/expr/test_value_exprs.py
@@ -1566,3 +1566,38 @@ def test_array_length_scalar():
     value = ibis.literal(raw_value)
     expr = value.length()
     assert isinstance(expr.op(), ops.ArrayLength)
+
+
+@pytest.mark.parametrize(
+    ("func", "expected_type"),
+    [
+        param(
+            ibis.timestamp,
+            dt.timestamp,
+            id="timestamp",
+            marks=pytest.mark.xfail(
+                raises=NotImplementedError,
+                reason=(
+                    "`ibis.timestamp` isn't implemented for expression inputs"
+                ),
+            ),
+        ),
+        param(ibis.date, dt.date, id="date"),
+        param(ibis.time, dt.time, id="time"),
+        param(ibis.coalesce, dt.timestamp, id="coalesce"),
+        param(ibis.greatest, dt.timestamp, id="greatest"),
+        param(ibis.least, dt.timestamp, id="least"),
+    ]
+    + [
+        param(
+            lambda ts: func(ts.notnull(), ts, ts - ibis.interval(days=1)),
+            dt.timestamp,
+            id=func.__name__,
+        )
+        for func in (ibis.ifelse, ibis.where)
+    ],
+)
+def test_deferred_function_call(func, expected_type):
+    t = ibis.table(dict(ts="timestamp"), name="t")
+    expr = t.select(col=func(_.ts))
+    assert expr["col"].type() == expected_type
diff --git a/ibis/tests/sql/test_non_tabular_results.py b/ibis/tests/sql/test_non_tabular_results.py
--- a/ibis/tests/sql/test_non_tabular_results.py
+++ b/ibis/tests/sql/test_non_tabular_results.py
@@ -135,6 +135,6 @@ def test_isnull_case_expr_rewrite_failure(alltypes):
 
     result = Compiler.to_sql(reduction)
     expected = """\
-SELECT sum(CASE WHEN `g` IS NULL THEN 1 ELSE 0 END) AS `sum`
+SELECT sum(if(`g` IS NULL, 1, 0)) AS `sum`
 FROM alltypes"""
     assert result == expected