Repository: ibis-project/ibis

Issue:
bug: casting from Int to UInt fails when generating SQL
### What happened?

I can execute to see the results, but generating the SQL fails.

Casting to other dtypes seems to work. I haven't tested other dtype combos besides these.

```python
import ibis
from ibis import examples as ex
import ibis.expr.datatypes as dt

int64_col = ex.Aids2.fetch().age

ibis.show_sql(int64_col.cast("int64"))  #works
ibis.show_sql(int64_col.cast(dt.Int64()))  #works
ibis.show_sql(int64_col.cast("int32"))  #works
ibis.show_sql(int64_col.cast(dt.Int32()))  #works
int64_col.cast("uint64").execute()  #works, oddly enough
int64_col.cast(dt.UInt64()).execute()  #works, oddly enough
# ibis.show_sql(int64_col.cast("uint64"))  # fails
# ibis.show_sql(int64_col.cast(dt.UInt64()))  # fails
```

### What version of ibis are you using?

near main, but not quite the latest and greatest. I can retry on main if you think it's important.

### What backend(s) are you using, if any?

duckdb

### Relevant log output

```sh
---------------------------------------------------------------------------
ParseError                                Traceback (most recent call last)
Cell In[32], line 13
     11 int64_col.cast("uint64").execute()  #works
     12 int64_col.cast(dt.UInt64()).execute()  #works
---> 13 ibis.show_sql(int64_col.cast("uint64"))  # fails
     14 ibis.show_sql(int64_col.cast(dt.UInt64()))  # fails

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/ibis/expr/sql.py:324, in show_sql(expr, dialect, file)
    288 @public
    289 def show_sql(
    290     expr: ir.Expr,
    291     dialect: str | None = None,
    292     file: IO[str] | None = None,
    293 ) -> None:
    294     """Pretty-print the compiled SQL string of an expression.
    295 
    296     If a dialect cannot be inferred and one was not passed, duckdb
   (...)
    322     FROM t AS t0
    323     """
--> 324     print(to_sql(expr, dialect=dialect), file=file)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/ibis/expr/sql.py:386, in to_sql(expr, dialect)
    383         read = write = getattr(backend, "_sqlglot_dialect", dialect)
    385 sql = backend._to_sql(expr)
--> 386 (pretty,) = sg.transpile(sql, read=read, write=write, pretty=True)
    387 return SQLString(pretty)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/__init__.py:184, in transpile(sql, read, write, identity, error_level, **opts)
    165 """
    166 Parses the given SQL string in accordance with the source dialect and returns a list of SQL strings transformed
    167 to conform to the target dialect. Each string in the returned list represents a single transformed SQL statement.
   (...)
    179     The list of transpiled SQL statements.
    180 """
    181 write = write or read if identity else write
    182 return [
    183     Dialect.get_or_raise(write)().generate(expression, **opts)
--> 184     for expression in parse(sql, read, error_level=error_level)
    185 ]

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/__init__.py:72, in parse(sql, read, **opts)
     60 """
     61 Parses the given SQL string into a collection of syntax trees, one per parsed SQL statement.
     62 
   (...)
     69     The resulting syntax tree collection.
     70 """
     71 dialect = Dialect.get_or_raise(read)()
---> 72 return dialect.parse(sql, **opts)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/dialects/dialect.py:163, in Dialect.parse(self, sql, **opts)
    162 def parse(self, sql: str, **opts) -> t.List[t.Optional[exp.Expression]]:
--> 163     return self.parser(**opts).parse(self.tokenizer.tokenize(sql), sql)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:745, in Parser.parse(self, raw_tokens, sql)
    731 def parse(
    732     self, raw_tokens: t.List[Token], sql: t.Optional[str] = None
    733 ) -> t.List[t.Optional[exp.Expression]]:
    734     """
    735     Parses a list of tokens and returns a list of syntax trees, one tree
    736     per parsed SQL statement.
   (...)
    743         The list of syntax trees.
    744     """
--> 745     return self._parse(
    746         parse_method=self.__class__._parse_statement, raw_tokens=raw_tokens, sql=sql
    747     )

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:808, in Parser._parse(self, parse_method, raw_tokens, sql)
    805 self._tokens = tokens
    806 self._advance()
--> 808 expressions.append(parse_method(self))
    810 if self._index < len(self._tokens):
    811     self.raise_error("Invalid expression / Unexpected token")

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:944, in Parser._parse_statement(self)
    941     return self._parse_command()
    943 expression = self._parse_expression()
--> 944 expression = self._parse_set_operations(expression) if expression else self._parse_select()
    946 self._parse_query_modifiers(expression)
    947 return expression

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:1645, in Parser._parse_select(self, nested, table, parse_subquery_alias)
   1642     self.raise_error("Cannot specify both ALL and DISTINCT after SELECT")
   1644 limit = self._parse_limit(top=True)
-> 1645 expressions = self._parse_csv(self._parse_expression)
   1647 this = self.expression(
   1648     exp.Select,
   1649     hint=hint,
   (...)
   1652     limit=limit,
   1653 )
   1654 this.comments = comments

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:3496, in Parser._parse_csv(self, parse_method, sep)
   3493 def _parse_csv(
   3494     self, parse_method: t.Callable, sep: TokenType = TokenType.COMMA
   3495 ) -> t.List[t.Optional[exp.Expression]]:
-> 3496     parse_result = parse_method()
   3497     items = [parse_result] if parse_result is not None else []
   3499     while self._match(sep):

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:2351, in Parser._parse_expression(self)
   2350 def _parse_expression(self) -> t.Optional[exp.Expression]:
-> 2351     return self._parse_alias(self._parse_conjunction())

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:2354, in Parser._parse_conjunction(self)
   2353 def _parse_conjunction(self) -> t.Optional[exp.Expression]:
-> 2354     return self._parse_tokens(self._parse_equality, self.CONJUNCTION)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:3512, in Parser._parse_tokens(self, parse_method, expressions)
   3509 def _parse_tokens(
   3510     self, parse_method: t.Callable, expressions: t.Dict
   3511 ) -> t.Optional[exp.Expression]:
-> 3512     this = parse_method()
   3514     while self._match_set(expressions):
   3515         this = self.expression(
   3516             expressions[self._prev.token_type],
   3517             this=this,
   3518             comments=self._prev_comments,
   3519             expression=parse_method(),
   3520         )

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:2357, in Parser._parse_equality(self)
   2356 def _parse_equality(self) -> t.Optional[exp.Expression]:
-> 2357     return self._parse_tokens(self._parse_comparison, self.EQUALITY)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:3512, in Parser._parse_tokens(self, parse_method, expressions)
   3509 def _parse_tokens(
   3510     self, parse_method: t.Callable, expressions: t.Dict
   3511 ) -> t.Optional[exp.Expression]:
-> 3512     this = parse_method()
   3514     while self._match_set(expressions):
   3515         this = self.expression(
   3516             expressions[self._prev.token_type],
   3517             this=this,
   3518             comments=self._prev_comments,
   3519             expression=parse_method(),
   3520         )

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:2360, in Parser._parse_comparison(self)
   2359 def _parse_comparison(self) -> t.Optional[exp.Expression]:
-> 2360     return self._parse_tokens(self._parse_range, self.COMPARISON)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:3512, in Parser._parse_tokens(self, parse_method, expressions)
   3509 def _parse_tokens(
   3510     self, parse_method: t.Callable, expressions: t.Dict
   3511 ) -> t.Optional[exp.Expression]:
-> 3512     this = parse_method()
   3514     while self._match_set(expressions):
   3515         this = self.expression(
   3516             expressions[self._prev.token_type],
   3517             this=this,
   3518             comments=self._prev_comments,
   3519             expression=parse_method(),
   3520         )

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:2363, in Parser._parse_range(self)
   2362 def _parse_range(self) -> t.Optional[exp.Expression]:
-> 2363     this = self._parse_bitwise()
   2364     negate = self._match(TokenType.NOT)
   2366     if self._match_set(self.RANGE_PARSERS):

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:2428, in Parser._parse_bitwise(self)
   2427 def _parse_bitwise(self) -> t.Optional[exp.Expression]:
-> 2428     this = self._parse_term()
   2430     while True:
   2431         if self._match_set(self.BITWISE):

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:2451, in Parser._parse_term(self)
   2450 def _parse_term(self) -> t.Optional[exp.Expression]:
-> 2451     return self._parse_tokens(self._parse_factor, self.TERM)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:3512, in Parser._parse_tokens(self, parse_method, expressions)
   3509 def _parse_tokens(
   3510     self, parse_method: t.Callable, expressions: t.Dict
   3511 ) -> t.Optional[exp.Expression]:
-> 3512     this = parse_method()
   3514     while self._match_set(expressions):
   3515         this = self.expression(
   3516             expressions[self._prev.token_type],
   3517             this=this,
   3518             comments=self._prev_comments,
   3519             expression=parse_method(),
   3520         )

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:2454, in Parser._parse_factor(self)
   2453 def _parse_factor(self) -> t.Optional[exp.Expression]:
-> 2454     return self._parse_tokens(self._parse_unary, self.FACTOR)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:3512, in Parser._parse_tokens(self, parse_method, expressions)
   3509 def _parse_tokens(
   3510     self, parse_method: t.Callable, expressions: t.Dict
   3511 ) -> t.Optional[exp.Expression]:
-> 3512     this = parse_method()
   3514     while self._match_set(expressions):
   3515         this = self.expression(
   3516             expressions[self._prev.token_type],
   3517             this=this,
   3518             comments=self._prev_comments,
   3519             expression=parse_method(),
   3520         )

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:2459, in Parser._parse_unary(self)
   2457 if self._match_set(self.UNARY_PARSERS):
   2458     return self.UNARY_PARSERS[self._prev.token_type](self)
-> 2459 return self._parse_at_time_zone(self._parse_type())

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:2467, in Parser._parse_type(self)
   2465 index = self._index
   2466 type_token = self._parse_types(check_func=True)
-> 2467 this = self._parse_column()
   2469 if type_token:
   2470     if this and not isinstance(this, exp.Star):

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:2606, in Parser._parse_column(self)
   2605 def _parse_column(self) -> t.Optional[exp.Expression]:
-> 2606     this = self._parse_field()
   2607     if isinstance(this, exp.Identifier):
   2608         this = self.expression(exp.Column, this=this)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:2702, in Parser._parse_field(self, any_token)
   2701 def _parse_field(self, any_token: bool = False) -> t.Optional[exp.Expression]:
-> 2702     return self._parse_primary() or self._parse_function() or self._parse_id_var(any_token)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:2732, in Parser._parse_function(self, functions)
   2729 parser = self.FUNCTION_PARSERS.get(upper)
   2731 if parser:
-> 2732     this = parser(self)
   2733 else:
   2734     subquery_predicate = self.SUBQUERY_PREDICATES.get(token_type)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:636, in Parser.(self)
    621 SCHEMA_UNNAMED_CONSTRAINTS = {"CHECK", "FOREIGN KEY", "LIKE", "PRIMARY KEY", "UNIQUE"}
    623 NO_PAREN_FUNCTION_PARSERS = {
    624     TokenType.CASE: lambda self: self._parse_case(),
    625     TokenType.IF: lambda self: self._parse_if(),
    626     TokenType.ANY: lambda self: self.expression(exp.Any, this=self._parse_bitwise()),
    627 }
    629 FUNCTION_PARSERS: t.Dict[str, t.Callable] = {
    630     "CONVERT": lambda self: self._parse_convert(self.STRICT_CAST),
    631     "TRY_CONVERT": lambda self: self._parse_convert(False),
    632     "EXTRACT": lambda self: self._parse_extract(),
    633     "POSITION": lambda self: self._parse_position(),
    634     "SUBSTRING": lambda self: self._parse_substring(),
    635     "TRIM": lambda self: self._parse_trim(),
--> 636     "CAST": lambda self: self._parse_cast(self.STRICT_CAST),
    637     "TRY_CAST": lambda self: self._parse_cast(False),
    638     "STRING_AGG": lambda self: self._parse_string_agg(),
    639 }
    641 QUERY_MODIFIER_PARSERS = {
    642     "match": lambda self: self._parse_match_recognize(),
    643     "where": lambda self: self._parse_where(),
   (...)
    654     "lock": lambda self: self._parse_lock(),
    655 }
    657 SHOW_PARSERS: t.Dict[str, t.Callable] = {}

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:3163, in Parser._parse_cast(self, strict)
   3160 to = self._parse_types()
   3162 if not to:
-> 3163     self.raise_error("Expected TYPE after CAST")
   3164 elif to.this == exp.DataType.Type.CHAR:
   3165     if self._match(TokenType.CHARACTER_SET):

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlglot/parser.py:854, in Parser.raise_error(self, message, token)
    842 error = ParseError.new(
    843     f"{message}. Line {token.line}, Col: {token.col}.\n"
    844     f"  {start_context}\033[4m{highlight}\033[0m{end_context}",
   (...)
    850     end_context=end_context,
    851 )
    853 if self.error_level == ErrorLevel.IMMEDIATE:
--> 854     raise error
    856 self.errors.append(error)

ParseError: Expected TYPE after CAST. Line 1, Col: 23.
  SELECT CAST(t0.age AS UINT64) AS "Cast(age, uint64)" 
FROM ibis_read_csv_15 AS t0
```


### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
This looks like a bug in sqlglot, not in ibis. I will report upstream, they are really good about fixing things quickly!
xref: https://github.com/tobymao/sqlglot/issues/1267
The reason execution works is because execution goes through duckdb-engine and the SQL is only ever parsed by DuckDB itself in that case.
This is now fixed upstream, so we'll pick it up when sqlglot cuts a new release. They do so pretty often so it should be well in time for 5.0

```
…/sqlglot on  main via 🐍 v3.10.10 via ❄️  impure (shell)
❯ python3
Python 3.10.10 (main, Feb  7 2023, 12:19:31) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import sqlglot as sg
>>> sg.parse_one("select cast(1 as uinteger)", read='duckdb')
(SELECT expressions:
  (CAST this:
    (LITERAL this: 1, is_string: False), to:
    (DATATYPE this: Type.UINT, nested: False, prefix: False)))
```
The fix is already out in sqlglot [v11.3.4](https://github.com/tobymao/sqlglot/compare/v11.3.3...v11.3.4) 🥳 . Do we now pin our dependencies to require `sqlglot>=11.3.4`?
I don't think so, I think we just update the lockfile, add a test and make sure it passes!

Resolving patch:
diff --git a/ibis/backends/duckdb/compiler.py b/ibis/backends/duckdb/compiler.py
--- a/ibis/backends/duckdb/compiler.py
+++ b/ibis/backends/duckdb/compiler.py
@@ -20,12 +20,24 @@ class DuckDBSQLExprTranslator(AlchemyExprTranslator):
     _dialect_name = "duckdb"
 
 
-@compiles(sat.UInt64, "duckdb")
-@compiles(sat.UInt32, "duckdb")
-@compiles(sat.UInt16, "duckdb")
 @compiles(sat.UInt8, "duckdb")
+def compile_uint8(element, compiler, **kw):
+    return "UTINYINT"
+
+
+@compiles(sat.UInt16, "duckdb")
+def compile_uint16(element, compiler, **kw):
+    return "USMALLINT"
+
+
+@compiles(sat.UInt32, "duckdb")
+def compile_uint32(element, compiler, **kw):
+    return "UINTEGER"
+
+
+@compiles(sat.UInt64, "duckdb")
 def compile_uint(element, compiler, **kw):
-    return element.__class__.__name__.upper()
+    return "UBIGINT"
 
 
 @compiles(sat.ArrayType, "duckdb")


Test patch:
diff --git a/ibis/backends/duckdb/tests/snapshots/test_datatypes/test_cast_uints/uint16/out.sql b/ibis/backends/duckdb/tests/snapshots/test_datatypes/test_cast_uints/uint16/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/duckdb/tests/snapshots/test_datatypes/test_cast_uints/uint16/out.sql
@@ -0,0 +1,3 @@
+SELECT
+  CAST(t0.a AS USMALLINT) AS "Cast(a, uint16)"
+FROM t AS t0
\ No newline at end of file
diff --git a/ibis/backends/duckdb/tests/snapshots/test_datatypes/test_cast_uints/uint32/out.sql b/ibis/backends/duckdb/tests/snapshots/test_datatypes/test_cast_uints/uint32/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/duckdb/tests/snapshots/test_datatypes/test_cast_uints/uint32/out.sql
@@ -0,0 +1,3 @@
+SELECT
+  CAST(t0.a AS UINTEGER) AS "Cast(a, uint32)"
+FROM t AS t0
\ No newline at end of file
diff --git a/ibis/backends/duckdb/tests/snapshots/test_datatypes/test_cast_uints/uint64/out.sql b/ibis/backends/duckdb/tests/snapshots/test_datatypes/test_cast_uints/uint64/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/duckdb/tests/snapshots/test_datatypes/test_cast_uints/uint64/out.sql
@@ -0,0 +1,3 @@
+SELECT
+  CAST(t0.a AS UBIGINT) AS "Cast(a, uint64)"
+FROM t AS t0
\ No newline at end of file
diff --git a/ibis/backends/duckdb/tests/snapshots/test_datatypes/test_cast_uints/uint8/out.sql b/ibis/backends/duckdb/tests/snapshots/test_datatypes/test_cast_uints/uint8/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/duckdb/tests/snapshots/test_datatypes/test_cast_uints/uint8/out.sql
@@ -0,0 +1,3 @@
+SELECT
+  CAST(t0.a AS UTINYINT) AS "Cast(a, uint8)"
+FROM t AS t0
\ No newline at end of file
diff --git a/ibis/backends/duckdb/tests/test_datatypes.py b/ibis/backends/duckdb/tests/test_datatypes.py
--- a/ibis/backends/duckdb/tests/test_datatypes.py
+++ b/ibis/backends/duckdb/tests/test_datatypes.py
@@ -1,4 +1,6 @@
 import pytest
+import sqlglot as sg
+from packaging.version import parse as vparse
 from pytest import param
 
 import ibis.expr.datatypes as dt
@@ -78,3 +80,18 @@
 def test_parser(typ, expected):
     ty = parse(typ)
     assert ty == expected
+
+
+@pytest.mark.parametrize("uint_type", ["uint8", "uint16", "uint32", "uint64"])
+@pytest.mark.xfail(
+    vparse(sg.__version__) < vparse("11.3.4"),
+    raises=sg.ParseError,
+    reason="sqlglot version doesn't support duckdb unsigned integer types",
+)
+def test_cast_uints(uint_type, snapshot):
+    import ibis
+
+    t = ibis.table(dict(a="int8"), name="t")
+    snapshot.assert_match(
+        str(ibis.to_sql(t.a.cast(uint_type), dialect="duckdb")), "out.sql"
+    )