Repository: ibis-project/ibis

Issue:
feat: support more unsigned integer operations
I notice that many operations that I'd expect to work on unsigned integer types, do not.

```
In [2]: import ibis

In [6]: import ibis.expr.datatypes as dt

In [9]: t = ibis.table([("uint1", dt.uint64), ("uint2", dt.uint64)])

In [10]: t.uint1 - t.uint2
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Input In [10], in <cell line: 1>()
----> 1 t.uint1 - t.uint2

TypeError: unsupported operand type(s) for -: 'IntegerColumn' and 'IntegerColumn'

In [11]: t.uint1 + t.uint2
Out[11]: 
r0 := UnboundTable: unbound_table_0
  uint1 uint64
  uint2 uint64

Selection[r0]
  selections:
    Add(uint1, uint2): r0.uint1 + r0.uint2


In [12]: t.uint1 + 1
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Input In [12], in <cell line: 1>()
----> 1 t.uint1 + 1

TypeError: unsupported operand type(s) for +: 'IntegerColumn' and 'int'

In [13]: t.uint1 - 1
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Input In [13], in <cell line: 1>()
----> 1 t.uint1 - 1

TypeError: unsupported operand type(s) for -: 'IntegerColumn' and 'int'

In [14]: t.uint1 * 2
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Input In [14], in <cell line: 1>()
----> 1 t.uint1 * 2

TypeError: unsupported operand type(s) for *: 'IntegerColumn' and 'int'

In [15]: t.uint1 * t.uint2
Out[15]: 
r0 := UnboundTable: unbound_table_0
  uint1 uint64
  uint2 uint64

Selection[r0]
  selections:
    Multiply(uint1, uint2): r0.uint1 * r0.uint2


In [19]: t.uint1 % 2
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Input In [19], in <cell line: 1>()
----> 1 t.uint1 % 2

TypeError: unsupported operand type(s) for %: 'IntegerColumn' and 'int'
```

Perhaps a separate issue, attempting the modulus operator on two unsigned integer columns raises an unexpected error:

