Repository: ibis-project/ibis

Issue:
feat: add support for ibis.date(y, m, d) using deferred
`ibis.date` errors out if you refer to parent expressions using `_`.

Expected: can pass `_['year'], _['month'], _['day']` to `ibis.date` to get a date expressions, just as you would `t['year'], t['month'], t['day']`

Actual: type error

MWE:

```python
import ibis
from ibis import _
import pandas as pd

cols = ['date_id', 'date_year', 'date_month', 'date_day']
vals = [[1, 2021, 8, 4], [2, 2021, 8, 26], [3, 2022, 8, 3], [4, 2022, 8, 25]]

df = pd.DataFrame(vals, columns=cols)

conn = ibis.pandas.connect({'dates': df})
dates_base = conn.table("dates")

# Works
dates_works = (
    dates_base
    .mutate(date_value=ibis.date(dates_base['date_year'], dates_base['date_month'], dates_base['date_day']))
)

# Does not works
dates_notworks = (
    dates_base
    .mutate(date_value=ibis.date(_['date_year'], _['date_month'], _['date_day']))
)
```

Error:

```python
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In [1], line 22
     14 dates_works = (
     15     dates_base
     16     .mutate(date_value=ibis.date(dates_base['date_year'], dates_base['date_month'], dates_base['date_day']))
     17 )
     19 # Does not works
     20 dates_notworks = (
     21     dates_base
---> 22     .mutate(date_value=ibis.date(_['date_year'], _['date_month'], _['date_day']))
     23 )

File /usr/lib/python3.10/functools.py:889, in singledispatch.<locals>.wrapper(*args, **kw)
    885 if not args:
    886     raise TypeError(f'{funcname} requires at least '
    887                     '1 positional argument')
--> 889 return dispatch(args[0].__class__)(*args, **kw)

TypeError: _date_from_deferred() takes 1 positional argument but 3 were given
```


Discussion:
Ah, so this is only implemented for the use case of `ibis.date(thing) -> thing.date()`, extracting the date from a non-date column. We haven't implemented the variadic case of `date`. This would be a new feature.
This is a tricky, but possibly fun issue to address. `Deferred` instances are not supported as inputs to `ops.Node`s because `ops.Node`s eagerly check their inputs' type and `Deferred` instances' types are not known until their `.resolve` method is called.

It's not clear to me exactly how to make these two things--`Deferred` and `ops.Node` behavior--work well together.
@cpcloud Postgres works in some cases.  Using the same data uploaded to postgres:

```python
dates_base = conn.table("dates")

dates = (
    dates_base
    .mutate(date_value=ibis.date(dates_base['date_year'], dates_base['date_month'], dates_base['date_day']))
)
```

yields `date_value` with the correct date value
xref #4382 
Closing in favor of #4382.

Resolving patch:
diff --git a/ibis/expr/api.py b/ibis/expr/api.py
--- a/ibis/expr/api.py
+++ b/ibis/expr/api.py
@@ -6,7 +6,7 @@
 import functools
 import numbers
 import operator
-from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
+from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, overload
 
 import ibis.expr.builders as bl
 import ibis.expr.datatypes as dt
@@ -19,7 +19,7 @@
 from ibis.common.exceptions import IbisInputError
 from ibis.common.temporal import normalize_datetime, normalize_timezone
 from ibis.expr.decompile import decompile
-from ibis.expr.deferred import Deferred
+from ibis.expr.deferred import Deferred, deferrable
 from ibis.expr.schema import Schema
 from ibis.expr.sql import parse_sql, show_sql, to_sql
 from ibis.expr.streaming import Watermark
@@ -27,6 +27,7 @@
     DateValue,
     Expr,
     Table,
+    TimestampValue,
     TimeValue,
     array,
     literal,
@@ -606,107 +607,247 @@ def random() -> ir.FloatingScalar:
     return ops.RandomScalar().to_expr()
 
 
