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:
tswast: This causes a failing test

```
_________________ test_implicitly_uncastable[source2-target2] __________________
[gw1] linux -- Python 3.8.14 /home/runner/.virtualenvs/.venv/bin/python

source = Int16(nullable=True), target = UInt64(nullable=True)

    @pytest.mark.parametrize(
        ('source', 'target'),
        [
            (dt.string, dt.null),
            (dt.int32, dt.int16),
            (dt.int16, dt.uint64),
            (dt.Decimal(12, 2), dt.int32),
            (dt.timestamp, dt.boolean),
            (dt.boolean, dt.interval),
            (dt.Interval('s', dt.int64), dt.Interval('s', dt.int16)),
        ],
    )
    def test_implicitly_uncastable(source, target):
>       assert not dt.castable(source, target)
E       assert not True
E        +  where True = <dispatched castable>(Int16(nullable=True), UInt64(nullable=True))
E        +    where <dispatched castable> = dt.castable
```

I would argue that we would want to allow this cast, even implicitly, as the size of the value is such that there won't be data loss, only overflow wrapping in many backends.

Alternatively, we're going to need to find a way to have flexible dtypes for literal integer values, as those are the ones causing the most trouble in https://github.com/ibis-project/ibis/issues/4764
codecov[bot]: # [Codecov](https://codecov.io/gh/ibis-project/ibis/pull/4766?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project) Report
> Merging [#4766](https://codecov.io/gh/ibis-project/ibis/pull/4766?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project) (38791f0) into [master](https://codecov.io/gh/ibis-project/ibis/commit/23af56782540c29a323ca62324005b113d1ba960?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project) (23af567) will **increase** coverage by `55.61%`.
> The diff coverage is `33.33%`.

[![Impacted file tree graph](https://codecov.io/gh/ibis-project/ibis/pull/4766/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/4766?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    #4766       +/-   ##
===========================================
+ Coverage   33.54%   89.16%   +55.61%     
===========================================
  Files         192      192               
  Lines       21487    21486        -1     
  Branches     2986     2986               
===========================================
+ Hits         7208    19158    +11950     
+ Misses      14030     1831    -12199     
- Partials      249      497      +248     
```

| [Impacted Files](https://codecov.io/gh/ibis-project/ibis/pull/4766?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project) | Coverage Δ | |
|---|---|---|
| [ibis/expr/datatypes/cast.py](https://codecov.io/gh/ibis-project/ibis/pull/4766/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL2RhdGF0eXBlcy9jYXN0LnB5) | `85.03% <33.33%> (+15.50%)` | :arrow_up: |
| [ibis/expr/operations/strings.py](https://codecov.io/gh/ibis-project/ibis/pull/4766/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL29wZXJhdGlvbnMvc3RyaW5ncy5weQ==) | `100.00% <0.00%> (+0.59%)` | :arrow_up: |
| [ibis/expr/operations/generic.py](https://codecov.io/gh/ibis-project/ibis/pull/4766/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL29wZXJhdGlvbnMvZ2VuZXJpYy5weQ==) | `98.91% <0.00%> (+1.62%)` | :arrow_up: |
| [ibis/backends/postgres/compiler.py](https://codecov.io/gh/ibis-project/ibis/pull/4766/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9iYWNrZW5kcy9wb3N0Z3Jlcy9jb21waWxlci5weQ==) | `100.00% <0.00%> (+4.00%)` | :arrow_up: |
| [ibis/expr/operations/temporal.py](https://codecov.io/gh/ibis-project/ibis/pull/4766/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL29wZXJhdGlvbnMvdGVtcG9yYWwucHk=) | `100.00% <0.00%> (+4.20%)` | :arrow_up: |
| [ibis/expr/operations/vectorized.py](https://codecov.io/gh/ibis-project/ibis/pull/4766/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL29wZXJhdGlvbnMvdmVjdG9yaXplZC5weQ==) | `100.00% <0.00%> (+4.34%)` | :arrow_up: |
| [ibis/expr/operations/core.py](https://codecov.io/gh/ibis-project/ibis/pull/4766/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL29wZXJhdGlvbnMvY29yZS5weQ==) | `94.56% <0.00%> (+5.43%)` | :arrow_up: |
| [ibis/expr/builders.py](https://codecov.io/gh/ibis-project/ibis/pull/4766/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9leHByL2J1aWxkZXJzLnB5) | `92.30% <0.00%> (+5.76%)` | :arrow_up: |
| [ibis/expr/operations/reductions.py](https://codecov.io/gh/ibis-project/ibis/pull/4766/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.80% <0.00%> (+5.95%)` | :arrow_up: |
| [ibis/common/annotations.py](https://codecov.io/gh/ibis-project/ibis/pull/4766/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ibis-project#diff-aWJpcy9jb21tb24vYW5ub3RhdGlvbnMucHk=) | `86.61% <0.00%> (+7.08%)` | :arrow_up: |
| ... and [157 more](https://codecov.io/gh/ibis-project/ibis/pull/4766/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 0s [: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 231 [: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") 1 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests")
19 494 runs  19 332 [: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") 6 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests")

For more details on these failures, see [this check](https://github.com/ibis-project/ibis/runs/9282825953).

Results for commit 38791f06.

[test-results]:data:application/gzip;base64,H4sIAH4mZGMC/02Myw6DIBBFf8Ww7gIYHZj+TCOoCamPBmFl+u8dW8Uuz7k3ZxNDGPtV3Cu8VWLNIRXocmxTWGZGQMmCp7SPoBt74mPN3n8dqMs9w4udxmKGNoxsrksf4xLZ7N2Y5z2rqKb6wDOrCEAX98uqBk9zZAv/V/0yTSExCLCG1CDRWTLYG+rAavTOyFpTq4yTtkEkJ8X7Aww9QI8OAQAA


Patch:
diff --git a/ibis/expr/datatypes/cast.py b/ibis/expr/datatypes/cast.py
index 99ea1629f..b7e21065f 100644
--- 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
index 32ed18818..8ca8c43e9 100644
--- 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
index dfa54d42e..d5d2b1bd5 100644
--- 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
index ad696ef09..c4e667d2e 100644
--- 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),