```
In [20]: t.uint1 % t.uint2
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Input In [20], in <cell line: 1>()
----> 1 t.uint1 % t.uint2

File ~/src/github.com/ibis-project/ibis/ibis/expr/types/numeric.py:276, in NumericValue.__mod__(self, other)
    274 def __mod__(self, other: NumericValue) -> NumericValue | NotImplemented:
    275     """Compute `self` modulo `other`."""
--> 276     return _binop(ops.Modulus, self, other)

File ~/src/github.com/ibis-project/ibis/ibis/expr/types/core.py:408, in _binop(op_class, left, right)
    381 """Try to construct a binary operation.
    382 
    383 Parameters
   (...)
    405 NotImplemented
    406 """
    407 try:
--> 408     node = op_class(left, right)
    409 except (IbisTypeError, NotImplementedError):
    410     return NotImplemented

File ~/src/github.com/ibis-project/ibis/ibis/common/grounds.py:26, in BaseMeta.__call__(cls, *args, **kwargs)
     25 def __call__(cls, *args, **kwargs) -> Base:
---> 26     return cls.__create__(*args, **kwargs)

File ~/src/github.com/ibis-project/ibis/ibis/common/grounds.py:106, in Annotable.__create__(cls, *args, **kwargs)
    102 @classmethod
    103 def __create__(cls, *args, **kwargs) -> Annotable:
    104     # construct the instance by passing the validated keyword arguments
    105     kwargs = cls.__signature__.validate(*args, **kwargs)
--> 106     return super().__create__(**kwargs)

File ~/src/github.com/ibis-project/ibis/ibis/common/grounds.py:35, in Base.__create__(cls, *args, **kwargs)
     33 @classmethod
     34 def __create__(cls, *args, **kwargs) -> Base:
---> 35     return type.__call__(cls, *args, **kwargs)

File ~/src/github.com/ibis-project/ibis/ibis/common/grounds.py:113, in Annotable.__init__(self, **kwargs)
    111     object.__setattr__(self, name, value)
    112 # allow child classes to do some post-initialization
--> 113 self.__post_init__()

File ~/src/github.com/ibis-project/ibis/ibis/common/grounds.py:246, in Concrete.__post_init__(self)
    244 object.__setattr__(self, "__precomputed_hash__", hashvalue)
    245 # initialize the remaining attributes
--> 246 super().__post_init__()

File ~/src/github.com/ibis-project/ibis/ibis/common/grounds.py:118, in Annotable.__post_init__(self)
    116 for name, field in self.__attributes__.items():
    117     if isinstance(field, Attribute):
--> 118         value = field.initialize(self)
    119         if value is not None:
    120             object.__setattr__(self, name, value)

File ~/src/github.com/ibis-project/ibis/ibis/common/annotations.py:65, in Attribute.initialize(self, this)
     63 if self._default is EMPTY:
     64     return None
---> 65 value = self._default(this)
     66 return self.validate(value, this=this)

File ~/src/github.com/ibis-project/ibis/ibis/expr/rules.py:411, in numeric_like.<locals>.output_dtype(self)
    409 dtypes = [arg.output_dtype for arg in args]
    410 if util.all_of(dtypes, dt.Integer):
--> 411     result = _promote_integral_binop(args, op)
    412 elif util.all_of(dtypes, dt.Decimal):
    413     result = _promote_decimal_binop(args, op)

File ~/src/github.com/ibis-project/ibis/ibis/expr/rules.py:367, in _promote_integral_binop(exprs, op)
    363 all_unsigned = dtypes and util.all_of(dtypes, dt.UnsignedInteger)
    364 # In some cases, the bounding type might be int8, even though neither
    365 # of the types are that small. We want to ensure the containing type is
    366 # _at least_ as large as the smallest type in the expression.
--> 367 values = list(starmap(op, product(*bounds)))
    368 dtypes.extend(dt.infer(v, prefer_unsigned=all_unsigned) for v in values)
    369 return dt.highest_precedence(dtypes)

ZeroDivisionError: integer division or modulo by zero
```


Discussion:
In my investigation, I found that most of these issues went away when I changed `can_cast_to_differently_signed_integer_type` in `ibis/expr/datatypes/core.py` to

```python
@castable.register(SignedInteger, UnsignedInteger)
@castable.register(UnsignedInteger, SignedInteger)
def can_cast_to_differently_signed_integer_type(
    source: Integral, target: Integral, value: int | None = None, **kwargs
) -> bool:
    if value is not None:
        return target.bounds.lower <= value <= target.bounds.upper
    else:
        return ((target.bounds.upper - target.bounds.lower) >=
                (source.bounds.upper - source.bounds.lower))
```

Resolving patch:
diff --git a/ibis/expr/datatypes/cast.py b/ibis/expr/datatypes/cast.py
--- a/ibis/expr/datatypes/cast.py
+++ b/ibis/expr/datatypes/cast.py
@@ -80,10 +80,12 @@ def can_cast_null(source: dt.DataType, target: dt.DataType, **kwargs) -> bool:
 def can_cast_to_differently_signed_integer_type(
     source: dt.Integer, target: dt.Integer, value: int | None = None, **kwargs
 ) -> bool:
-    if value is None:
-        return False
-    bounds = target.bounds
-    return bounds.lower <= value <= bounds.upper
+    if value is not None:
+        return target.bounds.lower <= value <= target.bounds.upper
+    else:
+        return (target.bounds.upper - target.bounds.lower) >= (
+            source.bounds.upper - source.bounds.lower
+        )
 
 
 @castable.register(dt.SignedInteger, dt.SignedInteger)


Test patch:
diff --git a/ibis/backends/pandas/tests/execution/conftest.py b/ibis/backends/pandas/tests/execution/conftest.py
--- a/ibis/backends/pandas/tests/execution/conftest.py
+++ b/ibis/backends/pandas/tests/execution/conftest.py
@@ -24,6 +24,7 @@ def df():
             'plain_datetimes_utc': pd.Series(
                 pd.date_range(start='2017-01-02 01:02:03.234', periods=3).values
             ).dt.tz_localize('UTC'),
