Repository: ibis-project/ibis

Issue:
feat(table): add namespace kwarg to ibis.table
A user asked about how to specify a table namespace when creating an unbound table.

The `UnboundTable` op does accept a namespace argument, but we don't expose that option in `ibis/expr/api.py::table`.

We will run afoul of the dual-definition of `schema` here, but if the user has to first construct the namespace object, at least that will be slightly separated.  

Still not a good solution, really, to the schema/schema issue, but I don't know what is, short of breaking all ibis code.



Discussion:
Break everything, and cut over to version 100?

Resolving patch:
diff --git a/ibis/expr/api.py b/ibis/expr/api.py
--- a/ibis/expr/api.py
+++ b/ibis/expr/api.py
@@ -313,15 +313,25 @@ def schema(
 def table(
     schema: SchemaLike | None = None,
     name: str | None = None,
+    catalog: str | None = None,
+    database: str | None = None,
 ) -> ir.Table:
     """Create a table literal or an abstract table without data.
 
+    Ibis uses the word database to refer to a collection of tables, and the word
+    catalog to refer to a collection of databases. You can use a combination of
+    `catalog` and `database` to specify a hierarchical location for table.
+
     Parameters
     ----------
     schema
         A schema for the table
     name
         Name for the table. One is generated if this value is `None`.
+    catalog
+        A collection of database.
+    database
+        A collection of tables. Required if catalog is not `None`.
 
     Returns
     -------
@@ -340,13 +350,32 @@ def table(
       a int64
       b string
 
+
+    Create a table with no data backing it in a specific location
+
+    >>> import ibis
+    >>> ibis.options.interactive = False
+    >>> t = ibis.table(schema=dict(a="int"), name="t", catalog="cat", database="db")
+    >>> t
+    UnboundTable: cat.db.t
+      a int64
     """
     if name is None:
         if isinstance(schema, type):
             name = schema.__name__
         else:
             name = next(_table_names)
-    return ops.UnboundTable(name=name, schema=schema).to_expr()
+    if catalog is not None and database is None:
+        raise ValueError(
+            "A catalog-only namespace is invalid in Ibis, "
+            "please specify a database as well."
+        )
+
+    return ops.UnboundTable(
+        name=name,
+        schema=schema,
+        namespace=ops.Namespace(catalog=catalog, database=database),
+    ).to_expr()
 
 
 def memtable(
diff --git a/ibis/expr/format.py b/ibis/expr/format.py
--- a/ibis/expr/format.py
+++ b/ibis/expr/format.py
@@ -244,6 +244,14 @@ def _physical_table(op, name, **kwargs):
     return f"{op.__class__.__name__}: {name}\n{schema}"
 
 
+@fmt.register(ops.UnboundTable)
+@fmt.register(ops.DatabaseTable)
+def _unbound_table(op, name, **kwargs):
+    schema = render_schema(op.schema, indent_level=1)
+    name = ".".join(filter(None, op.namespace.args + (name,)))
+    return f"{op.__class__.__name__}: {name}\n{schema}"
+
+
 @fmt.register(ops.InMemoryTable)
 def _in_memory_table(op, data, **kwargs):
     import rich.pretty
diff --git a/ibis/expr/operations/relations.py b/ibis/expr/operations/relations.py
--- a/ibis/expr/operations/relations.py
+++ b/ibis/expr/operations/relations.py
@@ -313,8 +313,8 @@ class PhysicalTable(Relation):
 
 @public
 class Namespace(Concrete):
-    database: Optional[str] = None
     catalog: Optional[str] = None
+    database: Optional[str] = None
 
 
 @public


Test patch:
diff --git a/ibis/expr/tests/snapshots/test_format/test_format_unbound_table_namespace/repr.txt b/ibis/expr/tests/snapshots/test_format/test_format_unbound_table_namespace/repr.txt
new file mode 100644
--- /dev/null
+++ b/ibis/expr/tests/snapshots/test_format/test_format_unbound_table_namespace/repr.txt
@@ -0,0 +1,3 @@
+UnboundTable: bork
+  a int64
+  b int64
\ No newline at end of file
diff --git a/ibis/expr/tests/snapshots/test_format/test_format_unbound_table_namespace/reprcatdb.txt b/ibis/expr/tests/snapshots/test_format/test_format_unbound_table_namespace/reprcatdb.txt
new file mode 100644
--- /dev/null
+++ b/ibis/expr/tests/snapshots/test_format/test_format_unbound_table_namespace/reprcatdb.txt
@@ -0,0 +1,3 @@
+UnboundTable: ork.bork.bork
+  a int64
+  b int64
\ No newline at end of file
diff --git a/ibis/expr/tests/snapshots/test_format/test_format_unbound_table_namespace/reprdb.txt b/ibis/expr/tests/snapshots/test_format/test_format_unbound_table_namespace/reprdb.txt
new file mode 100644
--- /dev/null
+++ b/ibis/expr/tests/snapshots/test_format/test_format_unbound_table_namespace/reprdb.txt
@@ -0,0 +1,3 @@
+UnboundTable: bork.bork
+  a int64
+  b int64
\ No newline at end of file
diff --git a/ibis/expr/tests/test_api.py b/ibis/expr/tests/test_api.py
--- a/ibis/expr/tests/test_api.py
+++ b/ibis/expr/tests/test_api.py
@@ -156,3 +156,18 @@ def test_nested_name_property():
         x = x + 1
 
     assert x.op().name.count("Add") == n
+
+
+def test_unbound_table_namespace():
+    t = ibis.table(name="bork", schema=(("a", "int"), ("b", "int")), database="bork")
+
+    assert t.op().namespace == ops.Namespace(database="bork")
+
+    t = ibis.table(
+        name="bork", schema=(("a", "int"), ("b", "int")), database="bork", catalog="ork"
+    )
+
+    assert t.op().namespace == ops.Namespace(catalog="ork", database="bork")
+
+    with pytest.raises(ValueError, match="A catalog-only namespace is invalid in Ibis"):
+        ibis.table(name="bork", schema=(("a", "int"), ("b", "int")), catalog="bork")
diff --git a/ibis/expr/tests/test_format.py b/ibis/expr/tests/test_format.py
--- a/ibis/expr/tests/test_format.py
+++ b/ibis/expr/tests/test_format.py
@@ -379,6 +379,25 @@ def test_format_in_memory_table(snapshot):
     snapshot.assert_match(result, "repr.txt")
 
 
+def test_format_unbound_table_namespace(snapshot):
+    t = ibis.table(name="bork", schema=(("a", "int"), ("b", "int")))
+
+    result = fmt(t)
+    snapshot.assert_match(result, "repr.txt")
+
+    t = ibis.table(name="bork", schema=(("a", "int"), ("b", "int")), database="bork")
+
+    result = fmt(t)
+    snapshot.assert_match(result, "reprdb.txt")
+
+    t = ibis.table(
+        name="bork", schema=(("a", "int"), ("b", "int")), catalog="ork", database="bork"
+    )
+
+    result = fmt(t)
+    snapshot.assert_match(result, "reprcatdb.txt")
+
+
 def test_format_new_relational_operation(alltypes, snapshot):
     class MyRelation(ops.Relation):
         parent: ops.Relation