Skip to content

Commit 9cc5741

Browse files
committed
feat(duckdb): switch to newer ATTACH syntax for attach_sqlite()
the sqlite_attach() approach is deprecated per https://duckdb.org/docs/stable/extensions/sqlite.html, and the newer syntax opens up read only flag, specifiying a name for the attachment, and not overwriting tables that already exist.
1 parent dcc79a8 commit 9cc5741

File tree

2 files changed

+100
-34
lines changed

2 files changed

+100
-34
lines changed

ibis/backends/duckdb/__init__.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,20 +1234,39 @@ def detach(self, name: str) -> None:
12341234
self.con.execute(f"DETACH {name}").fetchall()
12351235

12361236
def attach_sqlite(
1237-
self, path: str | Path, overwrite: bool = False, all_varchar: bool = False
1238-
) -> None:
1237+
self,
1238+
path: str | Path,
1239+
*,
1240+
name: str | None = None,
1241+
skip_if_exists: bool = False,
1242+
read_only: bool | None = None,
1243+
all_varchar: bool = False,
1244+
) -> str | None:
12391245
"""Attach a SQLite database to the current DuckDB session.
12401246
12411247
Parameters
12421248
----------
12431249
path
12441250
The path to the SQLite database.
1245-
overwrite
1246-
Allow overwriting any tables or views that already exist in your current
1247-
session with the contents of the SQLite database.
1251+
name
1252+
The name to attach the database as.
1253+
If `None`, use the default behavior of DuckDB
1254+
(as of duckdb==1.2.0 this is the basename of the path).
1255+
skip_if_exists
1256+
If `True`, do not attach the database if it already exists.
1257+
read_only
1258+
If `True`, attach the database as read-only.
1259+
If `False`, attach the database as read-write.
1260+
If `None`, use the default behavior of DuckDB
1261+
(as of duckdb==1.2.0 this is read-write).
12481262
all_varchar
12491263
Set all SQLite columns to type `VARCHAR` to avoid type errors on ingestion.
12501264
1265+
Returns
1266+
-------
1267+
str | None
1268+
The name of the attached database, or `None` if the database already exists.
1269+
12511270
Examples
12521271
--------
12531272
>>> import ibis
@@ -1267,13 +1286,34 @@ def attach_sqlite(
12671286
>>> con.attach_sqlite("/tmp/attach_sqlite.db")
12681287
>>> con.list_tables()
12691288
['t']
1270-
12711289
"""
12721290
self.load_extension("sqlite")
1291+
if_not_exists = "IF NOT EXISTS" if skip_if_exists else ""
1292+
as_name = (
1293+
f"AS {sg.to_identifier(name, self.compiler.quoted).sql(self.name)}"
1294+
if name
1295+
else ""
1296+
)
1297+
options = ["TYPE sqlite"]
1298+
if read_only is not None:
1299+
if read_only:
1300+
options.append("READ_ONLY true")
1301+
else:
1302+
options.append("READ_ONLY false")
1303+
option_string = ", ".join(options)
1304+
databases_before = set(self.list_catalogs())
12731305
with self._safe_raw_sql(f"SET GLOBAL sqlite_all_varchar={all_varchar}") as cur:
12741306
cur.execute(
1275-
f"CALL sqlite_attach('{path}', overwrite={overwrite})"
1307+
f"ATTACH {if_not_exists} '{path}' {as_name} ({option_string})"
12761308
).fetchall()
1309+
databases_after = set(self.list_catalogs())
1310+
added_databases = databases_after - databases_before
1311+
if not added_databases:
1312+
if skip_if_exists:
1313+
return None
1314+
raise AssertionError((databases_before, databases_after))
1315+
assert len(added_databases) == 1, (databases_before, databases_after)
1316+
return added_databases.pop()
12771317