-def timestamp(value, *args, timezone: str | None = None) -> ir.TimestampScalar:
-    """Return a timestamp literal if `value` is coercible to a timestamp.
+@overload
+def timestamp(
+    value_or_year: int | ir.IntegerValue | Deferred,
+    month: int | ir.IntegerValue | Deferred,
+    day: int | ir.IntegerValue | Deferred,
+    hour: int | ir.IntegerValue | Deferred,
+    minute: int | ir.IntegerValue | Deferred,
+    second: int | ir.IntegerValue | Deferred,
+    /,
+    timezone: str | None = None,
+) -> TimestampValue:
+    ...
+
+
+@overload
+def timestamp(value_or_year: Any, /, timezone: str | None = None) -> TimestampValue:
+    ...
+
+
+@deferrable
+def timestamp(
+    value_or_year,
+    month=None,
+    day=None,
+    hour=None,
+    minute=None,
+    second=None,
+    /,
+    timezone=None,
+):
+    """Construct a timestamp scalar or column.
 
     Parameters
     ----------
-    value
-        Timestamp string, datetime object or numeric value
-    args
-        Additional arguments if `value` is numeric
+    value_or_year
+        Either a string value or `datetime.datetime` to coerce to a timestamp,
+        or an integral value representing the timestamp year component.
+    month
+        The timestamp month component; required if `value_or_year` is a year.
+    day
+        The timestamp day component; required if `value_or_year` is a year.
+    hour
+        The timestamp hour component; required if `value_or_year` is a year.
+    minute
+        The timestamp minute component; required if `value_or_year` is a year.
+    second
+        The timestamp second component; required if `value_or_year` is a year.
     timezone
-        Timezone name
+        The timezone name, or none for a timezone-naive timestamp.
 
     Returns
     -------
-    TimestampScalar
+    TimestampValue
         A timestamp expression
 
     Examples
     --------
     >>> import ibis
     >>> ibis.options.interactive = True
