Repository: ibis-project/ibis

Issue:
refactor: normalize underlying types of literal values
Currently many of the literal types we support accept multiple values. This is desirable, since not everyone will come to ibis with the same kind of literal values.

However, we don't normalize the underlying value to a single type in some cases.

For example, with the `uuid` type we keep whatever value the user passed in:

```
In [27]: import uuid

In [28]: import ibis

In [29]: value = uuid.uuid4()

In [30]: x = ibis.literal(value, type='uuid')

In [31]: x
Out[31]:
Literal[uuid]
  e9cd2672-e276-4bef-acec-68423d3c6ff2

In [32]: y = ibis.literal(str(value), type='uuid')

In [33]: y
Out[33]:
Literal[uuid]
  e9cd2672-e276-4bef-acec-68423d3c6ff2

In [34]: x.equals(y)
Out[34]: False
```

We should address this by adding N to 1 tests and then fixing the cases where the tests fail: N different user-input types all compare equal to the normalized-type input.


Discussion:

Resolving patch:
diff --git a/ibis/expr/datatypes.py b/ibis/expr/datatypes.py
--- a/ibis/expr/datatypes.py
+++ b/ibis/expr/datatypes.py
@@ -10,6 +10,7 @@
 import numbers
 import re
 import typing
+import uuid as _uuid
 from typing import Iterator, Mapping, NamedTuple, Sequence, TypeVar
 
 import pandas as pd
@@ -1528,3 +1529,50 @@ def same_kind_left_null(_: Null, b: DataType) -> bool:
 def same_kind_both_null(a: Null, b: Null) -> bool:
     """Return ``True``."""
     return True
+
+
+_normalize = Dispatcher(
+    "_normalize",
+    doc="""\
+Ensure that the Python type underlying an
+:class:`~ibis.expr.operations.generic.Literal` resolves to a single acceptable
+type regardless of the input value.
+
+Parameters
+----------
+typ : DataType
+value :
+
+Returns
+-------
+value
+    the input ``value`` normalized to the expected type
+""",
+)
+
+
+@_normalize.register(DataType, object)
+def _normalize_default(typ: DataType, value: object) -> object:
+    return value
+
+
+@_normalize.register(Floating, (int, builtins.float))
+def _float(typ: Floating, value: builtins.float) -> builtins.float:
+    return builtins.float(value)
+
+
+@_normalize.register(UUID, str)
+def _str_to_uuid(typ: UUID, value: str) -> _uuid.UUID:
+    return _uuid.UUID(value)
+
+
+@_normalize.register(String, _uuid.UUID)
+def _uuid_to_str(typ: String, value: _uuid.UUID) -> str:
+    return str(value)
+
+
+@_normalize.register(UUID, _uuid.UUID)
+def _uuid_to_uuid(typ: UUID, value: _uuid.UUID) -> _uuid.UUID:
+    """Need this to override _uuid_to_str since dt.UUID is a child of
+    dt.String"""
+    return value
diff --git a/ibis/expr/types.py b/ibis/expr/types.py
--- a/ibis/expr/types.py
+++ b/ibis/expr/types.py
@@ -1239,6 +1239,7 @@ def literal(value, type=None):
     if dtype is dt.null:
         return null().cast(dtype)
     else:
+        value = dt._normalize(dtype, value)
         return ops.Literal(value, dtype=dtype).to_expr()
 
 


Test patch:
diff --git a/ibis/backends/tests/test_string.py b/ibis/backends/tests/test_string.py
--- a/ibis/backends/tests/test_string.py
+++ b/ibis/backends/tests/test_string.py
@@ -1,3 +1,5 @@
+import uuid
+
 import pytest
 from pytest import param
 
@@ -285,7 +287,7 @@ def test_special_strings(backend, con, alltypes, data, data_type):
     lit = ibis.literal(data, type=data_type).name('tmp')
     expr = alltypes[[alltypes.id, lit]].head(1)
     df = expr.execute()
-    assert df['tmp'].iloc[0] == data
+    assert df['tmp'].iloc[0] == uuid.UUID(data)
 
 
 @pytest.mark.xfail_backends(['clickhouse'])  # #2642
diff --git a/ibis/tests/expr/test_literal.py b/ibis/tests/expr/test_literal.py
--- a/ibis/tests/expr/test_literal.py
+++ b/ibis/tests/expr/test_literal.py
@@ -1,3 +1,7 @@
+import uuid
+
+import pytest
+
 import ibis
 from ibis.expr import datatypes
 from ibis.expr.operations import Literal
@@ -60,3 +64,31 @@ def test_pickle_literal_interval():
     a = ibis.interval(seconds=1).op()
 
     assert_pickle_roundtrip(a)
+
+
+@pytest.mark.parametrize(
+    ("userinput", "literal_type", "expected_type"),
+    [
+        pytest.param(uuid.uuid1(), "uuid", uuid.UUID, id="uuid1_as_uuid"),
+        pytest.param(uuid.uuid4(), "uuid", uuid.UUID, id="uuid4_as_uuid"),
+        pytest.param(
+            str(uuid.uuid1()), "uuid", uuid.UUID, id="str_uuid1_as_uuid"
+        ),
+        pytest.param(
+            str(uuid.uuid4()), "uuid", uuid.UUID, id="str_uuid4_as_uuid"
+        ),
+        pytest.param(uuid.uuid1(), "string", str, id="uuid1_as_str"),
+        pytest.param(uuid.uuid4(), "string", str, id="uuid4_as_str"),
+        pytest.param(str(uuid.uuid1()), "string", str, id="str_uuid1_as_str"),
+        pytest.param(str(uuid.uuid4()), "string", str, id="str_uuid4_as_str"),
+        pytest.param(0, "float", float, id="int_zero_as_float"),
+        pytest.param(0.0, "float", float, id="float_zero_as_float"),
+        pytest.param(42, "float", float, id="int_as_float"),
+        pytest.param(42.0, "float", float, id="float_as_float"),
+        pytest.param(42.0, None, float, id="float_implicit_type_as_float"),
+    ],
+)
+def test_normalized_underlying_value(userinput, literal_type, expected_type):
+    a = ibis.literal(userinput, type=literal_type)
+
+    assert isinstance(a.op().value, expected_type)