+            'plain_uint64': pd.Series(range(1, 4), dtype=np.dtype('uint64')),
             'dup_strings': list('dad'),
             'dup_ints': [1, 2, 1],
             'float64_as_strings': ['100.01', '234.23', '-999.34'],
diff --git a/ibis/backends/pandas/tests/execution/test_operations.py b/ibis/backends/pandas/tests/execution/test_operations.py
--- a/ibis/backends/pandas/tests/execution/test_operations.py
+++ b/ibis/backends/pandas/tests/execution/test_operations.py
@@ -848,3 +848,67 @@ def test_union_with_list_types(t, df, distinct):
     result = expr.execute()
     expected = df if distinct else pd.concat([df, df], axis=0, ignore_index=True)
     tm.assert_frame_equal(result, expected)
+
+
+@pytest.mark.parametrize(
+    "operation",
+    [
+        pytest.param(
+            lambda column: column + column,
+            id="sum",
+        ),
+        pytest.param(
+            lambda column: column + 1,
+            id="sum_scalar",
+        ),
+        pytest.param(
+            lambda column: column - column,
+            id="subtract",
+        ),
+        pytest.param(
+            lambda column: column - 1,
+            id="subtract_scalar",
+        ),
+        pytest.param(
+            lambda column: column * column,
+            id="multiply",
+        ),
+        pytest.param(
+            lambda column: column * 2,
+            id="multiply_scalar",
+        ),
+        pytest.param(
+            lambda column: column % column,
+            id="mod",
+            marks=pytest.mark.xfail(
+                raises=ZeroDivisionError,
+                reason=("Ibis cannot modulo divide two unsigned integer columns."),
+            ),
+        ),
+        pytest.param(
+            lambda column: column % 2,
+            id="mod_scalar",
+        ),
+        pytest.param(
+            lambda column: column / column,
+            id="divide",
+        ),
+        pytest.param(
+            lambda column: column / 2,
+            id="divide_scalar",
+        ),
+        pytest.param(
+            lambda column: column // column,
+            id="floordivide",
+        ),
+        pytest.param(
+            lambda column: column // 2,
+            id="floordivide_scalar",
+        ),
+    ],
+)
+def test_unsigned_integers(t, df, operation):
+    expr = operation(t.plain_uint64)
+    result = expr.execute()
+    expected = operation(df.plain_uint64)
+    tm.assert_series_equal(result, expected)
diff --git a/ibis/tests/expr/test_datatypes.py b/ibis/tests/expr/test_datatypes.py
--- a/ibis/tests/expr/test_datatypes.py
+++ b/ibis/tests/expr/test_datatypes.py
@@ -462,12 +462,15 @@ def test_infer_dtype(value, expected_dtype):
         (dt.null, dt.date),
         (dt.int8, dt.int64),
         (dt.int8, dt.Decimal(12, 2)),
+        (dt.int16, dt.uint64),
         (dt.int32, dt.int32),
         (dt.int32, dt.int64),
         (dt.uint32, dt.uint64),
+        (dt.uint32, dt.int64),
         (dt.uint32, dt.Decimal(12, 2)),
         (dt.uint32, dt.float32),
         (dt.uint32, dt.float64),
+        (dt.uint64, dt.int64),
         (dt.Interval('s', dt.int16), dt.Interval('s', dt.int32)),
     ],
 )
@@ -480,7 +483,9 @@ def test_implicit_castable(source, target):
     [
         (dt.string, dt.null),
         (dt.int32, dt.int16),
-        (dt.int16, dt.uint64),
+        (dt.int32, dt.uint16),
+        (dt.uint64, dt.int16),
+        (dt.uint64, dt.uint16),
         (dt.Decimal(12, 2), dt.int32),
         (dt.timestamp, dt.boolean),
         (dt.boolean, dt.interval),