-    >>> ibis.timestamp("2021-01-01 00:00:00")
-    Timestamp('2021-01-01 00:00:00')
+
+    Create a timestamp scalar from a string
+
+    >>> ibis.timestamp("2023-01-02T03:04:05")
+    Timestamp('2023-01-02 03:04:05')
+
+    Create a timestamp scalar from components
+
+    >>> ibis.timestamp(2023, 1, 2, 3, 4, 5)
+    Timestamp('2023-01-02 03:04:05')
+
+    Create a timestamp column from components
+
+    >>> t = ibis.memtable({"y": [2001, 2002], "m": [1, 4], "d": [2, 5], "h": [3, 6]})
+    >>> ibis.timestamp(t.y, t.m, t.d, t.h, 0, 0).name("timestamp")
+    ┏━━━━━━━━━━━━━━━━━━━━━┓
+    ┃ timestamp           ┃
+    ┡━━━━━━━━━━━━━━━━━━━━━┩
+    │ timestamp           │
+    ├─────────────────────┤
+    │ 2001-01-02 03:00:00 │
+    │ 2002-04-05 06:00:00 │
+    └─────────────────────┘
     """
-    if isinstance(value, (numbers.Real, ir.IntegerValue)):
-        if timezone:
-            raise NotImplementedError("timestamp timezone not implemented")
-        if not args:
-            raise TypeError(f"Use ibis.literal({value}).to_timestamp() instead")
-        return ops.TimestampFromYMDHMS(value, *args).to_expr()
-    elif isinstance(value, (Deferred, ir.Expr)):
-        # TODO(kszucs): could call .cast(dt.timestamp) for certain value expressions
-        raise NotImplementedError(
-            "`ibis.timestamp` isn't implemented for expression inputs"
-        )
+    args = (value_or_year, month, day, hour, minute, second)
+    is_ymdhms = any(a is not None for a in args[1:])
+
+    if is_ymdhms:
+        if timezone is not None:
+            raise NotImplementedError(
+                "Timezone currently not supported when creating a timestamp from components"
+            )
+        return ops.TimestampFromYMDHMS(*args).to_expr()
+    elif isinstance(value_or_year, (numbers.Real, ir.IntegerValue)):
+        raise TypeError("Use ibis.literal(...).to_timestamp() instead")
+    elif isinstance(value_or_year, ir.Expr):
+        return value_or_year.cast(dt.Timestamp(timezone=timezone))
     else:
-        value = normalize_datetime(value)
+        value = normalize_datetime(value_or_year)
         tzinfo = normalize_timezone(timezone or value.tzinfo)
         timezone = tzinfo.tzname(value) if tzinfo is not None else None
         return literal(value, type=dt.Timestamp(timezone=timezone))
 
 
-def date(value, *args) -> DateValue:
-    """Return a date literal if `value` is coercible to a date.
+@overload
+def date(
+    value_or_year: int | ir.IntegerValue | Deferred,
+    month: int | ir.IntegerValue | Deferred,
+    day: int | ir.IntegerValue | Deferred,
+    /,
+) -> DateValue:
+    ...
+
+
+@overload
+def date(value_or_year: Any, /) -> DateValue:
+    ...
+
+
+@deferrable
+def date(value_or_year, month=None, day=None, /):
+    """Construct a date scalar or column.
 
     Parameters
     ----------
-    value
-        Date string, datetime object or numeric value
-    args
-        Month and day if `value` is a year
+    value_or_year
+        Either a string value or `datetime.date` to coerce to a date, or
+        an integral value representing the date year component.
+    month
+        The date month component; required if `value_or_year` is a year.
+    day
+        The date day component; required if `value_or_year` is a year.
 
     Returns
     -------
-    DateScalar
+    DateValue
         A date expression
+
+    Examples
+    --------
+    >>> import ibis
+    >>> ibis.options.interactive = True
+
+    Create a date scalar from a string
+
+    >>> ibis.date("2023-01-02")
+    Timestamp('2023-01-02 00:00:00')
+
+    Create a date scalar from year, month, and day
+
+    >>> ibis.date(2023, 1, 2)
+    Timestamp('2023-01-02 00:00:00')
+
+    Create a date column from year, month, and day
+
+    >>> t = ibis.memtable({"y": [2001, 2002], "m": [1, 3], "d": [2, 4]})
+    >>> ibis.date(t.y, t.m, t.d).name("date")
+    ┏━━━━━━━━━━━━┓
+    ┃ date       ┃
+    ┡━━━━━━━━━━━━┩
+    │ date       │
+    ├────────────┤
+    │ 2001-01-02 │
+    │ 2002-03-04 │
+    └────────────┘
     """
-    if isinstance(value, (numbers.Real, ir.IntegerValue)):
-        year, month, day = value, *args
-        return ops.DateFromYMD(year, month, day).to_expr()
-    elif isinstance(value, ir.StringValue):
-        return value.cast(dt.date)
-    elif isinstance(value, Deferred):
-        return value.date()
+    if month is not None or day is not None:
+        return ops.DateFromYMD(value_or_year, month, day).to_expr()
+    elif isinstance(value_or_year, ir.Expr):
+        return value_or_year.cast(dt.date)
     else:
