Repository: ibis-project/ibis

Issue:
bug: interval addition/subtraction should upcast to smallest unit
### What happened?

Currently adding/subtracting intervals results in an interval with a unit of the `left` operand. This can result in accidental information loss if the smaller unit in the operation is the right operand:

```python
In [1]: import ibis

In [2]: one_second = ibis.interval(seconds=1)

In [3]: two_minutes = ibis.interval(minutes=2)

In [4]: (one_second + two_minutes).type().unit
Out[4]: <IntervalUnit.SECOND: 's'>

In [5]: (two_minutes + one_second).type().unit  # I'd expect seconds here too
Out[5]: <IntervalUnit.MINUTE: 'm'>

In [6]: (one_second + two_minutes).execute()
Out[6]: Timedelta('0 days 00:02:01')

In [7]: (two_minutes + one_second).execute()  # the second is dropped on rounding to minutes
Out[7]: Timedelta('0 days 00:02:00')
```

The offending code is [here](https://github.com/ibis-project/ibis/blob/5a013ffd3032e596a16a6a71476c2754f9bc9c8b/ibis/expr/operations/temporal.py#L297). With some pointers I can probably fix this. I know we have casting utilities for handling integer upcasting, but I'm not sure if the same exists for intervals.

### What version of ibis are you using?

dev

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

_No response_

### Relevant log output

_No response_

### Code of Conduct

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


Discussion:

Resolving patch:
diff --git a/ibis/expr/operations/temporal.py b/ibis/expr/operations/temporal.py
--- a/ibis/expr/operations/temporal.py
+++ b/ibis/expr/operations/temporal.py
@@ -301,9 +301,17 @@ def output_dtype(self):
             else arg
             for arg in (self.left, self.right)
         ]
+
+        interval_unit_args = [
+            arg.output_dtype.unit
+            for arg in (self.left, self.right)
+            if arg.output_dtype.is_interval()
+        ]
+
         value_dtype = rlz._promote_integral_binop(integer_args, self.op)
+        unit = rlz._promote_interval_resolution(interval_unit_args)
 
-        return self.left.output_dtype.copy(value_type=value_dtype)
+        return self.left.output_dtype.copy(value_type=value_dtype, unit=unit)
 
 
 @public
diff --git a/ibis/expr/rules.py b/ibis/expr/rules.py
--- a/ibis/expr/rules.py
+++ b/ibis/expr/rules.py
@@ -12,6 +12,7 @@
 import ibis.expr.types as ir
 from ibis import util
 from ibis.common.annotations import attribute, optional
+from ibis.common.enums import IntervalUnit
 from ibis.common.validators import (
     bool_,
     callable_with,  # noqa: F401
@@ -431,6 +432,14 @@ def output_dtype(self):
     return output_dtype
 
 
+def _promote_interval_resolution(units: list[IntervalUnit]) -> IntervalUnit:
+    # Find the smallest unit present in units
+    for unit in reversed(IntervalUnit):
+        if unit in units:
+            return unit
+    raise AssertionError('unreachable')
+
+
 # TODO(kszucs): it could be as simple as rlz.instance_of(ops.TableNode)
 # we have a single test case testing the schema superset condition, not
 # used anywhere else


Test patch:
diff --git a/ibis/backends/tests/test_temporal.py b/ibis/backends/tests/test_temporal.py
--- a/ibis/backends/tests/test_temporal.py
+++ b/ibis/backends/tests/test_temporal.py
@@ -830,6 +830,38 @@ def convert_to_offset(x):
                 ),
             ],
         ),
+        param(
+            lambda t, _: t.timestamp_col
+            + (ibis.interval(days=4) + ibis.interval(hours=2)),
+            lambda t, _: t.timestamp_col
+            + (pd.Timedelta(days=4) + pd.Timedelta(hours=2)),
+            id='timestamp-add-interval-binop-different-units',
+            marks=[
+                pytest.mark.notimpl(
+                    [
+                        "clickhouse",
+                        "sqlite",
+                        "postgres",
+                        "polars",
+                        "mysql",
+                        "impala",
+                        "snowflake",
+                    ],
+                    raises=com.OperationNotDefinedError,
+                ),
+                pytest.mark.notimpl(
+                    [
+                        "bigquery",
+                    ],
+                    raises=com.UnsupportedOperationError,
+                ),
+                pytest.mark.notimpl(
+                    ["druid"],
+                    raises=com.IbisTypeError,
+                    reason="Given argument with datatype interval(<IntervalUnit.HOUR: 'h'>) is not implicitly castable to string",
+                ),
+            ],
+        ),
         param(
             lambda t, _: t.timestamp_col - ibis.interval(days=17),
             lambda t, _: t.timestamp_col - pd.Timedelta(days=17),
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
@@ -144,27 +144,47 @@ def test_multiply(expr):
 
 
 @pytest.mark.parametrize(
-    'expr',
+    ('expr', 'expected_unit'),
     [
-        api.interval(days=1) + api.interval(days=1),
-        api.interval(days=2) + api.interval(hours=4),
+        (
+            api.interval(days=1) + api.interval(days=1),
+            IntervalUnit('D'),
+        ),
+        (
+            api.interval(days=2) + api.interval(hours=4),
+            IntervalUnit('h'),
+        ),
+        (
+            api.interval(seconds=1) + ibis.interval(minutes=2),
+            IntervalUnit('s'),
+        ),
     ],
 )
-def test_add(expr):
+def test_add(expr, expected_unit):
     assert isinstance(expr, ir.IntervalScalar)
-    assert expr.type().unit == IntervalUnit('D')
+    assert expr.type().unit == expected_unit
 
 
 @pytest.mark.parametrize(
-    'expr',
+    ('expr', 'expected_unit'),
     [
-        api.interval(days=3) - api.interval(days=1),
-        api.interval(days=2) - api.interval(hours=4),
+        (
+            api.interval(days=3) - api.interval(days=1),
+            IntervalUnit('D'),
+        ),
+        (
+            api.interval(days=2) - api.interval(hours=4),
+            IntervalUnit('h'),
+        ),
+        (
+            api.interval(minutes=2) - api.interval(seconds=1),
+            IntervalUnit('s'),
+        ),
     ],
 )
-def test_subtract(expr):
+def test_subtract(expr, expected_unit):
     assert isinstance(expr, ir.IntervalScalar)
-    assert expr.type().unit == IntervalUnit('D')
+    assert expr.type().unit == expected_unit
 
 
 @pytest.mark.parametrize(