From 162b05804425202c946281c64111d832f678f3ec Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Wed, 18 Oct 2023 15:52:19 -0400 Subject: [PATCH] feat(duckdb): add `attach` and `detach` methods for adding and removing databases to the current duckdb session --- ibis/backends/duckdb/__init__.py | 38 +++++++++++++++++++++++ ibis/backends/duckdb/tests/test_client.py | 37 ++++++++++++++++++++-- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/ibis/backends/duckdb/__init__.py b/ibis/backends/duckdb/__init__.py index 51bd63b849ef..42f33fffb30e 100644 --- a/ibis/backends/duckdb/__init__.py +++ b/ibis/backends/duckdb/__init__.py @@ -764,6 +764,44 @@ def read_sqlite(self, path: str | Path, table_name: str | None = None) -> ir.Tab return self.table(table_name) + def attach( + self, path: str | Path, name: str | None = None, read_only: bool = False + ) -> None: + """Attach another DuckDB database to the current DuckDB session. + + Parameters + ---------- + path + Path to the database to attach. + name + Name to attach the database as. Defaults to the basename of `path`. + read_only + Whether to attach the database as read-only. + """ + code = f"ATTACH '{path}'" + + if name is not None: + name = sg.to_identifier(name).sql(self.name) + code += f" AS {name}" + + if read_only: + code += " (READ_ONLY)" + + with self.begin() as con: + con.exec_driver_sql(code) + + def detach(self, name: str) -> None: + """Detach a database from the current DuckDB session. + + Parameters + ---------- + name + The name of the database to detach. + """ + name = sg.to_identifier(name).sql(self.name) + with self.begin() as con: + con.exec_driver_sql(f"DETACH {name}") + def attach_sqlite( self, path: str | Path, overwrite: bool = False, all_varchar: bool = False ) -> None: diff --git a/ibis/backends/duckdb/tests/test_client.py b/ibis/backends/duckdb/tests/test_client.py index 61ae6fa4ccfa..f940602df84e 100644 --- a/ibis/backends/duckdb/tests/test_client.py +++ b/ibis/backends/duckdb/tests/test_client.py @@ -70,8 +70,7 @@ def test_cross_db(tmpdir): con2 = ibis.duckdb.connect(path2) t2 = con2.create_table("t2", schema=ibis.schema(dict(x="int"))) - with con2.begin() as c: - c.exec_driver_sql(f"ATTACH '{path1}' AS test1 (READ_ONLY)") + con2.attach(path1, name="test1", read_only=True) t1_from_con2 = con2.table("t1", schema="test1.main") assert t1_from_con2.schema() == t2.schema() @@ -80,3 +79,37 @@ def test_cross_db(tmpdir): foo_t1_from_con2 = con2.table("t1", schema="test1.foo") assert foo_t1_from_con2.schema() == t2.schema() assert foo_t1_from_con2.execute().equals(t2.execute()) + + +def test_attach_detach(tmpdir): + import duckdb + + path1 = str(tmpdir.join("test1.ddb")) + with duckdb.connect(path1): + pass + + path2 = str(tmpdir.join("test2.ddb")) + con2 = ibis.duckdb.connect(path2) + + # default name + name = "test1" + assert name not in con2.list_databases() + + con2.attach(path1) + assert name in con2.list_databases() + + con2.detach(name) + assert name not in con2.list_databases() + + # passed-in name + name = "test_foo" + assert name not in con2.list_databases() + + con2.attach(path1, name=name) + assert name in con2.list_databases() + + con2.detach(name) + assert name not in con2.list_databases() + + with pytest.raises(sa.exc.ProgrammingError): + con2.detach(name)