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.
- duckdb_sqlalchemy/__init__.py +1411 -0
- duckdb_sqlalchemy/_supports.py +23 -0
- duckdb_sqlalchemy/bulk.py +192 -0
- duckdb_sqlalchemy/capabilities.py +24 -0
- duckdb_sqlalchemy/config.py +70 -0
- duckdb_sqlalchemy/conftest.py +0 -0
- duckdb_sqlalchemy/datatypes.py +332 -0
- duckdb_sqlalchemy/motherduck.py +237 -0
- duckdb_sqlalchemy/olap.py +39 -0
- duckdb_sqlalchemy/py.typed +0 -0
- duckdb_sqlalchemy/requirements.py +5 -0
- duckdb_sqlalchemy/tests/__init__.py +0 -0
- duckdb_sqlalchemy/tests/conftest.py +55 -0
- duckdb_sqlalchemy/tests/snapshots/test_datatypes/test_interval/schema.sql +3 -0
- duckdb_sqlalchemy/tests/sqlalchemy_suite/conftest.py +11 -0
- duckdb_sqlalchemy/tests/sqlalchemy_suite/test_suite.py +1 -0
- duckdb_sqlalchemy/tests/test_basic.py +718 -0
- duckdb_sqlalchemy/tests/test_core_units.py +474 -0
- duckdb_sqlalchemy/tests/test_datatypes.py +251 -0
- duckdb_sqlalchemy/tests/test_execution_options.py +32 -0
- duckdb_sqlalchemy/tests/test_helpers.py +71 -0
- duckdb_sqlalchemy/tests/test_integration.py +46 -0
- duckdb_sqlalchemy/tests/test_pandas.py +146 -0
- duckdb_sqlalchemy/tests/test_pyarrow.py +61 -0
- duckdb_sqlalchemy/tests/util.py +9 -0
- duckdb_sqlalchemy/url.py +59 -0
- duckdb_sqlalchemy-0.19.0.dist-info/METADATA +182 -0
- duckdb_sqlalchemy-0.19.0.dist-info/RECORD +31 -0
- duckdb_sqlalchemy-0.19.0.dist-info/WHEEL +4 -0
- duckdb_sqlalchemy-0.19.0.dist-info/entry_points.txt +2 -0
- duckdb_sqlalchemy-0.19.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import zlib
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable, Generic, Optional, Tuple, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
import duckdb
|
|
10
|
+
import fsspec
|
|
11
|
+
import sqlalchemy
|
|
12
|
+
from hypothesis import assume, given, settings
|
|
13
|
+
from hypothesis.strategies import text as text_strat
|
|
14
|
+
from packaging.version import Version
|
|
15
|
+
from pytest import LogCaptureFixture, fixture, importorskip, mark, raises
|
|
16
|
+
from sqlalchemy import (
|
|
17
|
+
Column,
|
|
18
|
+
DateTime,
|
|
19
|
+
ForeignKey,
|
|
20
|
+
Integer,
|
|
21
|
+
Interval,
|
|
22
|
+
MetaData,
|
|
23
|
+
Sequence,
|
|
24
|
+
String,
|
|
25
|
+
Table,
|
|
26
|
+
column,
|
|
27
|
+
create_engine,
|
|
28
|
+
func,
|
|
29
|
+
inspect,
|
|
30
|
+
select,
|
|
31
|
+
text,
|
|
32
|
+
types,
|
|
33
|
+
)
|
|
34
|
+
from sqlalchemy.dialects import registry # type: ignore
|
|
35
|
+
from sqlalchemy.engine import Connection, Engine
|
|
36
|
+
from sqlalchemy.engine.reflection import Inspector
|
|
37
|
+
from sqlalchemy.exc import DBAPIError
|
|
38
|
+
from sqlalchemy.ext.declarative import declarative_base
|
|
39
|
+
from sqlalchemy.orm import Session, relationship, sessionmaker
|
|
40
|
+
|
|
41
|
+
from .. import Dialect, insert, supports_attach, supports_user_agent
|
|
42
|
+
from .._supports import has_comment_support
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
# sqlalchemy 2
|
|
46
|
+
from sqlalchemy.engine import ObjectKind # type: ignore[attr-defined]
|
|
47
|
+
from sqlalchemy.orm import Mapped
|
|
48
|
+
except ImportError:
|
|
49
|
+
# sqlalchemy 1
|
|
50
|
+
T = TypeVar("T")
|
|
51
|
+
|
|
52
|
+
class Mapped(Generic[T]): # type: ignore[no-redef]
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@fixture
|
|
57
|
+
def engine() -> Engine:
|
|
58
|
+
registry.register("duckdb", "duckdb_sqlalchemy", "Dialect")
|
|
59
|
+
|
|
60
|
+
eng = create_engine("duckdb:///:memory:")
|
|
61
|
+
Base.metadata.create_all(eng)
|
|
62
|
+
return eng
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Base = declarative_base()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CompressedString(types.TypeDecorator):
|
|
69
|
+
"""Custom Column Type"""
|
|
70
|
+
|
|
71
|
+
impl = types.BLOB
|
|
72
|
+
|
|
73
|
+
def process_bind_param(self, value: Optional[str], dialect: Any) -> Optional[bytes]:
|
|
74
|
+
if value is None:
|
|
75
|
+
return None
|
|
76
|
+
return zlib.compress(value.encode("utf-8"), level=9)
|
|
77
|
+
|
|
78
|
+
def process_result_value(
|
|
79
|
+
self, value: Optional[bytes], dialect: Any
|
|
80
|
+
) -> Optional[str]:
|
|
81
|
+
if value is None:
|
|
82
|
+
return None
|
|
83
|
+
return zlib.decompress(value).decode("utf-8")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TableWithBinary(Base):
|
|
87
|
+
__tablename__ = "table_with_binary"
|
|
88
|
+
|
|
89
|
+
id = Column(Integer(), Sequence("id_seq"), primary_key=True)
|
|
90
|
+
|
|
91
|
+
text = Column(CompressedString())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class FakeModel(Base):
|
|
95
|
+
__tablename__ = "fake"
|
|
96
|
+
|
|
97
|
+
id = Column(Integer, Sequence("fakemodel_id_sequence"), primary_key=True)
|
|
98
|
+
name = Column(String)
|
|
99
|
+
|
|
100
|
+
owner: Mapped["Owner"] = relationship("Owner")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Owner(Base):
|
|
104
|
+
__tablename__ = "owner"
|
|
105
|
+
id = Column(Integer, Sequence("owner_id"), primary_key=True)
|
|
106
|
+
|
|
107
|
+
fake_id = Column(Integer, ForeignKey("fake.id"))
|
|
108
|
+
owned: Mapped[FakeModel] = relationship(FakeModel, back_populates="owner")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class IntervalModel(Base):
|
|
112
|
+
__tablename__ = "IntervalModel"
|
|
113
|
+
|
|
114
|
+
id = Column(Integer, Sequence("IntervalModel_id_sequence"), primary_key=True)
|
|
115
|
+
|
|
116
|
+
field = Column(Interval)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@fixture
|
|
120
|
+
def session(engine: Engine) -> Session:
|
|
121
|
+
return sessionmaker(bind=engine)()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_basic(session: Session) -> None:
|
|
125
|
+
session.add(FakeModel(name="Frank"))
|
|
126
|
+
session.commit()
|
|
127
|
+
|
|
128
|
+
frank = session.query(FakeModel).one() # act
|
|
129
|
+
|
|
130
|
+
assert frank.name == "Frank"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_foreign(session: Session) -> None:
|
|
134
|
+
model = FakeModel(name="Walter")
|
|
135
|
+
session.add(model)
|
|
136
|
+
session.add(Owner(owned=model))
|
|
137
|
+
session.commit()
|
|
138
|
+
|
|
139
|
+
owner = session.query(Owner).one() # act
|
|
140
|
+
|
|
141
|
+
owned = cast(FakeModel, owner.owned)
|
|
142
|
+
assert owned.name == "Walter"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_disabled_server_side_cursors(engine: Engine) -> None:
|
|
146
|
+
connection = engine.connect().execution_options(stream_results=True)
|
|
147
|
+
|
|
148
|
+
session = sessionmaker(bind=connection)()
|
|
149
|
+
|
|
150
|
+
session.add(FakeModel(name="Walter"))
|
|
151
|
+
session.commit()
|
|
152
|
+
|
|
153
|
+
assert list(session.query(FakeModel).yield_per(1))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@given(text_strat())
|
|
157
|
+
@settings(deadline=timedelta(seconds=1))
|
|
158
|
+
def test_simple_string(s: str) -> None:
|
|
159
|
+
assume("\x00" not in s)
|
|
160
|
+
eng = create_engine("duckdb:///:memory:")
|
|
161
|
+
Base.metadata.create_all(eng)
|
|
162
|
+
session = sessionmaker(bind=eng)()
|
|
163
|
+
model = FakeModel(name=s)
|
|
164
|
+
session.add(model)
|
|
165
|
+
session.add(Owner(owned=model))
|
|
166
|
+
session.commit()
|
|
167
|
+
|
|
168
|
+
owner = session.query(Owner).one() # act
|
|
169
|
+
|
|
170
|
+
owned = cast(FakeModel, owner.owned)
|
|
171
|
+
assert owned.name == s
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_get_tables(inspector: Inspector) -> None:
|
|
175
|
+
for table_name in inspector.get_table_names():
|
|
176
|
+
assert inspector.has_table(table_name)
|
|
177
|
+
assert inspector.get_view_names() == []
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@mark.skipif(
|
|
181
|
+
supports_attach is False,
|
|
182
|
+
reason="ATTACH is not supported for DuckDB version < 0.7.0",
|
|
183
|
+
)
|
|
184
|
+
def test_get_schema_names(inspector: Inspector, session: Session) -> None:
|
|
185
|
+
# Using multi-line strings because of all the single and double quotes flying around...
|
|
186
|
+
cmds = [
|
|
187
|
+
"""CREATE SCHEMA "quack quack" """,
|
|
188
|
+
"""CREATE SCHEMA "daffy duck"."you're "" despicable" """,
|
|
189
|
+
]
|
|
190
|
+
for cmd in cmds:
|
|
191
|
+
session.execute(text(cmd))
|
|
192
|
+
session.commit()
|
|
193
|
+
|
|
194
|
+
# Deliberately excluding pg_catalog schema (to align with Postgres)
|
|
195
|
+
names = set(inspector.get_schema_names())
|
|
196
|
+
expected = {
|
|
197
|
+
'"daffy duck".main',
|
|
198
|
+
'"daffy duck"."quack quack"',
|
|
199
|
+
'"daffy duck"."you\'re "" despicable"',
|
|
200
|
+
"memory.main",
|
|
201
|
+
'memory."quack quack"',
|
|
202
|
+
"system.information_schema",
|
|
203
|
+
"system.main",
|
|
204
|
+
"temp.main",
|
|
205
|
+
}
|
|
206
|
+
if Version(duckdb.__version__) <= Version("1.1.3"):
|
|
207
|
+
expected.update(
|
|
208
|
+
{
|
|
209
|
+
'"daffy duck".information_schema',
|
|
210
|
+
"memory.information_schema",
|
|
211
|
+
"temp.information_schema",
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
assert names == expected
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@mark.skipif(
|
|
218
|
+
supports_attach is False,
|
|
219
|
+
reason="ATTACH is not supported for DuckDB version < 0.7.0",
|
|
220
|
+
)
|
|
221
|
+
def test_get_table_names(inspector: Inspector, session: Session) -> None:
|
|
222
|
+
# Using multi-line strings because of all the single and double quotes flying around...
|
|
223
|
+
cmds = [
|
|
224
|
+
"""CREATE TABLE "daffy duck"."quack quack"."t2" (i INTEGER, j INTEGER);""",
|
|
225
|
+
"""CREATE TABLE "t3" (i INTEGER, j INTEGER);""",
|
|
226
|
+
"""CREATE SCHEMA "porky" """,
|
|
227
|
+
"""CREATE TABLE "porky"."t4" (i INTEGER, j INTEGER);""",
|
|
228
|
+
]
|
|
229
|
+
for cmd in cmds:
|
|
230
|
+
session.execute(text(cmd))
|
|
231
|
+
session.commit()
|
|
232
|
+
|
|
233
|
+
for schema, table_names in zip(
|
|
234
|
+
['"daffy duck"."quack quack"', "main", "porky"], [["t1", "t2"], ["t3"], ["t4"]]
|
|
235
|
+
):
|
|
236
|
+
_table_names = inspector.get_table_names(schema=schema)
|
|
237
|
+
assert set(_table_names).issuperset(set(table_names))
|
|
238
|
+
for _table_name in _table_names:
|
|
239
|
+
assert inspector.has_table(_table_name, schema)
|
|
240
|
+
|
|
241
|
+
table_names_all = inspector.get_table_names()
|
|
242
|
+
assert set(table_names_all).issuperset({"t1", "t2", "t3", "t4"})
|
|
243
|
+
for table_name in table_names_all:
|
|
244
|
+
assert inspector.has_table(table_name)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_get_views(conn: Connection, dialect: Dialect) -> None:
|
|
248
|
+
views = dialect.get_view_names(conn)
|
|
249
|
+
assert views == []
|
|
250
|
+
|
|
251
|
+
conn.execute(text("create view test as select 1"))
|
|
252
|
+
conn.execute(
|
|
253
|
+
text("create schema scheme; create view scheme.schema_test as select 1")
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
views = dialect.get_view_names(conn)
|
|
257
|
+
assert views == ["test"]
|
|
258
|
+
|
|
259
|
+
views = dialect.get_view_names(conn, schema="scheme")
|
|
260
|
+
assert views == ["schema_test"]
|
|
261
|
+
|
|
262
|
+
views = dialect.get_view_names(conn, schema="memory.scheme")
|
|
263
|
+
assert views == ["schema_test"]
|
|
264
|
+
|
|
265
|
+
assert dialect.has_table(conn, table_name="test")
|
|
266
|
+
assert dialect.has_table(conn, table_name="schema_test", schema="scheme")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@mark.skipif(os.uname().machine == "aarch64", reason="not supported on aarch64")
|
|
270
|
+
@mark.remote_data
|
|
271
|
+
def test_preload_extension() -> None:
|
|
272
|
+
duckdb.connect().execute("INSTALL httpfs")
|
|
273
|
+
engine = create_engine(
|
|
274
|
+
"duckdb:///",
|
|
275
|
+
connect_args={
|
|
276
|
+
"preload_extensions": ["httpfs"],
|
|
277
|
+
"config": {"s3_region": "ap-southeast-2", "s3_use_ssl": True},
|
|
278
|
+
},
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# check that we get an error indicating that the extension was loaded
|
|
282
|
+
with engine.connect() as conn, raises(Exception, match="HTTP HEAD"):
|
|
283
|
+
conn.execute(
|
|
284
|
+
text("SELECT * FROM read_parquet('https://domain/path/to/file.parquet');")
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@fixture
|
|
289
|
+
def inspector(engine: Engine, session: Session) -> Inspector:
|
|
290
|
+
cmds = [
|
|
291
|
+
"""CREATE TABLE test (id INTEGER);""" """ATTACH ':memory:' AS "daffy duck" """,
|
|
292
|
+
"""CREATE SCHEMA "daffy duck"."quack quack" """,
|
|
293
|
+
"""CREATE TABLE "daffy duck"."quack quack"."t1" (i INTEGER, j INTEGER);""",
|
|
294
|
+
]
|
|
295
|
+
for cmd in cmds:
|
|
296
|
+
session.execute(text(cmd))
|
|
297
|
+
session.commit()
|
|
298
|
+
|
|
299
|
+
meta = MetaData()
|
|
300
|
+
Table("test", meta)
|
|
301
|
+
|
|
302
|
+
return inspect(engine)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_get_columns(inspector: Inspector, session: Session) -> None:
|
|
306
|
+
cols = inspector.get_columns("test", None)
|
|
307
|
+
assert len(cols) == 1
|
|
308
|
+
assert cols[0]["name"] == "id"
|
|
309
|
+
assert inspector.has_table("t1", '"daffy duck"."quack quack"')
|
|
310
|
+
cols1 = inspector.get_columns("t1", None)
|
|
311
|
+
cols2 = inspector.get_columns("t1", '"daffy duck"."quack quack"')
|
|
312
|
+
cols3 = inspector.get_columns("t1", "daffy duck.quack quack")
|
|
313
|
+
assert len(cols1) == 2
|
|
314
|
+
assert cols1[0]["name"] == "i"
|
|
315
|
+
assert cols1[1]["name"] == "j"
|
|
316
|
+
assert cols1[0]["name"] == cols2[0]["name"] == cols3[0]["name"]
|
|
317
|
+
assert cols1[1]["name"] == cols2[1]["name"] == cols3[1]["name"]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_get_foreign_keys(inspector: Inspector) -> None:
|
|
321
|
+
assert inspector.get_foreign_keys("test", None) == []
|
|
322
|
+
assert inspector.get_foreign_keys("t1", '"daffy duck"."quack quack"') == []
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@mark.skipif(
|
|
326
|
+
Version(sqlalchemy.__version__) < Version("2.0.0"),
|
|
327
|
+
reason="2-arg pg_getconstraintdef not yet supported in duckdb",
|
|
328
|
+
)
|
|
329
|
+
def test_get_check_constraints(inspector: Inspector) -> None:
|
|
330
|
+
assert inspector.get_check_constraints("test", None) == []
|
|
331
|
+
assert inspector.get_check_constraints("t1", '"daffy duck"."quack quack"') == []
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_get_unique_constraints(inspector: Inspector) -> None:
|
|
335
|
+
inspector.get_unique_constraints("test", None)
|
|
336
|
+
inspector.get_unique_constraints("t1", '"daffy duck"."quack quack"')
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_reflect(session: Session, engine: Engine) -> None:
|
|
340
|
+
session.execute(text("create table test (id int);"))
|
|
341
|
+
session.commit()
|
|
342
|
+
|
|
343
|
+
meta = MetaData()
|
|
344
|
+
meta.reflect(only=["test"], bind=engine)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_get_multi_columns(engine: Engine) -> None:
|
|
348
|
+
importorskip("sqlalchemy", "2.0.0-rc1")
|
|
349
|
+
with engine.connect() as conn:
|
|
350
|
+
assert cast(Dialect, engine.dialect).get_multi_columns(
|
|
351
|
+
connection=conn,
|
|
352
|
+
schema=None,
|
|
353
|
+
filter_names=set(),
|
|
354
|
+
scope=None,
|
|
355
|
+
kind=cast(Tuple[str, ...], (ObjectKind.TABLE,)),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def test_commit(session: Session, engine: Engine) -> None:
|
|
360
|
+
importorskip("sqlalchemy", "1.4.0")
|
|
361
|
+
session.execute(text("commit;"))
|
|
362
|
+
|
|
363
|
+
InteractiveShell = importorskip("IPython.core.interactiveshell").InteractiveShell
|
|
364
|
+
|
|
365
|
+
shell = InteractiveShell()
|
|
366
|
+
assert not shell.run_line_magic("load_ext", "sql")
|
|
367
|
+
assert not shell.run_line_magic("sql", "duckdb:///:memory:")
|
|
368
|
+
assert shell.run_line_magic("sql", "select 42;") == [(42,)]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_table_reflect(session: Session, engine: Engine) -> None:
|
|
372
|
+
session.execute(text("create table test (id int);"))
|
|
373
|
+
session.commit()
|
|
374
|
+
|
|
375
|
+
meta = MetaData()
|
|
376
|
+
user_table = Table("test", meta)
|
|
377
|
+
insp = inspect(engine)
|
|
378
|
+
|
|
379
|
+
reflect_table = (
|
|
380
|
+
insp.reflecttable if hasattr(insp, "reflecttable") else insp.reflect_table
|
|
381
|
+
)
|
|
382
|
+
reflect_table = cast(Callable[..., Any], reflect_table)
|
|
383
|
+
reflect_table(user_table, None)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def test_fetch_df_chunks() -> None:
|
|
387
|
+
import duckdb
|
|
388
|
+
|
|
389
|
+
duckdb.connect(":memory:").execute("select 1").fetch_df_chunk(1)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_fetchmany(engine: Engine) -> None:
|
|
393
|
+
with engine.connect() as conn:
|
|
394
|
+
res = conn.execute(text("select 1"))
|
|
395
|
+
assert res.fetchmany(1) == [(1,)]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_description() -> None:
|
|
399
|
+
import duckdb
|
|
400
|
+
|
|
401
|
+
duckdb.connect("").description
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def test_intervals(session: Session) -> None:
|
|
405
|
+
session.add(IntervalModel(field=timedelta(days=1)))
|
|
406
|
+
session.commit()
|
|
407
|
+
|
|
408
|
+
owner = session.query(IntervalModel).one() # act
|
|
409
|
+
|
|
410
|
+
assert owner.field == timedelta(days=1)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def test_binary(session: Session) -> None:
|
|
414
|
+
a = TableWithBinary(text="Hello World!")
|
|
415
|
+
session.add(a)
|
|
416
|
+
session.commit()
|
|
417
|
+
|
|
418
|
+
b: TableWithBinary = session.query(TableWithBinary).one()
|
|
419
|
+
assert b.text == "Hello World!"
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@mark.skipif(
|
|
423
|
+
not has_comment_support(), reason="comments not supported by duckdb version"
|
|
424
|
+
)
|
|
425
|
+
def test_comment_support(engine: Engine) -> None:
|
|
426
|
+
"comments not yet supported by duckdb"
|
|
427
|
+
|
|
428
|
+
class Notice(Base):
|
|
429
|
+
__tablename__ = "tb_notice"
|
|
430
|
+
__table_args__ = {"comment": "Notice table"}
|
|
431
|
+
|
|
432
|
+
seqno = Column(
|
|
433
|
+
Integer,
|
|
434
|
+
Sequence("seqno_sequence"),
|
|
435
|
+
primary_key=True,
|
|
436
|
+
comment="Integer representing the sequence number",
|
|
437
|
+
)
|
|
438
|
+
title = Column(
|
|
439
|
+
String(200),
|
|
440
|
+
nullable=False,
|
|
441
|
+
comment="Title of the notice, represented as a string",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
Base.metadata.create_all(bind=engine)
|
|
445
|
+
|
|
446
|
+
inspector = inspect(engine)
|
|
447
|
+
|
|
448
|
+
assert inspector.get_table_comment("tb_notice")["text"] == "Notice table"
|
|
449
|
+
|
|
450
|
+
columns = inspector.get_columns("tb_notice", None)
|
|
451
|
+
assert columns[0]["comment"] == "Integer representing the sequence number"
|
|
452
|
+
assert columns[1]["comment"] == "Title of the notice, represented as a string"
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def test_rowcount() -> None:
|
|
456
|
+
import duckdb
|
|
457
|
+
|
|
458
|
+
assert duckdb.connect().rowcount == -1
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def test_sessions(session: Session) -> None:
|
|
462
|
+
c = IntervalModel(field=timedelta(seconds=5))
|
|
463
|
+
session.add(c)
|
|
464
|
+
session.commit()
|
|
465
|
+
|
|
466
|
+
c2 = session.query(IntervalModel).get(1)
|
|
467
|
+
assert c2
|
|
468
|
+
c2.field = timedelta(days=5)
|
|
469
|
+
session.flush()
|
|
470
|
+
session.commit()
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def test_inmemory() -> None:
|
|
474
|
+
InteractiveShell = importorskip("IPython.core.interactiveshell").InteractiveShell
|
|
475
|
+
|
|
476
|
+
shell = InteractiveShell()
|
|
477
|
+
shell.run_cell("""import sqlalchemy as sa""")
|
|
478
|
+
shell.run_cell("""eng = sa.create_engine("duckdb:///:memory:")""")
|
|
479
|
+
shell.run_cell("""conn = eng.connect()""")
|
|
480
|
+
shell.run_cell("""conn.execute(sa.text("CREATE TABLE t (x int)"))""")
|
|
481
|
+
res = shell.run_cell("""conn.execute(sa.text("SHOW TABLES")).fetchall()""")
|
|
482
|
+
|
|
483
|
+
assert res.result == [("t",)]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def test_config(tmp_path: Path) -> None:
|
|
487
|
+
db_path = tmp_path / "test.db"
|
|
488
|
+
|
|
489
|
+
db = duckdb.connect(str(db_path))
|
|
490
|
+
db.execute("create table hello1 (i int)")
|
|
491
|
+
db.close()
|
|
492
|
+
|
|
493
|
+
eng = create_engine(
|
|
494
|
+
f"duckdb:///{db_path}",
|
|
495
|
+
connect_args={"read_only": True, "config": {"memory_limit": "500mb"}},
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
with raises(
|
|
499
|
+
DBAPIError,
|
|
500
|
+
match='Cannot execute statement of type "CREATE" (on database "test" which is attached )?in read-only mode!',
|
|
501
|
+
):
|
|
502
|
+
with eng.connect() as conn:
|
|
503
|
+
conn.execute(text("create table hello2 (i int)"))
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def test_url_config() -> None:
|
|
507
|
+
eng = create_engine("duckdb:///:memory:?worker_threads=123")
|
|
508
|
+
|
|
509
|
+
with eng.connect() as conn:
|
|
510
|
+
res = conn.execute(text("select current_setting('worker_threads')"))
|
|
511
|
+
row = res.first()
|
|
512
|
+
assert row is not None
|
|
513
|
+
assert row[0] == 123
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def test_url_config_and_dict_config() -> None:
|
|
517
|
+
eng = create_engine(
|
|
518
|
+
"duckdb:///:memory:?worker_threads=123",
|
|
519
|
+
connect_args={"config": {"memory_limit": "500mb"}},
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
with eng.connect() as conn:
|
|
523
|
+
res = conn.execute(
|
|
524
|
+
text(
|
|
525
|
+
"select current_setting('worker_threads'), current_setting('memory_limit')"
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
row = res.first()
|
|
529
|
+
assert row is not None
|
|
530
|
+
worker_threads, memory_limit = row
|
|
531
|
+
assert worker_threads == 123
|
|
532
|
+
assert memory_limit in ("500.0MB", "476.8 MiB")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
user_agent_re = r"duckdb/.*(.*) python(/.*)? duckdb-sqlalchemy/.*(sqlalchemy/.*)"
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@mark.skipif(
|
|
539
|
+
supports_user_agent is False,
|
|
540
|
+
reason="custom_user_agent is not supported for DuckDB version < 0.9.2",
|
|
541
|
+
)
|
|
542
|
+
def test_user_agent() -> None:
|
|
543
|
+
eng = create_engine("duckdb:///:memory:")
|
|
544
|
+
|
|
545
|
+
with eng.connect() as conn:
|
|
546
|
+
res = conn.execute(text("PRAGMA USER_AGENT"))
|
|
547
|
+
row = res.first()
|
|
548
|
+
assert row is not None
|
|
549
|
+
assert re.match(user_agent_re, row[0])
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@mark.skipif(
|
|
553
|
+
supports_user_agent is False,
|
|
554
|
+
reason="custom_user_agent is not supported for DuckDB version < 0.9.2",
|
|
555
|
+
)
|
|
556
|
+
def test_user_agent_with_custom_user_agent() -> None:
|
|
557
|
+
connect_args = {"config": {"custom_user_agent": "custom"}}
|
|
558
|
+
eng = create_engine("duckdb:///:memory:", connect_args=connect_args)
|
|
559
|
+
|
|
560
|
+
with eng.connect() as conn:
|
|
561
|
+
res = conn.execute(text("PRAGMA USER_AGENT"))
|
|
562
|
+
row = res.first()
|
|
563
|
+
assert row is not None
|
|
564
|
+
assert re.match(user_agent_re + " custom", row[0])
|
|
565
|
+
|
|
566
|
+
# Check that connect hasn't mutated the caller's data.
|
|
567
|
+
assert connect_args["config"]["custom_user_agent"] == "custom"
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def test_do_ping(tmp_path: Path, caplog: LogCaptureFixture) -> None:
|
|
571
|
+
engine = create_engine(
|
|
572
|
+
"duckdb:///" + str(tmp_path / "db"),
|
|
573
|
+
pool_pre_ping=True,
|
|
574
|
+
pool_size=1,
|
|
575
|
+
poolclass=sqlalchemy.pool.QueuePool,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
logger = cast(logging.Logger, engine.pool.logger) # type: ignore
|
|
579
|
+
logger.setLevel(logging.DEBUG)
|
|
580
|
+
|
|
581
|
+
with caplog.at_level(logging.DEBUG, logger=logger.name):
|
|
582
|
+
engine.connect() # create a connection in the pool
|
|
583
|
+
assert (
|
|
584
|
+
engine.connect() is not None
|
|
585
|
+
) # grab the "stale" connection, which will cause a ping
|
|
586
|
+
|
|
587
|
+
assert any(
|
|
588
|
+
"Pool pre-ping on connection" in message for message in caplog.messages
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def test_try_cast(engine: Engine) -> None:
|
|
593
|
+
try_cast = importorskip("sqlalchemy", "2.0.14").try_cast
|
|
594
|
+
|
|
595
|
+
with engine.connect() as conn:
|
|
596
|
+
query = select(try_cast("2022-01-01", DateTime))
|
|
597
|
+
assert conn.execute(query).one() == (datetime(2022, 1, 1),)
|
|
598
|
+
|
|
599
|
+
query = select(try_cast("not a date", DateTime))
|
|
600
|
+
assert conn.execute(query).one() == (None,)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def test_params(engine: Engine) -> None:
|
|
604
|
+
s = text("SELECT :x")
|
|
605
|
+
with engine.connect() as conn:
|
|
606
|
+
assert ("m",) == conn.execute(s, {"x": "m"}).fetchone()
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def test_361(engine: Engine) -> None:
|
|
610
|
+
importorskip("sqlalchemy", "2.0.0")
|
|
611
|
+
|
|
612
|
+
with engine.connect() as conn:
|
|
613
|
+
conn.execute(text("create table test (dt date);"))
|
|
614
|
+
conn.execute(text("insert into test values ('2022-01-01');"))
|
|
615
|
+
|
|
616
|
+
metadata = MetaData()
|
|
617
|
+
metadata.reflect(bind=conn)
|
|
618
|
+
test = metadata.tables["test"]
|
|
619
|
+
part = "year"
|
|
620
|
+
date_part = func.date_part(part, test.c.dt)
|
|
621
|
+
|
|
622
|
+
stmt = select(date_part).select_from(test).group_by(date_part)
|
|
623
|
+
assert conn.execute(stmt).fetchall() == [(2022,)]
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def test_close(engine: Engine) -> None:
|
|
627
|
+
with engine.connect() as conn:
|
|
628
|
+
res = conn.execute(text("select 1"))
|
|
629
|
+
res.close()
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def test_with_cache(tmp_path: Path) -> None:
|
|
633
|
+
importorskip("duckdb", "1.0.0")
|
|
634
|
+
tmp_db_path = str(tmp_path / "db_cached")
|
|
635
|
+
engine1 = create_engine(f"duckdb:///{tmp_db_path}?threads=1")
|
|
636
|
+
engine2 = create_engine(f"duckdb:///{tmp_db_path}?threads=1")
|
|
637
|
+
with engine1.connect() as conn1:
|
|
638
|
+
with engine2.connect() as conn2:
|
|
639
|
+
res1 = conn1.execute(
|
|
640
|
+
text("select value from duckdb_settings() where name = 'threads'")
|
|
641
|
+
).fetchall()
|
|
642
|
+
res2 = conn2.execute(
|
|
643
|
+
text("select value from duckdb_settings() where name = 'threads'")
|
|
644
|
+
).fetchall()
|
|
645
|
+
assert res1 == res2
|
|
646
|
+
# TODO: how do we validate that both connections point to the same database instance?
|
|
647
|
+
assert res1[0][0] == "1"
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def test_no_cache(tmp_path: Path) -> None:
|
|
651
|
+
tmp_db_path = str(tmp_path / "db_no_cache")
|
|
652
|
+
engine1 = create_engine(f"duckdb:///{tmp_db_path}?threads=1&user=1")
|
|
653
|
+
engine2 = create_engine(f"duckdb:///{tmp_db_path}?threads=2&user=2")
|
|
654
|
+
with engine1.connect() as conn1:
|
|
655
|
+
with engine2.connect() as conn2:
|
|
656
|
+
res1 = conn1.execute(
|
|
657
|
+
text("select value from duckdb_settings() where name = 'threads'")
|
|
658
|
+
).fetchall()
|
|
659
|
+
res2 = conn2.execute(
|
|
660
|
+
text("select value from duckdb_settings() where name = 'threads'")
|
|
661
|
+
).fetchall()
|
|
662
|
+
assert res1 != res2
|
|
663
|
+
assert res1[0][0] == "1"
|
|
664
|
+
assert res2[0][0] == "2"
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def test_reflection(engine: Engine) -> None:
|
|
668
|
+
metadata = MetaData()
|
|
669
|
+
with engine.connect() as conn:
|
|
670
|
+
conn.execute(text("CREATE TABLE tbl(col1 INTEGER)"))
|
|
671
|
+
metadata.reflect(engine)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def test_upsert(session: Session) -> None:
|
|
675
|
+
class User(Base):
|
|
676
|
+
__tablename__ = "users"
|
|
677
|
+
|
|
678
|
+
id = Column(Integer(), Sequence("id_seq"), primary_key=True)
|
|
679
|
+
name = Column(String, unique=True)
|
|
680
|
+
fullname = Column(String)
|
|
681
|
+
|
|
682
|
+
Base.metadata.create_all(session.bind)
|
|
683
|
+
stmt = insert(User).values(
|
|
684
|
+
[
|
|
685
|
+
{"name": "spongebob", "fullname": "Spongebob Squarepants"},
|
|
686
|
+
{"name": "sandy", "fullname": "Sandy Cheeks"},
|
|
687
|
+
{"name": "patrick", "fullname": "Patrick Star"},
|
|
688
|
+
{"name": "squidward", "fullname": "Squidward Tentacles"},
|
|
689
|
+
{"name": "ehkrabs", "fullname": "Eugene H. Krabs"},
|
|
690
|
+
]
|
|
691
|
+
)
|
|
692
|
+
stmt = stmt.on_conflict_do_update(
|
|
693
|
+
index_elements=[User.name], set_=dict(fullname=stmt.excluded.fullname)
|
|
694
|
+
)
|
|
695
|
+
session.execute(stmt)
|
|
696
|
+
|
|
697
|
+
assert session.query(User).count() == 5
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def test_reserved_keywords(engine: Engine) -> None:
|
|
701
|
+
stmt = select(column("qualify"))
|
|
702
|
+
|
|
703
|
+
assert str(stmt.compile(engine)) == 'SELECT "qualify"'
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def test_register_filesystem() -> None:
|
|
707
|
+
memory_fs = fsspec.filesystem("memory")
|
|
708
|
+
file_fs = fsspec.filesystem("file")
|
|
709
|
+
engine = create_engine(
|
|
710
|
+
"duckdb:///",
|
|
711
|
+
connect_args={
|
|
712
|
+
"register_filesystems": [memory_fs, file_fs],
|
|
713
|
+
},
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
with engine.connect() as conn:
|
|
717
|
+
duckdb_conn = getattr(conn.connection.dbapi_connection, "_ConnectionWrapper__c")
|
|
718
|
+
assert duckdb.list_filesystems(connection=duckdb_conn) == ["memory", "file"]
|