-        return literal(value, type=dt.date)
+        return literal(value_or_year, type=dt.date)
+
+
+@overload
+def time(
+    value_or_hour: int | ir.IntegerValue | Deferred,
+    minute: int | ir.IntegerValue | Deferred,
+    second: int | ir.IntegerValue | Deferred,
+    /,
+) -> TimeValue:
+    ...
+
+
+@overload
+def time(value_or_hour: Any, /) -> TimeValue:
+    ...
 
 
-def time(value, *args) -> TimeValue:
+@deferrable
+def time(value_or_hour, minute=None, second=None, /):
     """Return a time literal if `value` is coercible to a time.
 
     Parameters
     ----------
-    value
-        Time string
-    args
-        Minutes, seconds if `value` is an hour
+    value_or_hour
+        Either a string value or `datetime.time` to coerce to a time, or
+        an integral value representing the time hour component.
+    minute
+        The time minute component; required if `value_or_hour` is an hour.
+    second
+        The time second component; required if `value_or_hour` is an hour.
 
     Returns
     -------
-    TimeScalar
+    TimeValue
         A time expression
 
     Examples
     --------
     >>> import ibis
     >>> ibis.options.interactive = True
-    >>> ibis.time("00:00:00")
-    datetime.time(0, 0)
-    >>> ibis.time(12, 15, 30)
-    datetime.time(12, 15, 30)
+
+    Create a time scalar from a string
+
+    >>> ibis.time("01:02:03")
+    datetime.time(1, 2, 3)
+
+    Create a time scalar from hour, minute, and second
+
+    >>> ibis.time(1, 2, 3)
+    datetime.time(1, 2, 3)
+
+    Create a time column from hour, minute, and second
+
+    >>> t = ibis.memtable({"h": [1, 4], "m": [2, 5], "s": [3, 6]})
+    >>> ibis.time(t.h, t.m, t.s).name("time")
+    ┏━━━━━━━━━━┓
+    ┃ time     ┃
+    ┡━━━━━━━━━━┩
+    │ time     │
+    ├──────────┤
+    │ 01:02:03 │
+    │ 04:05:06 │
+    └──────────┘
     """
-    if isinstance(value, (numbers.Real, ir.IntegerValue)):
-        hours, mins, secs = value, *args
-        return ops.TimeFromHMS(hours, mins, secs).to_expr()
-    elif isinstance(value, ir.StringValue):
-        return value.cast(dt.time)
-    elif isinstance(value, Deferred):
-        return value.time()
+    if minute is not None or second is not None:
+        return ops.TimeFromHMS(value_or_hour, minute, second).to_expr()
+    elif isinstance(value_or_hour, ir.Expr):
+        return value_or_hour.cast(dt.time)
     else:
