Repository: ibis-project/ibis

Issue:
feat(trino): enable passing table properties to trino's `create_table`
### Is your feature request related to a problem?

I am unable to pass [Trino's table properties](https://trino.io/docs/current/sql/create-table.html) to ibis's `con.create_table` method using the Trino backend.

### Describe the solution you'd like

I'd like `create_table` to have an API for passing table properties to the Trino backend.

### What version of ibis are you running?

N/A

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

Trino

### Code of Conduct

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


Discussion:

Resolving patch:
diff --git a/ibis/backends/trino/__init__.py b/ibis/backends/trino/__init__.py
--- a/ibis/backends/trino/__init__.py
+++ b/ibis/backends/trino/__init__.py
@@ -2,21 +2,31 @@
 
 from __future__ import annotations
 
+import collections
 import contextlib
 import warnings
 from functools import cached_property
-from typing import Iterator
+from typing import TYPE_CHECKING, Any, Iterator, Mapping
 
+import pandas as pd
 import sqlalchemy as sa
 import toolz
 from trino.sqlalchemy.datatype import ROW as _ROW
 
+import ibis
+import ibis.common.exceptions as com
 import ibis.expr.datatypes as dt
+import ibis.expr.types as ir
 from ibis import util
 from ibis.backends.base.sql.alchemy import AlchemyCanCreateSchema, BaseAlchemyBackend
 from ibis.backends.base.sql.alchemy.datatypes import ArrayType
 from ibis.backends.trino.compiler import TrinoSQLCompiler
-from ibis.backends.trino.datatypes import ROW, parse
+from ibis.backends.trino.datatypes import ROW, TrinoType, parse
+
+if TYPE_CHECKING:
+    import pyarrow as pa
+
+    import ibis.expr.schema as sch
 
 
 class Backend(BaseAlchemyBackend, AlchemyCanCreateSchema):
@@ -128,3 +138,129 @@ def drop_schema(
         if_exists = "IF EXISTS " * force
         with self.begin() as con:
             con.exec_driver_sql(f"DROP SCHEMA {if_exists}{name}")
+
+    def create_table(
+        self,
+        name: str,
+        obj: pd.DataFrame | pa.Table | ir.Table | None = None,
+        *,
+        schema: sch.Schema | None = None,
+        database: str | None = None,
+        temp: bool = False,
+        overwrite: bool = False,
+        comment: str | None = None,
+        properties: Mapping[str, Any] | None = None,
+    ) -> ir.Table:
+        """Create a table in Trino.
+
+        Parameters
+        ----------
+        name
+            Name of the table to create
+        obj
+            The data with which to populate the table; optional, but one of `obj`
+            or `schema` must be specified
+        schema
+            The schema of the table to create; optional, but one of `obj` or
+            `schema` must be specified
+        database
+            Not yet implemented.
+        temp
+            This parameter is not yet supported in the Trino backend, because
+            Trino doesn't implement temporary tables
+        overwrite
+            If `True`, replace the table if it already exists, otherwise fail if
+            the table exists
+        comment
+            Add a comment to the table
+        properties
+            Table properties to set on creation
+        """
+        if obj is None and schema is None:
+            raise com.IbisError("One of the `schema` or `obj` parameter is required")
+
+        if temp:
+            raise NotImplementedError(
+                "Temporary tables in the Trino backend are not yet supported"
+            )
+
+        orig_table_ref = name
+
+        if overwrite:
+            name = util.gen_name("trino_overwrite")
+
+        create_stmt = "CREATE TABLE"
+
+        table_ref = self._quote(name)
+
+        create_stmt += f" {table_ref}"
+
+        if schema is not None and obj is None:
+            schema_str = ", ".join(
+                (
+                    f"{self._quote(name)} {TrinoType.to_string(typ)}"
+                    + " NOT NULL" * (not typ.nullable)
+                )
+                for name, typ in schema.items()
+            )
+            create_stmt += f" ({schema_str})"
+
+        if comment is not None:
+            create_stmt += f" COMMENT {comment!r}"
+
+        if properties:
+
+            def literal_compile(v):
+                if isinstance(v, collections.abc.Mapping):
+                    return f"MAP(ARRAY{list(v.keys())!r}, ARRAY{list(v.values())!r})"
+                elif util.is_iterable(v):
+                    return f"ARRAY{list(v)!r}"
+                else:
+                    return repr(v)
+
+            pairs = ", ".join(
+                f"{k} = {literal_compile(v)}" for k, v in properties.items()
+            )
+            create_stmt += f" WITH ({pairs})"
+
+        if obj is not None:
+            import pyarrow as pa
+
+            if isinstance(obj, (pd.DataFrame, pa.Table)):
+                table = ibis.memtable(obj, schema=schema)
+            else:
+                table = obj
+
+            self._run_pre_execute_hooks(table)
+
+            compiled_table = self.compile(table)
+
+            # cast here because trino doesn't allow specifying a schema in
+            # CTAS, e.g., `CREATE TABLE (schema) AS SELECT`
+            subquery = compiled_table.subquery()
+            columns = subquery.columns
+            select = sa.select(
+                *(
+                    sa.cast(columns[name], TrinoType.from_ibis(typ))
+                    for name, typ in (schema or table.schema()).items()
+                )
+            )
+            compiled = select.compile(compile_kwargs=dict(literal_binds=True))
+
+            create_stmt += f" AS {compiled}"
+
+        with self.begin() as con:
+            con.exec_driver_sql(create_stmt)
+
+            if overwrite:
+                # drop the original table
+                con.exec_driver_sql(
+                    f"DROP TABLE IF EXISTS {self._quote(orig_table_ref)}"
+                )
+
+                # rename the new table to the original table name
+                con.exec_driver_sql(
+                    f"ALTER TABLE IF EXISTS {table_ref} RENAME TO {self._quote(orig_table_ref)}"
+                )
+
+        return self.table(orig_table_ref)


Test patch:
diff --git a/ibis/backends/trino/tests/test_client.py b/ibis/backends/trino/tests/test_client.py
new file mode 100644
--- /dev/null
+++ b/ibis/backends/trino/tests/test_client.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+import pytest
+
+import ibis
+
+
+@pytest.fixture
+def tmp_name(con):
+    name = ibis.util.gen_name("test_trino_table_properties")
+    yield name
+    con.drop_table(name, force=True)
+
+
+def test_table_properties(tmp_name):
+    con = ibis.trino.connect(database="hive", schema="default")
+    schema = ibis.schema(dict(a="int"))
+    t = con.create_table(
+        tmp_name,
+        schema=schema,
+        properties={
+            "format": "ORC",
+            "bucketed_by": ["a"],
+            "bucket_count": 42,
+            "extra_properties": {
+                "any": "property",
+                "you": "want",
+            },
+        },
+    )
+    assert t.schema() == schema
+    with con.begin() as c:
+        ddl = c.exec_driver_sql(f"SHOW CREATE TABLE {tmp_name}").scalar()
+    assert "ORC" in ddl
+    assert "bucketed_by" in ddl