12781318
def register_filesystem(self, filesystem: AbstractFileSystem):
12791319
"""Register an `fsspec` filesystem object with DuckDB.

ibis/backends/duckdb/tests/test_io.py

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -235,52 +235,78 @@ def test_read_sqlite_no_table_name(con, tmp_path):
235235
scon.close()
236236

237237

238+
@pytest.fixture
239+
def sqlite_path(tmp_path, data_dir):
240+
path = tmp_path / "test.db"
241+
scon = sqlite3.connect(str(path))
242+
try:
243+
with scon:
244+
scon.executescript((data_dir.parent / "schema" / "sqlite.sql").read_text())
245+
yield path
246+
finally:
247+
scon.close()
248+
249+
238250
# Because we create a new connection and the test requires loading/installing a
239251
# DuckDB extension, we need to xfail these on Nix.
240252
@pytest.mark.xfail(
241253
LINUX and SANDBOXED,
242254
reason="nix on linux cannot download duckdb extensions or data due to sandboxing",
243255
raises=duckdb.IOException,
244256
)
245-
def test_attach_sqlite(data_dir, tmp_path):
246-
import sqlite3
247-
257+
def test_attach_sqlite(sqlite_path):
248258
# Create a new connection here because we already have the `ibis_testing`
249259
# tables loaded in to the `con` fixture.
250260
con = ibis.duckdb.connect()
251261

252-
test_db_path = tmp_path / "test.db"
253-
scon = sqlite3.connect(test_db_path)
254-
try:
255-
with scon:
256-
scon.executescript((data_dir.parent / "schema" / "sqlite.sql").read_text())
257-
258-
con.attach_sqlite(test_db_path)
259-
assert set(con.list_tables()) >= {
262+
catalogs_before = con.list_catalogs()
263+
assert con.list_tables() == []
264+
for i in range(2):
265+
name = con.attach_sqlite(sqlite_path, name="foo", skip_if_exists=True)
266+
if i == 0:
267+
assert isinstance(name, str)
268+
else:
269+
assert name is None
270+
assert con.list_tables() == []
271+
assert set(con.list_catalogs()) == {*catalogs_before, "foo"}
272+
database = ("foo", "main")
273+
assert set(con.list_tables(database=database)) >= {
260274
"functional_alltypes",
261275
"awards_players",
262276
"batting",
263277
"diamonds",
264278
}
265279

266-
fa = con.tables.functional_alltypes
267-
assert len(set(fa.schema().types)) > 1
280+
types = con.table("functional_alltypes", database=database).schema().types
281+
assert any(not isinstance(t, dt.String) for t in types)
282+
assert any(isinstance(t, dt.String) for t in types)
268283

269-
# overwrite existing sqlite_db and force schema to all strings
270-
con.attach_sqlite(test_db_path, overwrite=True, all_varchar=True)
271-
assert set(con.list_tables()) >= {
272-
"functional_alltypes",
273-
"awards_players",
274-
"batting",
275-
"diamonds",
276-
}
284+
with pytest.raises(duckdb.BinderException) as exc:
285+
con.attach_sqlite(sqlite_path, name="foo")
286+
assert "already" in str(exc.value)
277287

278-
fa = con.tables.functional_alltypes
279-
types = fa.schema().types
280-
assert len(set(types)) == 1
281-
assert dt.String(nullable=True) in set(types)
282-
finally:
283-
scon.close()
288+
con.detach("foo")
289+
assert con.list_catalogs() == catalogs_before
290+
291+
# Check for the name to be returned.
292+
# Check all_varchar=True.
293+
con = ibis.duckdb.connect()
294+
catalogs_before = con.list_catalogs()
295+
name = con.attach_sqlite(sqlite_path, all_varchar=True)
296+
assert isinstance(name, str)
297+
assert set(con.list_catalogs()) == {*catalogs_before, name}
298+
types = con.table("functional_alltypes", database=(name, "main")).schema().types
299+
assert all(isinstance(t, dt.String) for t in types)
300+
301+
# Explicitly NOT testing read_only=None in case duckdb's default changes
302+
name = con.attach_sqlite(sqlite_path, name="read_write", read_only=False)
303+
assert name == "read_write"
304+
con.create_table("t", database=("read_write", "main"), schema={"a": "int"})
305+
306+
name = con.attach_sqlite(sqlite_path, name="read_only", read_only=True)
307+
assert name == "read_only"
308+
with pytest.raises(duckdb.InvalidInputException) as exc:
309+
con.create_table("t", database=("read_only", "main"), schema={"a": "int"})
284310

285311

286312
def test_memtable_with_nullable_dtypes(con):

0 commit comments

Comments
 (0)