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,251 @@
|
|
|
1
|
+
import decimal
|
|
2
|
+
import json
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Any, Callable, Dict, Type, cast
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from packaging.version import Version
|
|
8
|
+
from pytest import importorskip, mark
|
|
9
|
+
from pytest_snapshot.plugin import Snapshot
|
|
10
|
+
from sqlalchemy import (
|
|
11
|
+
Column,
|
|
12
|
+
Integer,
|
|
13
|
+
Interval,
|
|
14
|
+
MetaData,
|
|
15
|
+
Sequence,
|
|
16
|
+
String,
|
|
17
|
+
Table,
|
|
18
|
+
inspect,
|
|
19
|
+
schema,
|
|
20
|
+
select,
|
|
21
|
+
text,
|
|
22
|
+
)
|
|
23
|
+
from sqlalchemy.dialects.postgresql import UUID
|
|
24
|
+
from sqlalchemy.engine import Engine, create_engine
|
|
25
|
+
from sqlalchemy.ext.declarative import declarative_base
|
|
26
|
+
from sqlalchemy.orm import Session
|
|
27
|
+
from sqlalchemy.sql import sqltypes
|
|
28
|
+
from sqlalchemy.types import FLOAT, JSON
|
|
29
|
+
|
|
30
|
+
from .._supports import duckdb_version, has_uhugeint_support
|
|
31
|
+
from ..datatypes import Map, Struct, types
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mark.parametrize("coltype", types)
|
|
35
|
+
@mark.skipif(not has_uhugeint_support, reason="duckdb version too old")
|
|
36
|
+
def test_unsigned_integer_type(
|
|
37
|
+
engine: Engine, session: Session, coltype: Type[Integer]
|
|
38
|
+
) -> None:
|
|
39
|
+
Base = declarative_base()
|
|
40
|
+
|
|
41
|
+
tname = "table"
|
|
42
|
+
table = type(
|
|
43
|
+
"Table",
|
|
44
|
+
(Base,),
|
|
45
|
+
{
|
|
46
|
+
"__tablename__": tname,
|
|
47
|
+
"id": Column(Integer, primary_key=True, default=0),
|
|
48
|
+
"a": Column(coltype),
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
Base.metadata.create_all(engine)
|
|
52
|
+
|
|
53
|
+
has_table = cast(
|
|
54
|
+
Callable[[str], bool],
|
|
55
|
+
engine.has_table if hasattr(engine, "has_table") else inspect(engine).has_table,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert has_table(tname)
|
|
59
|
+
|
|
60
|
+
session.add(table(a=1))
|
|
61
|
+
session.commit()
|
|
62
|
+
|
|
63
|
+
assert session.query(table).one()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@mark.remote_data()
|
|
67
|
+
def test_raw_json(engine: Engine) -> None:
|
|
68
|
+
importorskip("duckdb", "0.9.3.dev4040")
|
|
69
|
+
|
|
70
|
+
with engine.connect() as conn:
|
|
71
|
+
assert conn.execute(text("load json"))
|
|
72
|
+
|
|
73
|
+
assert conn.execute(text("select {'Hello': 'world'}::JSON")).fetchone() == (
|
|
74
|
+
{"Hello": "world"},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@mark.remote_data()
|
|
79
|
+
def test_custom_json_serializer() -> None:
|
|
80
|
+
def default(o: Any) -> Any:
|
|
81
|
+
if isinstance(o, decimal.Decimal):
|
|
82
|
+
return {"__tag": "decimal", "value": str(o)}
|
|
83
|
+
|
|
84
|
+
def object_hook(pairs: Dict[str, Any]) -> Any:
|
|
85
|
+
if pairs.get("__tag", None) == "decimal":
|
|
86
|
+
return decimal.Decimal(pairs["value"])
|
|
87
|
+
else:
|
|
88
|
+
return pairs
|
|
89
|
+
|
|
90
|
+
engine = create_engine(
|
|
91
|
+
"duckdb://",
|
|
92
|
+
json_serializer=json.JSONEncoder(default=default).encode,
|
|
93
|
+
json_deserializer=json.JSONDecoder(object_hook=object_hook).decode,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
Base = declarative_base()
|
|
97
|
+
|
|
98
|
+
class Entry(Base):
|
|
99
|
+
__tablename__ = "test_json"
|
|
100
|
+
id = Column(Integer, Sequence("id_seq"), primary_key=True)
|
|
101
|
+
data = Column(JSON, nullable=False)
|
|
102
|
+
|
|
103
|
+
Base.metadata.create_all(engine)
|
|
104
|
+
|
|
105
|
+
with engine.connect() as conn:
|
|
106
|
+
session = Session(bind=conn)
|
|
107
|
+
|
|
108
|
+
data = {"hello": decimal.Decimal("42")}
|
|
109
|
+
|
|
110
|
+
session.add(Entry(data=data)) # type: ignore[call-arg]
|
|
111
|
+
session.commit()
|
|
112
|
+
|
|
113
|
+
(res,) = session.execute(select(Entry)).one()
|
|
114
|
+
|
|
115
|
+
assert res.data == data
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_json(engine: Engine, session: Session) -> None:
|
|
119
|
+
base = declarative_base()
|
|
120
|
+
|
|
121
|
+
class Entry(base):
|
|
122
|
+
__tablename__ = "test_json"
|
|
123
|
+
|
|
124
|
+
id = Column(Integer, primary_key=True, default=0)
|
|
125
|
+
meta = Column(JSON, nullable=False)
|
|
126
|
+
|
|
127
|
+
base.metadata.create_all(bind=engine)
|
|
128
|
+
|
|
129
|
+
session.add(Entry(meta={"hello": "world"})) # type: ignore[call-arg]
|
|
130
|
+
session.commit()
|
|
131
|
+
|
|
132
|
+
result = session.query(Entry).one()
|
|
133
|
+
|
|
134
|
+
assert result.meta == {"hello": "world"}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_uuid(engine: Engine, session: Session) -> None:
|
|
138
|
+
importorskip("duckdb", "0.7.1")
|
|
139
|
+
base = declarative_base()
|
|
140
|
+
|
|
141
|
+
class Entry(base):
|
|
142
|
+
__tablename__ = "test_uuid"
|
|
143
|
+
|
|
144
|
+
id = Column(UUID, primary_key=True, default=0)
|
|
145
|
+
|
|
146
|
+
base.metadata.create_all(bind=engine)
|
|
147
|
+
|
|
148
|
+
ident = uuid4()
|
|
149
|
+
|
|
150
|
+
session.add(Entry(id=ident)) # type: ignore[call-arg]
|
|
151
|
+
session.commit()
|
|
152
|
+
|
|
153
|
+
result = session.query(Entry).one()
|
|
154
|
+
|
|
155
|
+
assert result.id == ident
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_double_in_sqla_v2(engine: Engine) -> None:
|
|
159
|
+
with engine.begin() as con:
|
|
160
|
+
con.execute(text("CREATE TABLE t (x DOUBLE)"))
|
|
161
|
+
con.execute(text("INSERT INTO t VALUES (1.0), (2.0), (3.0)"))
|
|
162
|
+
|
|
163
|
+
md = MetaData()
|
|
164
|
+
|
|
165
|
+
t = Table("t", md, autoload_with=engine)
|
|
166
|
+
|
|
167
|
+
with engine.begin() as con:
|
|
168
|
+
con.execute(t.select())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_all_types_reflection(engine: Engine) -> None:
|
|
172
|
+
importorskip("sqlalchemy", "1.4.0")
|
|
173
|
+
importorskip("duckdb", "0.5.1")
|
|
174
|
+
|
|
175
|
+
with warnings.catch_warnings() as capture, engine.connect() as conn:
|
|
176
|
+
conn.execute(text("create table t2 as select * from test_all_types()"))
|
|
177
|
+
table = Table("t2", MetaData(), autoload_with=conn)
|
|
178
|
+
for col in table.columns:
|
|
179
|
+
name = col.name
|
|
180
|
+
if name.endswith("_enum") and duckdb_version < Version("0.7.1"):
|
|
181
|
+
continue
|
|
182
|
+
if "array" in name or "struct" in name or "map" in name or "union" in name:
|
|
183
|
+
assert col.type == sqltypes.NULLTYPE, name
|
|
184
|
+
else:
|
|
185
|
+
assert col.type != sqltypes.NULLTYPE, name
|
|
186
|
+
assert not capture
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_nested_types(engine: Engine, session: Session) -> None:
|
|
190
|
+
importorskip("duckdb", "0.5.0") # nested types require at least duckdb 0.5.0
|
|
191
|
+
base = declarative_base()
|
|
192
|
+
|
|
193
|
+
class Entry(base):
|
|
194
|
+
__tablename__ = "test_struct"
|
|
195
|
+
|
|
196
|
+
id = Column(Integer, primary_key=True, default=0)
|
|
197
|
+
struct = Column(Struct(fields={"name": String}))
|
|
198
|
+
map = Column(Map(String, Integer))
|
|
199
|
+
# union = Column(Union(fields={"name": String, "age": Integer}))
|
|
200
|
+
|
|
201
|
+
base.metadata.create_all(bind=engine)
|
|
202
|
+
|
|
203
|
+
struct_data = {"name": "Edgar"}
|
|
204
|
+
map_data = {"one": 1, "two": 2}
|
|
205
|
+
|
|
206
|
+
session.add(Entry(struct=struct_data, map=map_data)) # type: ignore[call-arg]
|
|
207
|
+
session.commit()
|
|
208
|
+
|
|
209
|
+
result = session.query(Entry).one()
|
|
210
|
+
|
|
211
|
+
assert result.struct == struct_data
|
|
212
|
+
assert result.map == map_data
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_double_nested_types(engine: Engine, session: Session) -> None:
|
|
216
|
+
"""Test for https://github.com/leonardovida/duckdb-sqlalchemy/issues/1138"""
|
|
217
|
+
importorskip("duckdb", "0.5.0") # nested types require at least duckdb 0.5.0
|
|
218
|
+
base = declarative_base()
|
|
219
|
+
|
|
220
|
+
class Entry(base):
|
|
221
|
+
__tablename__ = "test_struct"
|
|
222
|
+
|
|
223
|
+
id = Column(Integer, primary_key=True, default=0)
|
|
224
|
+
outer = Column(Struct({"inner": Struct({"val": Integer})}))
|
|
225
|
+
|
|
226
|
+
base.metadata.create_all(bind=engine)
|
|
227
|
+
|
|
228
|
+
outer = {"inner": {"val": 42}}
|
|
229
|
+
|
|
230
|
+
session.add(Entry(outer=outer)) # type: ignore[call-arg]
|
|
231
|
+
session.commit()
|
|
232
|
+
|
|
233
|
+
result = session.query(Entry).one()
|
|
234
|
+
|
|
235
|
+
assert result.outer == outer
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_interval(engine: Engine, snapshot: Snapshot) -> None:
|
|
239
|
+
test_table = Table("test_table", MetaData(), Column("duration", Interval))
|
|
240
|
+
create_sql = f"{str(schema.CreateTable(test_table).compile(engine)).strip()}\n"
|
|
241
|
+
snapshot.assert_match(create_sql, "schema.sql")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_div_is_floordiv(engine: Engine) -> None:
|
|
245
|
+
test_table = Table(
|
|
246
|
+
"test_table", MetaData(), Column("value", FLOAT), Column("eur2usd_rate", FLOAT)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
stmt = test_table.c.value / test_table.c.eur2usd_rate
|
|
250
|
+
|
|
251
|
+
assert str(stmt.compile(engine)) == "test_table.value / test_table.eur2usd_rate"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from sqlalchemy import Column, Integer, MetaData, String, Table, create_engine, select
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_bulk_insert_register_path() -> None:
|
|
8
|
+
if (
|
|
9
|
+
importlib.util.find_spec("pandas") is None
|
|
10
|
+
and importlib.util.find_spec("pyarrow") is None
|
|
11
|
+
):
|
|
12
|
+
pytest.skip("pandas or pyarrow is required for bulk insert fast path")
|
|
13
|
+
|
|
14
|
+
engine = create_engine("duckdb:///:memory:")
|
|
15
|
+
md = MetaData()
|
|
16
|
+
t = Table(
|
|
17
|
+
"bulk_insert",
|
|
18
|
+
md,
|
|
19
|
+
Column("id", Integer),
|
|
20
|
+
Column("name", String),
|
|
21
|
+
)
|
|
22
|
+
md.create_all(engine)
|
|
23
|
+
|
|
24
|
+
rows = [{"id": 1, "name": "Ada"}, {"id": 2, "name": "Grace"}]
|
|
25
|
+
|
|
26
|
+
with engine.begin() as conn:
|
|
27
|
+
conn = conn.execution_options(duckdb_copy_threshold=1)
|
|
28
|
+
conn.execute(t.insert(), rows)
|
|
29
|
+
|
|
30
|
+
with engine.connect() as conn:
|
|
31
|
+
result = conn.execute(select(t.c.id, t.c.name).order_by(t.c.id)).fetchall()
|
|
32
|
+
assert result == [(1, "Ada"), (2, "Grace")]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from urllib.parse import parse_qs
|
|
2
|
+
|
|
3
|
+
import sqlalchemy
|
|
4
|
+
from packaging.version import Version
|
|
5
|
+
from pytest import raises
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
|
|
8
|
+
from duckdb_sqlalchemy import URL, Dialect, make_url, read_parquet
|
|
9
|
+
from duckdb_sqlalchemy.config import get_core_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_url_helper_round_trip() -> None:
|
|
13
|
+
url = URL(database=":memory:", read_only=True)
|
|
14
|
+
assert url.drivername == "duckdb"
|
|
15
|
+
assert url.database == ":memory:"
|
|
16
|
+
assert str(url.query["read_only"]).lower() == "true"
|
|
17
|
+
|
|
18
|
+
base = make_url(database=":memory:")
|
|
19
|
+
rendered = base.render_as_string(hide_password=False)
|
|
20
|
+
assert rendered.startswith("duckdb:///")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_read_parquet_helper() -> None:
|
|
24
|
+
if Version(sqlalchemy.__version__) < Version("1.4.0"):
|
|
25
|
+
with raises(NotImplementedError):
|
|
26
|
+
read_parquet("data/events.parquet", columns=["event_id"])
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
parquet = read_parquet("data/events.parquet", columns=["event_id"])
|
|
30
|
+
stmt = select(parquet.c.event_id).select_from(parquet)
|
|
31
|
+
sql = str(stmt.compile(dialect=Dialect(), compile_kwargs={"literal_binds": True}))
|
|
32
|
+
assert "read_parquet" in sql
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_motherduck_config_env_and_ttl(monkeypatch) -> None:
|
|
36
|
+
get_core_config()
|
|
37
|
+
|
|
38
|
+
captured = {}
|
|
39
|
+
|
|
40
|
+
class DummyConn:
|
|
41
|
+
def execute(self, *args, **kwargs):
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def register_filesystem(self, filesystem):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def close(self):
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def fake_connect(*cargs, **cparams):
|
|
51
|
+
captured.update(cparams)
|
|
52
|
+
return DummyConn()
|
|
53
|
+
|
|
54
|
+
import duckdb_sqlalchemy
|
|
55
|
+
|
|
56
|
+
monkeypatch.setenv("motherduck_token", "token123")
|
|
57
|
+
monkeypatch.delenv("MOTHERDUCK_TOKEN", raising=False)
|
|
58
|
+
monkeypatch.setattr(duckdb_sqlalchemy.duckdb, "connect", fake_connect)
|
|
59
|
+
|
|
60
|
+
dialect = Dialect()
|
|
61
|
+
dialect.connect(
|
|
62
|
+
database="md:my_db",
|
|
63
|
+
url_config={"dbinstance_inactivity_ttl": "1h"},
|
|
64
|
+
config={},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
assert captured["config"]["motherduck_token"] == "token123"
|
|
68
|
+
database, query = captured["database"].split("?", 1)
|
|
69
|
+
assert database == "md:my_db"
|
|
70
|
+
assert parse_qs(query)["dbinstance_inactivity_ttl"] == ["1h"]
|
|
71
|
+
assert "motherduck_dbinstance_inactivity_ttl" not in captured["config"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import duckdb
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from pytest import mark, raises
|
|
4
|
+
from sqlalchemy import __version__, text
|
|
5
|
+
from sqlalchemy.engine import create_engine
|
|
6
|
+
from sqlalchemy.engine.base import Connection
|
|
7
|
+
from sqlalchemy.exc import ProgrammingError
|
|
8
|
+
|
|
9
|
+
df = pd.DataFrame([{"a": 1}])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@mark.skipif(not hasattr(Connection, "exec_driver_sql"), reason="Needs exec_driver_sql")
|
|
13
|
+
def test_register_driver(conn: Connection) -> None:
|
|
14
|
+
conn.exec_driver_sql("register", ("test_df_driver", df)) # type: ignore[arg-type]
|
|
15
|
+
conn.execute(text("select * from test_df_driver"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_plain_register(conn: Connection) -> None:
|
|
19
|
+
if __version__.startswith("1.3"):
|
|
20
|
+
conn.execute(text("register"), {"name": "test_df", "df": df})
|
|
21
|
+
else:
|
|
22
|
+
conn.execute(text("register(:name, :df)"), {"name": "test_df", "df": df})
|
|
23
|
+
conn.execute(text("select * from test_df"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
duckdb_version = duckdb.__version__
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@mark.remote_data
|
|
30
|
+
@mark.skipif(
|
|
31
|
+
"dev" in duckdb_version, reason="md extension not available for dev builds"
|
|
32
|
+
)
|
|
33
|
+
@mark.skipif(
|
|
34
|
+
duckdb_version != "1.1.1", reason="md extension not available for this version"
|
|
35
|
+
)
|
|
36
|
+
def test_motherduck() -> None:
|
|
37
|
+
engine = create_engine(
|
|
38
|
+
"duckdb:///md:motherdb",
|
|
39
|
+
connect_args={"config": {"motherduck_token": "motherduckdb_token"}},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
with raises(
|
|
43
|
+
ProgrammingError,
|
|
44
|
+
match="Jwt is not in the form of Header.Payload.Signature with two dots and 3 sections",
|
|
45
|
+
):
|
|
46
|
+
engine.connect()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#! /usr/bin/env python3
|
|
2
|
+
# vim:fenc=utf-8
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Test pandas functionality.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pathlib
|
|
9
|
+
import random
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from itertools import product
|
|
13
|
+
from typing import Dict, List, Optional, Tuple, Union, cast
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from pandas.testing import assert_frame_equal
|
|
17
|
+
from pytest import importorskip, mark
|
|
18
|
+
from sqlalchemy import create_engine
|
|
19
|
+
|
|
20
|
+
from .util import sqlalchemy_1_only
|
|
21
|
+
|
|
22
|
+
pytestmark = sqlalchemy_1_only
|
|
23
|
+
|
|
24
|
+
_possible_args = OrderedDict(
|
|
25
|
+
{
|
|
26
|
+
"chunksize": [None, 1, 10, 100],
|
|
27
|
+
"if_exists": ["fail", "replace", "append"],
|
|
28
|
+
"method": [
|
|
29
|
+
None,
|
|
30
|
+
"multi",
|
|
31
|
+
], # TODO Implement a callable insert method?
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
args = {
|
|
36
|
+
"to_sql": ["chunksize", "if_exists", "method"],
|
|
37
|
+
"read_sql": ["chunksize"],
|
|
38
|
+
}
|
|
39
|
+
params = {
|
|
40
|
+
k: list(
|
|
41
|
+
product(*(_v for _k, _v in _possible_args.items() if _k in args[k])) # type: ignore
|
|
42
|
+
)
|
|
43
|
+
for k in args
|
|
44
|
+
}
|
|
45
|
+
params_strings = {k: (",".join([str(_k) for _k in args[k]])) for k in args}
|
|
46
|
+
|
|
47
|
+
# Generate a DataFrame of 100 rows.
|
|
48
|
+
sample_data: Dict[str, List[Union[datetime, str, int, float]]] = {
|
|
49
|
+
"datetime": [],
|
|
50
|
+
"int": [],
|
|
51
|
+
"str": [],
|
|
52
|
+
"float": [],
|
|
53
|
+
}
|
|
54
|
+
sample_rowcount = max(
|
|
55
|
+
cs
|
|
56
|
+
for cs in cast(List[Optional[int]], _possible_args["chunksize"])
|
|
57
|
+
if cs is not None
|
|
58
|
+
)
|
|
59
|
+
for i in range(sample_rowcount):
|
|
60
|
+
sample_data["datetime"].append(datetime.now(timezone.utc))
|
|
61
|
+
sample_data["int"].append(random.randint(0, 100))
|
|
62
|
+
sample_data["float"].append(round(random.random(), 5))
|
|
63
|
+
sample_data["str"].append("foo")
|
|
64
|
+
|
|
65
|
+
sample_df: pd.DataFrame = pd.DataFrame(sample_data)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@mark.parametrize(params_strings["to_sql"], params["to_sql"])
|
|
69
|
+
def test_to_sql(
|
|
70
|
+
chunksize: Optional[int],
|
|
71
|
+
if_exists: str,
|
|
72
|
+
method: Optional[str],
|
|
73
|
+
index: bool = False,
|
|
74
|
+
) -> None:
|
|
75
|
+
eng = create_engine("duckdb:///:memory:")
|
|
76
|
+
try:
|
|
77
|
+
sample_df.to_sql(
|
|
78
|
+
name="foo",
|
|
79
|
+
con=eng,
|
|
80
|
+
if_exists=if_exists,
|
|
81
|
+
chunksize=chunksize,
|
|
82
|
+
index=index,
|
|
83
|
+
)
|
|
84
|
+
except ValueError as e:
|
|
85
|
+
if if_exists != "fail":
|
|
86
|
+
raise e
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
table_name = "test_read"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Perform the test twice:
|
|
93
|
+
# Once for reading the table name (testing reflection),
|
|
94
|
+
@mark.parametrize(params_strings["read_sql"], params["read_sql"])
|
|
95
|
+
def test_read_sql_reflection(
|
|
96
|
+
chunksize: Tuple[Optional[int]],
|
|
97
|
+
) -> None:
|
|
98
|
+
run_query(table_name, chunksize[0])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# and once for directly executing a SQL query.
|
|
102
|
+
@mark.parametrize(params_strings["read_sql"], params["read_sql"])
|
|
103
|
+
def test_read_sql(
|
|
104
|
+
chunksize: Tuple[Optional[int]],
|
|
105
|
+
) -> None:
|
|
106
|
+
run_query(f"SELECT * FROM {table_name}", chunksize[0])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run_query(query: str, chunksize: Optional[int]) -> None:
|
|
110
|
+
eng = create_engine("duckdb:///:memory:")
|
|
111
|
+
|
|
112
|
+
sample_df.to_sql(name=table_name, con=eng, if_exists="replace")
|
|
113
|
+
|
|
114
|
+
result = pd.read_sql(query, eng, chunksize=chunksize)
|
|
115
|
+
chunks = [result] if chunksize is None else list(result)
|
|
116
|
+
if chunksize is None:
|
|
117
|
+
df = cast(pd.DataFrame, chunks[0])
|
|
118
|
+
assert len(df) == sample_rowcount
|
|
119
|
+
else:
|
|
120
|
+
importorskip("duckdb", "0.5.0")
|
|
121
|
+
|
|
122
|
+
# Assert that the chunks are the size specified.
|
|
123
|
+
df = cast(pd.DataFrame, chunks[0])
|
|
124
|
+
assert len(df) == chunksize
|
|
125
|
+
# Assert that the expected number of chunks was returned.
|
|
126
|
+
assert (sample_rowcount / chunksize) == len(chunks)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_read_sql_duckdb_table(tmp_path: pathlib.Path) -> None:
|
|
130
|
+
import duckdb
|
|
131
|
+
|
|
132
|
+
db = str(tmp_path / "test_db.duckdb")
|
|
133
|
+
con = duckdb.connect(database=db, read_only=False)
|
|
134
|
+
df = pd.DataFrame(
|
|
135
|
+
{"a": [1, 2, 3, 4], "b": [0.1, 0.2, 0.3, 0.4], "c": ["a", "b", "c", "d"]}
|
|
136
|
+
)
|
|
137
|
+
con.register("df_view", df)
|
|
138
|
+
con.execute("CREATE TABLE test_data AS SELECT * FROM df_view;")
|
|
139
|
+
con.close()
|
|
140
|
+
|
|
141
|
+
engine = create_engine(f"duckdb:///{db}")
|
|
142
|
+
|
|
143
|
+
result = pd.read_sql("SELECT * FROM test_data", engine)
|
|
144
|
+
assert_frame_equal(result, df)
|
|
145
|
+
result = pd.read_sql("test_data", engine)
|
|
146
|
+
assert_frame_equal(result, df)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from pyarrow import RecordBatch, RecordBatchReader
|
|
2
|
+
from pyarrow import Table as ArrowTable
|
|
3
|
+
from sqlalchemy import MetaData, Table, create_engine, text
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_fetch_arrow() -> None:
|
|
7
|
+
engine = create_engine("duckdb:///:memory:")
|
|
8
|
+
with engine.begin() as con:
|
|
9
|
+
con.execute(text("CREATE TABLE tbl (label VARCHAR, value DOUBLE)"))
|
|
10
|
+
con.execute(
|
|
11
|
+
text(
|
|
12
|
+
"INSERT INTO tbl VALUES ('xx',-1.0), ('ww',-4.5), ('zz',6.0), ('yy',2.5)"
|
|
13
|
+
)
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
md = MetaData()
|
|
17
|
+
t = Table("tbl", md, autoload_with=engine)
|
|
18
|
+
stmt = t.select().where(t.c.value > -4.0).order_by(t.c.label)
|
|
19
|
+
|
|
20
|
+
# rows
|
|
21
|
+
with engine.begin() as con:
|
|
22
|
+
res = con.execute(stmt).cursor.fetchall()
|
|
23
|
+
assert res == [("xx", -1.0), ("yy", 2.5), ("zz", 6.0)]
|
|
24
|
+
|
|
25
|
+
# arrow table
|
|
26
|
+
with engine.begin() as con:
|
|
27
|
+
res = con.execute(stmt).cursor.fetch_arrow_table()
|
|
28
|
+
assert isinstance(res, ArrowTable)
|
|
29
|
+
assert res == ArrowTable.from_pydict(
|
|
30
|
+
{"label": ["xx", "yy", "zz"], "value": [-1.0, 2.5, 6.0]}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# arrow batches
|
|
34
|
+
with engine.begin() as con:
|
|
35
|
+
res = con.execute(stmt).cursor.fetch_record_batch()
|
|
36
|
+
assert isinstance(res, RecordBatchReader)
|
|
37
|
+
assert res.read_all() == ArrowTable.from_pydict(
|
|
38
|
+
{"label": ["xx", "yy", "zz"], "value": [-1.0, 2.5, 6.0]}
|
|
39
|
+
)
|
|
40
|
+
res = con.execute(stmt).cursor.fetch_record_batch(rows_per_batch=2)
|
|
41
|
+
assert res.read_next_batch() == RecordBatch.from_pydict(
|
|
42
|
+
{"label": ["xx", "yy"], "value": [-1.0, 2.5]}
|
|
43
|
+
)
|
|
44
|
+
assert res.read_next_batch() == RecordBatch.from_pydict(
|
|
45
|
+
{"label": ["zz"], "value": [6.0]}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_arrow_execution_option() -> None:
|
|
50
|
+
engine = create_engine("duckdb:///:memory:")
|
|
51
|
+
with engine.begin() as con:
|
|
52
|
+
con.execute(text("CREATE TABLE tbl (label VARCHAR, value DOUBLE)"))
|
|
53
|
+
con.execute(text("INSERT INTO tbl VALUES ('aa', 1.0), ('bb', 2.0)"))
|
|
54
|
+
|
|
55
|
+
with engine.connect().execution_options(duckdb_arrow=True) as con:
|
|
56
|
+
result = con.execute(text("SELECT * FROM tbl ORDER BY label"))
|
|
57
|
+
table = result.arrow
|
|
58
|
+
assert isinstance(table, ArrowTable)
|
|
59
|
+
assert table == ArrowTable.from_pydict(
|
|
60
|
+
{"label": ["aa", "bb"], "value": [1.0, 2.0]}
|
|
61
|
+
)
|
duckdb_sqlalchemy/url.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from typing import Any, Mapping, Optional, Sequence
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.engine import URL as SAURL
|
|
4
|
+
|
|
5
|
+
__all__ = ["URL", "make_url"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def URL(
|
|
9
|
+
*,
|
|
10
|
+
database: str = ":memory:",
|
|
11
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
12
|
+
**kwargs: Any,
|
|
13
|
+
) -> SAURL:
|
|
14
|
+
"""
|
|
15
|
+
Build a SQLAlchemy URL for duckdb-sqlalchemy.
|
|
16
|
+
|
|
17
|
+
All keyword arguments are treated as DuckDB config options (URL query params).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def _stringify(value: Any) -> str:
|
|
21
|
+
if isinstance(value, bool):
|
|
22
|
+
return "true" if value else "false"
|
|
23
|
+
return str(value)
|
|
24
|
+
|
|
25
|
+
def _coerce(value: Any) -> Any:
|
|
26
|
+
if value is None:
|
|
27
|
+
return None
|
|
28
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
29
|
+
return tuple(_stringify(v) for v in value)
|
|
30
|
+
return _stringify(value)
|
|
31
|
+
|
|
32
|
+
query_dict: dict[str, str | tuple[str, ...]] = {}
|
|
33
|
+
if query:
|
|
34
|
+
query_dict.update(
|
|
35
|
+
{
|
|
36
|
+
k: v
|
|
37
|
+
for k, v in ((k, _coerce(v)) for k, v in query.items())
|
|
38
|
+
if v is not None
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
if kwargs:
|
|
42
|
+
query_dict.update(
|
|
43
|
+
{
|
|
44
|
+
k: v
|
|
45
|
+
for k, v in ((k, _coerce(v)) for k, v in kwargs.items())
|
|
46
|
+
if v is not None
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return SAURL.create("duckdb", database=database, query=query_dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def make_url(
|
|
54
|
+
*,
|
|
55
|
+
database: str = ":memory:",
|
|
56
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
57
|
+
**kwargs: Any,
|
|
58
|
+
) -> SAURL:
|
|
59
|
+
return URL(database=database, query=query, **kwargs)
|