Repository: ibis-project/ibis

Issue:
feat: Connect to sqlite database from duckdb
### Is your feature request related to a problem?

I would like to be able to use the python API to have a duckdb session attach to a sqlite database. This is separate from reading an individual table, as is currently supported.

This is currently possible to do via SQL:

```python
import ibis

connection = ibis.duckdb.connect()

connection.raw_sql("""
INSTALL sqlite;
LOAD sqlite;
CALL sqlite_attach('EnsDb.Hsapiens.v108.sqlite');
""")


connection.list_tables()
['protein_domain',
 'uniprot',
...
]
```

### Describe the solution you'd like

I would like there to be some python API for this. Potentially something like:

```python
connection = ibis.duckdb.connect()
connection.register("sqlite://EnsDb.Hsapiens.v108.sqlite")
```

### What version of ibis are you running?

'5.1.0'

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

DuckDB

### Code of Conduct

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


Discussion:
Can you try

```python
con = ibis.duckdb.connect()
t = con.read_sqlite("path/to/sqlite.db", table_name="mytable")
```

?
Thanks for the quick response. Connecting to a single table from a sqlite store with the duckdb backend totally works right now.

But my feature request is for connect to the whole database, not just a single table.
Roger that! SGTM.

Resolving patch:
diff --git a/ibis/backends/duckdb/__init__.py b/ibis/backends/duckdb/__init__.py
--- a/ibis/backends/duckdb/__init__.py
+++ b/ibis/backends/duckdb/__init__.py
@@ -488,6 +488,7 @@ def read_sqlite(self, path: str | Path, table_name: str | None = None) -> ir.Tab
         if table_name is None:
             raise ValueError("`table_name` is required when registering a sqlite table")
         self._load_extensions(["sqlite"])
+
         source = sa.select(sa.literal_column("*")).select_from(
             sa.func.sqlite_scan(str(path), table_name)
         )
@@ -497,6 +498,40 @@ def read_sqlite(self, path: str | Path, table_name: str | None = None) -> ir.Tab
 
         return self.table(table_name)
 
+    def attach_sqlite(
+        self, path: str | Path, overwrite: bool = False, all_varchar: bool = False
+    ) -> None:
+        """Attach a SQLite database to the current DuckDB session.
+
+        Parameters
+        ----------
+        path
+            The path to the SQLite database.
+        overwrite
+            Allow overwriting any tables or views that already exist in your current
+            session with the contents of the SQLite database.
+        all_varchar
+            Set all SQLite columns to type `VARCHAR` to avoid type errors on ingestion.
+
+        Returns
+        -------
+        None
+
+        Examples
+        --------
+        >>> import ibis
+        >>> con = ibis.connect("duckdb://")
+        >>> con.attach_sqlite("ci/ibis-testing-data/ibis_testing.db")
+        >>> con.list_tables()
+        ['functional_alltypes', 'awards_players', 'batting', 'diamonds']
+        """
+        self._load_extensions(["sqlite"])
+        with self.begin() as con:
+            con.execute(sa.text(f"SET GLOBAL sqlite_all_varchar={all_varchar}"))
+            con.execute(
+                sa.text(f"CALL sqlite_attach('{str(path)}', overwrite={overwrite})")
+            )
+
     def to_pyarrow_batches(
         self,
         expr: ir.Expr,


Test patch:
diff --git a/ibis/backends/duckdb/tests/test_register.py b/ibis/backends/duckdb/tests/test_register.py
--- a/ibis/backends/duckdb/tests/test_register.py
+++ b/ibis/backends/duckdb/tests/test_register.py
@@ -3,6 +3,7 @@
 import tempfile
 from pathlib import Path
 
+import duckdb
 import numpy as np
 import pandas as pd
 import pandas.testing as tm
@@ -11,6 +12,8 @@
 
 import ibis
 import ibis.common.exceptions as exc
+import ibis.expr.datatypes as dt
+from ibis.backends.conftest import LINUX, SANDBOXED
 
 
 def test_read_csv(data_directory):
@@ -122,6 +125,46 @@ def test_register_sqlite(con, tmp_path):
     assert ft.count().execute()
 
 
+# Because we create a new connection and the test requires loading/installing a
+# DuckDB extension, we need to xfail these on Nix.
+@pytest.mark.xfail(
+    LINUX and SANDBOXED,
+    reason="nix on linux cannot download duckdb extensions or data due to sandboxing",
+    raises=duckdb.IOException,
+)
+def test_attach_sqlite(data_directory):
+    # Create a new connection here because we already have the `ibis_testing`
+    # tables loaded in to the `con` fixture.
+    con = ibis.duckdb.connect()
+
+    sqlite_db = data_directory / "ibis_testing.db"
+
+    con.attach_sqlite(sqlite_db)
+    assert set(con.list_tables()) == {
+        "functional_alltypes",
+        "awards_players",
+        "batting",
+        "diamonds",
+    }
+
+    fa = con.tables.functional_alltypes
+    assert len(set(fa.schema().types)) > 1
+
+    # overwrite existing sqlite_db and force schema to all strings
+    con.attach_sqlite(sqlite_db, overwrite=True, all_varchar=True)
+    assert set(con.list_tables()) == {
+        "functional_alltypes",
+        "awards_players",
+        "batting",
+        "diamonds",
+    }
+
+    fa = con.tables.functional_alltypes
+    types = fa.schema().types
+    assert len(set(types)) == 1
+    assert dt.String(nullable=True) in set(types)
+
+
 def test_read_in_memory(con):
     df_arrow = pa.table({"a": ["a"], "b": [1]})
     df_pandas = pd.DataFrame({"a": ["a"], "b": [1]})