duckdb-sqlalchemy 0.19.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,474 @@
1
+ from typing import cast
2
+ from urllib.parse import parse_qs
3
+
4
+ import duckdb
5
+ import pytest
6
+ from sqlalchemy import Integer, String, pool
7
+ from sqlalchemy import exc as sa_exc
8
+
9
+ from duckdb_sqlalchemy import (
10
+ URL,
11
+ ConnectionWrapper,
12
+ CursorWrapper,
13
+ Dialect,
14
+ MotherDuckURL,
15
+ _apply_motherduck_defaults,
16
+ _looks_like_motherduck,
17
+ _normalize_motherduck_config,
18
+ _supports,
19
+ create_engine_from_paths,
20
+ olap,
21
+ stable_session_hint,
22
+ )
23
+ from duckdb_sqlalchemy import datatypes as dt
24
+ from duckdb_sqlalchemy.config import TYPES, apply_config, get_core_config
25
+
26
+
27
+ def _cursor(conn: object) -> CursorWrapper:
28
+ wrapper = ConnectionWrapper(cast(duckdb.DuckDBPyConnection, conn))
29
+ return CursorWrapper(cast(duckdb.DuckDBPyConnection, conn), wrapper)
30
+
31
+
32
+ def test_url_coerces_types_and_overrides() -> None:
33
+ url = URL(
34
+ database=":memory:",
35
+ query={"access_mode": "read_only", "flag": True, "drop": None},
36
+ access_mode="read_write",
37
+ enabled=False,
38
+ list_val=[1, "two"],
39
+ tuple_val=("a", 2),
40
+ empty=None,
41
+ )
42
+
43
+ assert url.query["access_mode"] == "read_write"
44
+ assert url.query["flag"] == "true"
45
+ assert url.query["enabled"] == "false"
46
+ assert url.query["list_val"] == ("1", "two")
47
+ assert url.query["tuple_val"] == ("a", "2")
48
+ assert "drop" not in url.query
49
+ assert "empty" not in url.query
50
+
51
+
52
+ def test_create_connect_args_moves_user_query_param() -> None:
53
+ dialect = Dialect()
54
+ url = URL(
55
+ database="md:my_db",
56
+ query={
57
+ "user": "alice",
58
+ "session_hint": "hint",
59
+ "attach_mode": "single",
60
+ "cache_buster": "123",
61
+ "motherduck_dbinstance_inactivity_ttl": "15m",
62
+ "memory_limit": "1GB",
63
+ },
64
+ )
65
+
66
+ args, kwargs = dialect.create_connect_args(url)
67
+
68
+ assert args == ()
69
+ database, query = kwargs["database"].split("?", 1)
70
+ assert database == "md:my_db"
71
+ assert parse_qs(query) == {
72
+ "user": ["alice"],
73
+ "session_hint": ["hint"],
74
+ "attach_mode": ["single"],
75
+ "cache_buster": ["123"],
76
+ "dbinstance_inactivity_ttl": ["15m"],
77
+ }
78
+ assert kwargs["url_config"] == {"memory_limit": "1GB"}
79
+
80
+
81
+ def test_create_connect_args_strips_pool_override() -> None:
82
+ dialect = Dialect()
83
+ url = URL(
84
+ database=":memory:",
85
+ query={"duckdb_sqlalchemy_pool": "queue", "threads": "4"},
86
+ )
87
+
88
+ _, kwargs = dialect.create_connect_args(url)
89
+
90
+ assert kwargs["url_config"] == {"threads": "4"}
91
+
92
+
93
+ def test_pool_override_from_url() -> None:
94
+ url = URL(database="md:my_db", query={"duckdb_sqlalchemy_pool": "queue"})
95
+ assert Dialect.get_pool_class(url) is pool.QueuePool
96
+
97
+
98
+ def test_create_engine_from_paths_requires_paths() -> None:
99
+ with pytest.raises(ValueError):
100
+ create_engine_from_paths([])
101
+
102
+
103
+ def test_is_disconnect_matches_patterns() -> None:
104
+ dialect = Dialect()
105
+ assert dialect.is_disconnect(RuntimeError("connection closed"), None, None)
106
+ assert not dialect.is_disconnect(RuntimeError("syntax error"), None, None)
107
+
108
+
109
+ def test_retry_on_transient_select() -> None:
110
+ dialect = Dialect()
111
+
112
+ class DummyCursor:
113
+ def __init__(self) -> None:
114
+ self.calls = 0
115
+
116
+ def execute(self, statement, parameters=None):
117
+ self.calls += 1
118
+ if self.calls == 1:
119
+ raise RuntimeError("HTTP Error: 503 Service Unavailable")
120
+ return None
121
+
122
+ class DummyContext:
123
+ execution_options = {
124
+ "duckdb_retry_on_transient": True,
125
+ "duckdb_retry_count": 1,
126
+ }
127
+
128
+ cursor = DummyCursor()
129
+ dialect.do_execute(cursor, "select 1", (), context=DummyContext())
130
+ assert cursor.calls == 2
131
+
132
+
133
+ def test_motherduck_url_builder_moves_path_params() -> None:
134
+ url = MotherDuckURL(
135
+ database="md:my_db",
136
+ session_hint="team-a",
137
+ attach_mode="single",
138
+ motherduck_dbinstance_inactivity_ttl="15m",
139
+ query={"memory_limit": "1GB"},
140
+ )
141
+
142
+ database, query = url.database.split("?", 1)
143
+ assert database == "md:my_db"
144
+ assert parse_qs(query) == {
145
+ "session_hint": ["team-a"],
146
+ "attach_mode": ["single"],
147
+ "dbinstance_inactivity_ttl": ["15m"],
148
+ }
149
+ assert url.query == {"memory_limit": "1GB"}
150
+
151
+
152
+ def test_stable_session_hint_is_deterministic() -> None:
153
+ hint1 = stable_session_hint("user-123", salt="salt", length=8)
154
+ hint2 = stable_session_hint("user-123", salt="salt", length=8)
155
+ assert hint1 == hint2
156
+ assert len(hint1) == 8
157
+
158
+
159
+ def test_apply_config_uses_literal_processors() -> None:
160
+ dialect = Dialect()
161
+
162
+ class DummyConn:
163
+ def __init__(self) -> None:
164
+ self.executed = []
165
+
166
+ def execute(self, statement: str) -> None:
167
+ self.executed.append(statement)
168
+
169
+ conn = DummyConn()
170
+ ext = {"memory_limit": "1GB", "threads": 4, "enable_profiling": True}
171
+
172
+ apply_config(dialect, conn, ext)
173
+
174
+ processors = {k: v.literal_processor(dialect=dialect) for k, v in TYPES.items()}
175
+ expected = [
176
+ f"SET {key} = {processors[type(value)](value)}" for key, value in ext.items()
177
+ ]
178
+
179
+ assert conn.executed == expected
180
+
181
+
182
+ def test_get_core_config_includes_motherduck_keys() -> None:
183
+ core = get_core_config()
184
+
185
+ expected = {
186
+ "motherduck_token",
187
+ "attach_mode",
188
+ "saas_mode",
189
+ "session_hint",
190
+ "access_mode",
191
+ "dbinstance_inactivity_ttl",
192
+ "motherduck_dbinstance_inactivity_ttl",
193
+ }
194
+ assert expected.issubset(core)
195
+
196
+
197
+ def test_looks_like_motherduck_detection() -> None:
198
+ assert _looks_like_motherduck("md:db", {}) is True
199
+ assert _looks_like_motherduck("motherduck:db", {}) is True
200
+ assert _looks_like_motherduck("local.db", {"motherduck_token": "x"}) is True
201
+ assert _looks_like_motherduck("local.db", {}) is False
202
+
203
+
204
+ def test_apply_motherduck_defaults_env_token(monkeypatch: pytest.MonkeyPatch) -> None:
205
+ config = {}
206
+ monkeypatch.setenv("MOTHERDUCK_TOKEN", "token123")
207
+ monkeypatch.delenv("motherduck_token", raising=False)
208
+
209
+ _apply_motherduck_defaults(config, "md:my_db")
210
+
211
+ assert config["motherduck_token"] == "token123"
212
+
213
+
214
+ def test_apply_motherduck_defaults_skips_non_md(
215
+ monkeypatch: pytest.MonkeyPatch,
216
+ ) -> None:
217
+ config = {}
218
+ monkeypatch.setenv("MOTHERDUCK_TOKEN", "token123")
219
+ monkeypatch.delenv("motherduck_token", raising=False)
220
+
221
+ _apply_motherduck_defaults(config, "local.db")
222
+
223
+ assert "motherduck_token" not in config
224
+
225
+
226
+ def test_apply_motherduck_defaults_requires_string() -> None:
227
+ with pytest.raises(TypeError):
228
+ _apply_motherduck_defaults({"motherduck_token": 123}, "md:db")
229
+
230
+
231
+ def test_normalize_motherduck_config_alias() -> None:
232
+ config = {"dbinstance_inactivity_ttl": "1h"}
233
+ _normalize_motherduck_config(config)
234
+ assert config["motherduck_dbinstance_inactivity_ttl"] == "1h"
235
+
236
+ config = {
237
+ "dbinstance_inactivity_ttl": "1h",
238
+ "motherduck_dbinstance_inactivity_ttl": "2h",
239
+ }
240
+ _normalize_motherduck_config(config)
241
+ assert config["motherduck_dbinstance_inactivity_ttl"] == "2h"
242
+
243
+
244
+ def test_has_comment_support_false_on_parser_exception(
245
+ monkeypatch: pytest.MonkeyPatch,
246
+ ) -> None:
247
+ _supports.has_comment_support.cache_clear()
248
+
249
+ class DummyConn:
250
+ def __enter__(self):
251
+ return self
252
+
253
+ def __exit__(self, exc_type, exc, tb):
254
+ return False
255
+
256
+ def execute(self, *args, **kwargs):
257
+ raise _supports.duckdb.ParserException("nope")
258
+
259
+ monkeypatch.setattr(_supports.duckdb, "connect", lambda *_: DummyConn())
260
+
261
+ assert _supports.has_comment_support() is False
262
+
263
+
264
+ def test_has_comment_support_true_when_no_error(
265
+ monkeypatch: pytest.MonkeyPatch,
266
+ ) -> None:
267
+ _supports.has_comment_support.cache_clear()
268
+
269
+ class DummyConn:
270
+ def __enter__(self):
271
+ return self
272
+
273
+ def __exit__(self, exc_type, exc, tb):
274
+ return False
275
+
276
+ def execute(self, *args, **kwargs):
277
+ return None
278
+
279
+ monkeypatch.setattr(_supports.duckdb, "connect", lambda *_: DummyConn())
280
+
281
+ assert _supports.has_comment_support() is True
282
+
283
+
284
+ def test_identifier_preparer_separate_and_format_schema() -> None:
285
+ preparer = Dialect().identifier_preparer
286
+
287
+ assert preparer._separate("db.schema") == ("db", "schema")
288
+ assert preparer._separate('"my db"."my schema"') == ("my db", "my schema")
289
+ assert preparer._separate("schema") == (None, "schema")
290
+
291
+ formatted = preparer.format_schema('"my db".main')
292
+ assert formatted == '"my db".main'
293
+
294
+
295
+ def test_table_function_error_without_table_valued(
296
+ monkeypatch: pytest.MonkeyPatch,
297
+ ) -> None:
298
+ class DummyFunc:
299
+ def __getattr__(self, name):
300
+ def _fn(*args, **kwargs):
301
+ return object()
302
+
303
+ return _fn
304
+
305
+ monkeypatch.setattr(olap, "func", DummyFunc())
306
+
307
+ with pytest.raises(NotImplementedError):
308
+ olap.table_function("read_parquet", "path", columns=["col"])
309
+
310
+
311
+ def test_table_function_returns_function_call(monkeypatch: pytest.MonkeyPatch) -> None:
312
+ called = {}
313
+
314
+ class DummyFunc:
315
+ def __getattr__(self, name):
316
+ def _fn(*args, **kwargs):
317
+ called["name"] = name
318
+ called["args"] = args
319
+ called["kwargs"] = kwargs
320
+ return "ok"
321
+
322
+ return _fn
323
+
324
+ monkeypatch.setattr(olap, "func", DummyFunc())
325
+
326
+ result = olap.table_function("read_csv", "file.csv", header=True)
327
+
328
+ assert result == "ok"
329
+ assert called["name"] == "read_csv"
330
+ assert called["args"] == ("file.csv",)
331
+ assert called["kwargs"] == {"header": True}
332
+
333
+
334
+ def test_cursorwrapper_execute_basic_paths() -> None:
335
+ class DummyConn:
336
+ def __init__(self) -> None:
337
+ self.calls = []
338
+
339
+ def commit(self) -> None:
340
+ self.calls.append(("commit",))
341
+
342
+ def register(self, name, df) -> None:
343
+ self.calls.append(("register", name, df))
344
+
345
+ def execute(self, *args):
346
+ self.calls.append(("execute", args))
347
+
348
+ conn = DummyConn()
349
+ cursor = _cursor(conn)
350
+ df = object()
351
+
352
+ cursor.execute("COMMIT")
353
+ cursor.execute("register", ("view", df))
354
+ cursor.execute("select 1")
355
+ cursor.execute("select ?", (1,))
356
+
357
+ assert ("commit",) in conn.calls
358
+ assert ("register", "view", df) in conn.calls
359
+ assert ("execute", ("select 1",)) in conn.calls
360
+ assert ("execute", ("select ?", (1,))) in conn.calls
361
+
362
+
363
+ def test_cursorwrapper_execute_show_isolation_level() -> None:
364
+ class DummyConn:
365
+ def __init__(self) -> None:
366
+ self.calls = []
367
+
368
+ def execute(self, statement: str) -> None:
369
+ self.calls.append(statement)
370
+
371
+ conn = DummyConn()
372
+ cursor = _cursor(conn)
373
+
374
+ cursor.execute("show transaction isolation level")
375
+
376
+ assert conn.calls == ["select 'read committed' as transaction_isolation"]
377
+
378
+
379
+ def test_cursorwrapper_executemany_coerces_to_list() -> None:
380
+ class DummyConn:
381
+ def __init__(self) -> None:
382
+ self.calls = []
383
+
384
+ def executemany(self, *args):
385
+ self.calls.append(args)
386
+
387
+ conn = DummyConn()
388
+ cursor = _cursor(conn)
389
+ params = ({"a": 1}, {"a": 2})
390
+
391
+ cursor.executemany("insert", params) # type: ignore[arg-type]
392
+
393
+ assert conn.calls[0][0] == "insert"
394
+ assert conn.calls[0][1] == list(params)
395
+
396
+
397
+ def test_cursorwrapper_execute_handles_specific_runtime_errors() -> None:
398
+ class CommitConn:
399
+ def commit(self) -> None:
400
+ raise RuntimeError(
401
+ "TransactionContext Error: cannot commit - no transaction is active"
402
+ )
403
+
404
+ cursor = _cursor(CommitConn())
405
+ cursor.execute("commit")
406
+
407
+ class NotImplementedConn:
408
+ def execute(self, *args, **kwargs):
409
+ raise RuntimeError("Not implemented Error: nope")
410
+
411
+ cursor = _cursor(NotImplementedConn())
412
+ with pytest.raises(NotImplementedError):
413
+ cursor.execute("select 1")
414
+
415
+
416
+ def test_cursorwrapper_description_handles_unhashable_type_code() -> None:
417
+ class DummyConn:
418
+ description = [("col", ["complex"])]
419
+
420
+ cursor = _cursor(DummyConn())
421
+
422
+ assert cursor.description == [("col", "['complex']")]
423
+
424
+
425
+ def test_connectionwrapper_close_marks_closed() -> None:
426
+ class DummyConn:
427
+ def __init__(self) -> None:
428
+ self.closed = False
429
+
430
+ def close(self) -> None:
431
+ self.closed = True
432
+
433
+ conn = DummyConn()
434
+ wrapper = ConnectionWrapper(cast(duckdb.DuckDBPyConnection, conn))
435
+ assert wrapper.closed is False
436
+
437
+ wrapper.close()
438
+
439
+ assert wrapper.closed is True
440
+ assert conn.closed is True
441
+
442
+
443
+ def test_map_processors(monkeypatch: pytest.MonkeyPatch) -> None:
444
+ map_type = dt.Map(String, Integer)
445
+ bind = map_type.bind_processor(Dialect())
446
+
447
+ assert bind({"a": 1, "b": 2}) == {"key": ["a", "b"], "value": [1, 2]}
448
+ assert bind(None) is None
449
+
450
+ result = map_type.result_processor(Dialect(), None)({"key": ["a"], "value": [1]})
451
+ if dt.IS_GT_1:
452
+ assert result == {"key": ["a"], "value": [1]}
453
+ else:
454
+ assert result == {"a": 1}
455
+
456
+ monkeypatch.setattr(dt, "IS_GT_1", False)
457
+ legacy = dt.Map(String, Integer).result_processor(Dialect(), None)
458
+ assert legacy({"key": ["a"], "value": [1]}) == {"a": 1}
459
+ assert legacy(None) == {}
460
+
461
+
462
+ def test_struct_or_union_requires_fields() -> None:
463
+ dialect = Dialect()
464
+ compiler = dialect.type_compiler
465
+ preparer = dialect.identifier_preparer
466
+
467
+ with pytest.raises(sa_exc.CompileError):
468
+ dt.struct_or_union(dt.Struct(), compiler, preparer)
469
+
470
+ struct = dt.Struct({"first name": String, "age": Integer})
471
+ rendered = dt.struct_or_union(struct, compiler, preparer)
472
+ assert rendered.startswith("(")
473
+ assert rendered.endswith(")")
474
+ assert '"first name"' in rendered