-        return literal(value, type=dt.time)
+        return literal(value_or_hour, type=dt.time)
 
 
 def interval(
diff --git a/ibis/expr/deferred.py b/ibis/expr/deferred.py
--- a/ibis/expr/deferred.py
+++ b/ibis/expr/deferred.py
@@ -1,7 +1,9 @@
 from __future__ import annotations
 
+import functools
+import inspect
 import operator
-from typing import Any, Callable, NoReturn
+from typing import Any, Callable, NoReturn, TypeVar
 
 _BINARY_OPS: dict[str, Callable[[Any, Any], Any]] = {
     "+": operator.add,
@@ -220,7 +222,10 @@ def __init__(self, func: Any, *args: Any, **kwargs: Any) -> None:
     def __repr__(self) -> str:
         params = [repr(a) for a in self._args]
         params.extend(f"{k}={v!r}" for k, v in self._kwargs.items())
-        return f"{self._func!r}({', '.join(params)})"
+        # Repr directly wrapped functions as their name, fallback to repr for
+        # deferred objects or callables without __name__ otherwise
+        func = getattr(self._func, "__name__", "") or repr(self._func)
+        return f"{func}({', '.join(params)})"
 
     def _resolve(self, param: Any) -> Any:
         func = _resolve(self._func, param)
@@ -272,6 +277,35 @@ def _resolve(expr: Deferred, param: Any) -> Any:
     return expr
 
 
+F = TypeVar("F", bound=Callable)
+
+
+def deferrable(func: F) -> F:
+    """Wrap a top-level expr function to support deferred arguments.
+
+    When a deferrable function is called, if any of the direct args or kwargs
+    is a `Deferred` value, then the result is also `Deferred`. Otherwise the
+    function is called directly.
+    """
+    # Parse the signature of func so we can validate deferred calls eagerly,
+    # erroring for invalid/missing arguments at call time not resolve time.
+    sig = inspect.signature(func)
+
+    @functools.wraps(func)
+    def inner(*args, **kwargs):
+        is_deferred = any(isinstance(a, Deferred) for a in args) or any(
+            isinstance(v, Deferred) for v in kwargs.values()
+        )
+        if is_deferred:
+            # Try to bind the arguments now, raising a nice error
+            # immediately if the function was called incorrectly
+            sig.bind(*args, **kwargs)
+            return deferred_apply(func, *args, **kwargs)
+        return func(*args, **kwargs)
+
+    return inner
+
+
 def deferred_apply(func: Callable, *args: Any, **kwargs: Any) -> Deferred:
     """Construct a deferred call from a callable and arguments.
 


Test patch:
diff --git a/ibis/expr/tests/test_deferred.py b/ibis/expr/tests/test_deferred.py
--- a/ibis/expr/tests/test_deferred.py
+++ b/ibis/expr/tests/test_deferred.py
@@ -8,6 +8,7 @@
 
 import ibis
 from ibis import _
+from ibis.expr.deferred import deferrable, deferred_apply
 
 
 @pytest.fixture
@@ -100,6 +101,19 @@ def test_method_kwargs(table):
     assert repr(expr) == "_.a.log(base=_.b)"
 
 
+def test_deferred_apply(table):
+    expr = deferred_apply(operator.add, _.a, 2)
+    res = expr.resolve(table)
+    assert res.equals(table.a + 2)
+    assert repr(expr) == "add(_.a, 2)"
+
+    func = lambda a, b: a + b
+    expr = deferred_apply(func, _.a, 2)
+    res = expr.resolve(table)
+    assert res.equals(table.a + 2)
+    assert func.__name__ in repr(expr)
+
+
 @pytest.mark.parametrize(
     "symbol, op",
     [
@@ -181,3 +195,27 @@ def test_deferred_is_not_iterable(obj):
 
     with pytest.raises(TypeError, match="is not an iterator"):
         next(obj)
+
+
+def test_deferrable(table):
+    @deferrable
+    def f(a, b, c=3):
+        return a + b + c
+
+    assert f(table.a, table.b).equals(table.a + table.b + 3)
+    assert f(table.a, table.b, c=4).equals(table.a + table.b + 4)
+
+    expr = f(_.a, _.b)
+    sol = table.a + table.b + 3
+    res = expr.resolve(table)
+    assert res.equals(sol)
+    assert repr(expr) == "f(_.a, _.b)"
+
+    expr = f(1, 2, c=_.a)
+    sol = 3 + table.a
+    res = expr.resolve(table)
+    assert res.equals(sol)
+    assert repr(expr) == "f(1, 2, c=_.a)"
+
+    with pytest.raises(TypeError, match="unknown"):
+        f(_.a, _.b, unknown=3)  # invalid calls caught at call time
diff --git a/ibis/tests/expr/test_temporal.py b/ibis/tests/expr/test_temporal.py
--- a/ibis/tests/expr/test_temporal.py
+++ b/ibis/tests/expr/test_temporal.py
@@ -10,6 +10,7 @@
 import ibis.expr.datatypes as dt
 import ibis.expr.operations as ops
 import ibis.expr.types as ir
+from ibis import _
 from ibis.common.temporal import IntervalUnit
 from ibis.expr import api
 from ibis.tests.util import assert_equal
@@ -809,7 +810,82 @@ def test_time_truncate(table, operand, unit):
     assert isinstance(expr.op(), ops.TimeTruncate)
 
 
-def test_date_time_literals():
-    assert ibis.date(2022, 2, 4).type() == dt.date
-    assert ibis.time(16, 20, 00).type() == dt.time
+def test_date_literal():
+    expr = ibis.date(2022, 2, 4)
+    sol = ops.DateFromYMD(2022, 2, 4).to_expr()
+    assert expr.equals(sol)
+
+    expr = ibis.date("2022-02-04")
+    sol = ibis.literal("2022-02-04", type=dt.date)
+    assert expr.equals(sol)
+
+
+def test_date_expression():
+    t = ibis.table({"x": "int", "y": "int", "z": "int", "s": "string"})
+    deferred = ibis.date(_.x, _.y, _.z)
+    expr = ibis.date(t.x, t.y, t.z)
+    assert isinstance(expr.op(), ops.DateFromYMD)
+    assert deferred.resolve(t).equals(expr)
+    assert repr(deferred) == "date(_.x, _.y, _.z)"
+
+    deferred = ibis.date(_.s)
+    expr = ibis.date(t.s)
+    assert deferred.resolve(t).equals(expr)
+    assert repr(deferred) == "date(_.s)"
+
+
+def test_time_literal():
+    expr = ibis.time(1, 2, 3)
+    sol = ops.TimeFromHMS(1, 2, 3).to_expr()
+    assert expr.equals(sol)
+
+    expr = ibis.time("01:02:03")
+    sol = ibis.literal("01:02:03", type=dt.time)
+    assert expr.equals(sol)
+
+
+def test_time_expression():
+    t = ibis.table({"x": "int", "y": "int", "z": "int", "s": "string"})
+    deferred = ibis.time(_.x, _.y, _.z)
+    expr = ibis.time(t.x, t.y, t.z)
+    assert isinstance(expr.op(), ops.TimeFromHMS)
+    assert deferred.resolve(t).equals(expr)
+    assert repr(deferred) == "time(_.x, _.y, _.z)"
+
+    deferred = ibis.time(_.s)
+    expr = ibis.time(t.s)
+    assert deferred.resolve(t).equals(expr)
+    assert repr(deferred) == "time(_.s)"
+
+
+def test_timestamp_literals():
     assert ibis.timestamp(2022, 2, 4, 16, 20, 00).type() == dt.timestamp
+
+
+def test_timestamp_literal():
+    expr = ibis.timestamp(2022, 2, 4, 16, 20, 0)
+    sol = ops.TimestampFromYMDHMS(2022, 2, 4, 16, 20, 0).to_expr()
+    assert expr.equals(sol)
+
+    expr = ibis.timestamp("2022-02-04T01:02:03")
+    sol = ibis.literal("2022-02-04T01:02:03", type=dt.timestamp)
+    assert expr.equals(sol)
+
+    expr = ibis.timestamp("2022-02-04T01:02:03Z")
+    sol = ibis.literal("2022-02-04T01:02:03", type=dt.Timestamp(timezone="UTC"))
+    assert expr.equals(sol)
+
+
+def test_timestamp_expression():
+    t = ibis.table(dict.fromkeys("abcdef", "int"))
+    deferred = ibis.timestamp(_.a, _.b, _.c, _.d, _.e, _.f)
+    expr = ibis.timestamp(t.a, t.b, t.c, t.d, t.e, t.f)
+    assert isinstance(expr.op(), ops.TimestampFromYMDHMS)
+    assert deferred.resolve(t).equals(expr)
+    assert repr(deferred) == "timestamp(_.a, _.b, _.c, _.d, _.e, _.f)"
+
+    t2 = ibis.table({"s": "string"})
+    deferred = ibis.timestamp(_.s, timezone="UTC")
+    expr = ibis.timestamp(t2.s, timezone="UTC")
+    assert deferred.resolve(t2).equals(expr)
+    assert repr(deferred) == "timestamp(_.s, timezone='UTC')"
diff --git a/ibis/tests/expr/test_timestamp.py b/ibis/tests/expr/test_timestamp.py
--- a/ibis/tests/expr/test_timestamp.py
+++ b/ibis/tests/expr/test_timestamp.py
@@ -183,5 +183,5 @@ def test_timestamp_field_access_on_time_failure(
 
 @pytest.mark.parametrize("value", [42, np.int64(42), np.int8(-42)])
 def test_integer_timestamp_fails(value):
-    with pytest.raises(TypeError, match=r"Use ibis\.literal\(-?\d+\)\.to_timestamp"):
+    with pytest.raises(TypeError, match=r"Use ibis\.literal\(\.\.\.\)\.to_timestamp"):
         ibis.timestamp(value)
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,15 +1566,7 @@ def equal(x, y):
 @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.timestamp, dt.timestamp, id="timestamp"),
         param(ibis.date, dt.date, id="date"),
         param(ibis.time, dt.time, id="time"),
         param(ibis.coalesce, dt.timestamp, id="coalesce"),