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,23 @@
1
+ from functools import lru_cache
2
+
3
+ import duckdb
4
+
5
+ from .capabilities import get_capabilities
6
+
7
+ _capabilities = get_capabilities(duckdb.__version__)
8
+ duckdb_version = _capabilities.version
9
+ has_uhugeint_support = _capabilities.supports_uhugeint
10
+
11
+
12
+ @lru_cache()
13
+ def has_comment_support() -> bool:
14
+ """
15
+ See https://github.com/duckdb/duckdb/pull/10372
16
+ """
17
+ try:
18
+ with duckdb.connect(":memory:") as con:
19
+ con.execute("CREATE TABLE t (i INTEGER);")
20
+ con.execute("COMMENT ON TABLE t IS 'test';")
21
+ except duckdb.ParserException:
22
+ return False
23
+ return True
@@ -0,0 +1,192 @@
1
+ import csv
2
+ import tempfile
3
+ from pathlib import Path
4
+ from typing import Any, Iterable, Mapping, Optional, Sequence, Tuple, Union
5
+
6
+ TableLike = Union[str, Any]
7
+
8
+
9
+ def _quote_literal(value: Any) -> str:
10
+ if isinstance(value, Path):
11
+ value = str(value)
12
+ if isinstance(value, bool):
13
+ return "TRUE" if value else "FALSE"
14
+ if isinstance(value, (int, float)):
15
+ return str(value)
16
+ if value is None:
17
+ return "NULL"
18
+ return "'" + str(value).replace("'", "''") + "'"
19
+
20
+
21
+ def _format_copy_options(options: Mapping[str, Any]) -> str:
22
+ if not options:
23
+ return ""
24
+ parts = []
25
+ for key, value in options.items():
26
+ if value is None:
27
+ continue
28
+ opt_key = str(key).upper()
29
+ if isinstance(value, (list, tuple)):
30
+ inner = ", ".join(_quote_literal(v) for v in value)
31
+ parts.append(f"{opt_key} ({inner})")
32
+ else:
33
+ parts.append(f"{opt_key} {_quote_literal(value)}")
34
+ if not parts:
35
+ return ""
36
+ return " (" + ", ".join(parts) + ")"
37
+
38
+
39
+ def _format_table(connection: Any, table: TableLike) -> str:
40
+ if hasattr(table, "name"):
41
+ preparer = getattr(
42
+ getattr(connection, "dialect", None), "identifier_preparer", None
43
+ )
44
+ if preparer is not None:
45
+ return preparer.format_table(table)
46
+ schema = getattr(table, "schema", None)
47
+ name = getattr(table, "name", None)
48
+ if schema:
49
+ return f"{schema}.{name}"
50
+ return str(name)
51
+ return str(table)
52
+
53
+
54
+ def _format_columns(connection: Any, columns: Optional[Sequence[str]]) -> str:
55
+ if not columns:
56
+ return ""
57
+ preparer = getattr(
58
+ getattr(connection, "dialect", None), "identifier_preparer", None
59
+ )
60
+ if preparer is None:
61
+ cols = ", ".join(columns)
62
+ else:
63
+ cols = ", ".join(preparer.quote_identifier(col) for col in columns)
64
+ return f" ({cols})"
65
+
66
+
67
+ def _execute_sql(connection: Any, statement: str) -> Any:
68
+ if hasattr(connection, "exec_driver_sql"):
69
+ return connection.exec_driver_sql(statement)
70
+ return connection.execute(statement)
71
+
72
+
73
+ def copy_from_parquet(
74
+ connection: Any,
75
+ table: TableLike,
76
+ path: Union[str, Path],
77
+ *,
78
+ columns: Optional[Sequence[str]] = None,
79
+ **options: Any,
80
+ ) -> Any:
81
+ return _copy_from_file(
82
+ connection,
83
+ table,
84
+ path,
85
+ format_name="parquet",
86
+ columns=columns,
87
+ **options,
88
+ )
89
+
90
+
91
+ def copy_from_csv(
92
+ connection: Any,
93
+ table: TableLike,
94
+ path: Union[str, Path],
95
+ *,
96
+ columns: Optional[Sequence[str]] = None,
97
+ **options: Any,
98
+ ) -> Any:
99
+ return _copy_from_file(
100
+ connection,
101
+ table,
102
+ path,
103
+ format_name="csv",
104
+ columns=columns,
105
+ **options,
106
+ )
107
+
108
+
109
+ def _copy_from_file(
110
+ connection: Any,
111
+ table: TableLike,
112
+ path: Union[str, Path],
113
+ *,
114
+ format_name: str,
115
+ columns: Optional[Sequence[str]] = None,
116
+ **options: Any,
117
+ ) -> Any:
118
+ table_name = _format_table(connection, table)
119
+ column_clause = _format_columns(connection, columns)
120
+ path_literal = _quote_literal(path)
121
+ copy_options = {"format": format_name, **options}
122
+ options_clause = _format_copy_options(copy_options)
123
+ statement = f"COPY {table_name}{column_clause} FROM {path_literal}{options_clause}"
124
+ return _execute_sql(connection, statement)
125
+
126
+
127
+ def copy_from_rows(
128
+ connection: Any,
129
+ table: TableLike,
130
+ rows: Iterable[Union[Mapping[str, Any], Sequence[Any]]],
131
+ *,
132
+ columns: Optional[Sequence[str]] = None,
133
+ chunk_size: int = 100000,
134
+ include_header: bool = False,
135
+ **copy_options: Any,
136
+ ) -> Any:
137
+ iterator = iter(rows)
138
+ first = next(iterator, None)
139
+ if first is None:
140
+ return None
141
+
142
+ if isinstance(first, Mapping):
143
+ if columns is None:
144
+ columns = list(first.keys())
145
+
146
+ def row_to_seq(row: Mapping[str, Any]) -> Sequence[Any]:
147
+ return [row.get(col) for col in columns or []]
148
+ else:
149
+
150
+ def row_to_seq(row: Sequence[Any]) -> Sequence[Any]:
151
+ return row
152
+
153
+ def open_writer() -> Tuple[Any, Any, int]:
154
+ tmp = tempfile.NamedTemporaryFile("w", newline="", suffix=".csv", delete=False)
155
+ writer = csv.writer(tmp)
156
+ if include_header and columns:
157
+ writer.writerow(columns)
158
+ return tmp, writer, 0
159
+
160
+ def flush_chunk(tmp: Any) -> None:
161
+ tmp.flush()
162
+ path = tmp.name
163
+ tmp.close()
164
+ try:
165
+ copy_from_csv(
166
+ connection,
167
+ table,
168
+ path,
169
+ columns=columns if columns else None,
170
+ **copy_options,
171
+ )
172
+ finally:
173
+ try:
174
+ Path(path).unlink()
175
+ except FileNotFoundError:
176
+ pass
177
+
178
+ copy_options = {"header": include_header, **copy_options}
179
+
180
+ tmp, writer, count = open_writer()
181
+ writer.writerow(row_to_seq(first))
182
+ count += 1
183
+
184
+ for row in iterator:
185
+ writer.writerow(row_to_seq(row))
186
+ count += 1
187
+ if chunk_size and count >= chunk_size:
188
+ flush_chunk(tmp)
189
+ tmp, writer, count = open_writer()
190
+
191
+ if count:
192
+ flush_chunk(tmp)
@@ -0,0 +1,24 @@
1
+ from dataclasses import dataclass
2
+ from typing import Union
3
+
4
+ from packaging.version import Version
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class DuckDBCapabilities:
9
+ version: Version
10
+ supports_attach: bool
11
+ supports_user_agent: bool
12
+ supports_uhugeint: bool
13
+ supports_varint: bool
14
+
15
+
16
+ def get_capabilities(version: Union[str, Version]) -> DuckDBCapabilities:
17
+ resolved = version if isinstance(version, Version) else Version(version)
18
+ return DuckDBCapabilities(
19
+ version=resolved,
20
+ supports_attach=resolved >= Version("0.7.0"),
21
+ supports_user_agent=resolved >= Version("0.9.2"),
22
+ supports_uhugeint=resolved >= Version("0.10.0"),
23
+ supports_varint=resolved > Version("1.0.0"),
24
+ )
@@ -0,0 +1,70 @@
1
+ import os
2
+ from decimal import Decimal
3
+ from functools import lru_cache
4
+ from typing import Dict, Set, Type, Union
5
+
6
+ import duckdb
7
+ from sqlalchemy import Boolean, Float, Integer, String
8
+ from sqlalchemy.engine import Dialect
9
+ from sqlalchemy.sql.type_api import TypeEngine
10
+
11
+ TYPES: Dict[Type, TypeEngine] = {
12
+ bool: Boolean(),
13
+ int: Integer(),
14
+ float: Float(),
15
+ str: String(),
16
+ }
17
+
18
+
19
+ @lru_cache()
20
+ def get_core_config() -> Set[str]:
21
+ # List of connection string parameters that are supported by MotherDuck
22
+ # See: https://motherduck.com/docs/key-tasks/authenticating-and-connecting-to-motherduck/authenticating-to-motherduck/
23
+ motherduck_config_keys = {
24
+ "motherduck_token",
25
+ "attach_mode",
26
+ "saas_mode",
27
+ "session_hint",
28
+ "access_mode",
29
+ "dbinstance_inactivity_ttl",
30
+ "motherduck_dbinstance_inactivity_ttl",
31
+ }
32
+
33
+ with duckdb.connect(":memory:") as conn:
34
+ rows = conn.execute("SELECT name FROM duckdb_settings()").fetchall()
35
+ return {name for (name,) in rows} | motherduck_config_keys
36
+
37
+
38
+ def apply_config(
39
+ dialect: Dialect,
40
+ conn: duckdb.DuckDBPyConnection,
41
+ ext: Dict[str, Union[str, int, bool, float, None]],
42
+ ) -> None:
43
+ # TODO: does sqlalchemy have something that could do this for us?
44
+ processors = [
45
+ (typ, type_engine.literal_processor(dialect=dialect))
46
+ for typ, type_engine in TYPES.items()
47
+ ]
48
+ string_processor = String().literal_processor(dialect=dialect)
49
+
50
+ for k, v in ext.items():
51
+ if v is None:
52
+ conn.execute(f"SET {k} = NULL")
53
+ continue
54
+ if isinstance(v, os.PathLike):
55
+ v = os.fspath(v)
56
+ process = string_processor
57
+ elif isinstance(v, Decimal):
58
+ v = str(v)
59
+ process = string_processor
60
+ else:
61
+ process = None
62
+ for typ, processor in processors:
63
+ if isinstance(v, typ):
64
+ process = processor
65
+ break
66
+ if process is None:
67
+ v = str(v)
68
+ process = string_processor
69
+ assert process, f"Not able to configure {k} with {v}"
70
+ conn.execute(f"SET {k} = {process(v)}")
File without changes
@@ -0,0 +1,332 @@
1
+ """
2
+ See https://duckdb.org/docs/sql/data_types/numeric for more information
3
+
4
+ Also
5
+ ```sql
6
+ select * from duckdb_types where type_category = 'NUMERIC';
7
+ ```
8
+ """
9
+
10
+ import typing
11
+ from typing import Any, Dict, Optional, Tuple, Type
12
+
13
+ import duckdb
14
+ from packaging.version import Version
15
+ from sqlalchemy import exc, util
16
+ from sqlalchemy.dialects.postgresql.base import PGIdentifierPreparer, PGTypeCompiler
17
+ from sqlalchemy.engine import Dialect
18
+ from sqlalchemy.ext.compiler import compiles
19
+ from sqlalchemy.sql import sqltypes, type_api
20
+ from sqlalchemy.sql.type_api import TypeEngine
21
+ from sqlalchemy.types import BigInteger, Integer, SmallInteger, String
22
+
23
+ # INTEGER INT4, INT, SIGNED -2147483648 2147483647
24
+ # SMALLINT INT2, SHORT -32768 32767
25
+ # BIGINT INT8, LONG -9223372036854775808 9223372036854775807
26
+ (BigInteger, SmallInteger) # pure reexport
27
+
28
+ duckdb_version = duckdb.__version__
29
+
30
+ IS_GT_1 = Version(duckdb_version) > Version("1.0.0")
31
+
32
+
33
+ class DuckDBInteger(Integer):
34
+ cache_ok = True
35
+
36
+
37
+ class UInt64(DuckDBInteger):
38
+ pass
39
+
40
+
41
+ class UInt32(DuckDBInteger):
42
+ pass
43
+
44
+
45
+ class UInt16(DuckDBInteger):
46
+ "AKA USMALLINT"
47
+
48
+
49
+ class UInt8(DuckDBInteger):
50
+ pass
51
+
52
+
53
+ class UTinyInteger(DuckDBInteger):
54
+ "AKA UInt1"
55
+
56
+ name = "UTinyInt"
57
+ # UTINYINT - 0 255
58
+
59
+
60
+ class TinyInteger(DuckDBInteger):
61
+ "AKA Int1"
62
+
63
+ name = "TinyInt"
64
+ # TINYINT INT1 -128 127
65
+
66
+
67
+ class USmallInteger(DuckDBInteger):
68
+ name = "usmallint"
69
+ "AKA UInt2"
70
+ # USMALLINT - 0 65535
71
+ min = 0
72
+ max = 2**15
73
+
74
+
75
+ class UBigInteger(DuckDBInteger):
76
+ name = "UBigInt"
77
+ min = 0
78
+ max = 18446744073709551615
79
+
80
+
81
+ class HugeInteger(DuckDBInteger):
82
+ name = "HugeInt"
83
+ # HUGEINT -170141183460469231731687303715884105727* 170141183460469231731687303715884105727
84
+
85
+
86
+ class UHugeInteger(DuckDBInteger):
87
+ name = "UHugeInt"
88
+
89
+
90
+ class UInteger(DuckDBInteger):
91
+ # UINTEGER - 0 4294967295
92
+ pass
93
+
94
+
95
+ if IS_GT_1:
96
+
97
+ class VarInt(DuckDBInteger):
98
+ pass
99
+
100
+
101
+ def compile_uint(element: Integer, compiler: PGTypeCompiler, **kw: Any) -> str:
102
+ return getattr(element, "name", type(element).__name__)
103
+
104
+
105
+ types = [
106
+ subclass
107
+ for subclass in DuckDBInteger.__subclasses__()
108
+ if subclass.__module__ == UInt64.__module__
109
+ ]
110
+ assert types
111
+
112
+
113
+ TV = typing.Union[Type[TypeEngine], TypeEngine]
114
+
115
+
116
+ def _normalize_fields(
117
+ fields: Optional[Dict[str, TV]],
118
+ ) -> Optional[Tuple[Tuple[str, TypeEngine], ...]]:
119
+ if fields is None:
120
+ return None
121
+ if isinstance(fields, dict):
122
+ items = fields.items()
123
+ else:
124
+ items = fields
125
+ return tuple(
126
+ (str(key), type_api.to_instance(value))
127
+ for key, value in items # type: ignore[arg-type]
128
+ )
129
+
130
+
131
+ def _normalize_fields_cache_key(
132
+ fields: Optional[Tuple[Tuple[str, TypeEngine], ...]],
133
+ ) -> Optional[Tuple[Tuple[str, Any], ...]]:
134
+ if fields is None:
135
+ return None
136
+ return tuple(
137
+ (key, value._static_cache_key if isinstance(value, TypeEngine) else value)
138
+ for key, value in fields
139
+ )
140
+
141
+
142
+ class Struct(TypeEngine):
143
+ """
144
+ Represents a STRUCT type in DuckDB
145
+
146
+ ```python
147
+ from duckdb_sqlalchemy.datatypes import Struct
148
+ from sqlalchemy import Table, Column, String
149
+
150
+ Table(
151
+ 'hello',
152
+ Column('name', Struct({'first': String, 'last': String})
153
+ )
154
+ ```
155
+
156
+ :param fields: only optional due to limitations with how much type information DuckDB returns to us in the description field
157
+ """
158
+
159
+ __visit_name__ = "struct"
160
+ cache_ok = True
161
+
162
+ def __init__(self, fields: Optional[Dict[str, TV]] = None):
163
+ self.fields = fields
164
+ self._fields = _normalize_fields(fields)
165
+ self._fields_cache_key = _normalize_fields_cache_key(self._fields)
166
+
167
+ @util.memoized_property
168
+ def _static_cache_key(self): # type: ignore[override]
169
+ if self._fields_cache_key is None:
170
+ return (self.__class__,)
171
+ return (self.__class__, ("fields", self._fields_cache_key))
172
+
173
+
174
+ class Map(TypeEngine):
175
+ """
176
+ Represents a MAP type in DuckDB
177
+
178
+ ```python
179
+ from duckdb_sqlalchemy.datatypes import Map
180
+ from sqlalchemy import Table, Column, String
181
+
182
+ Table(
183
+ 'hello',
184
+ Column('name', Map(String, String)
185
+ )
186
+ ```
187
+ """
188
+
189
+ __visit_name__ = "map"
190
+ cache_ok = True
191
+ key_type: TV
192
+ value_type: TV
193
+
194
+ def __init__(self, key_type: TV, value_type: TV):
195
+ self.key_type = type_api.to_instance(key_type)
196
+ self.value_type = type_api.to_instance(value_type)
197
+
198
+ def bind_processor(self, dialect: Dialect) -> Any:
199
+ return lambda value: (
200
+ {"key": list(value), "value": list(value.values())} if value else None
201
+ )
202
+
203
+ def result_processor(self, dialect: Dialect, coltype: object) -> Any:
204
+ if IS_GT_1:
205
+ return lambda value: value
206
+ else:
207
+ return (
208
+ lambda value: dict(zip(value["key"], value["value"])) if value else {}
209
+ )
210
+
211
+
212
+ class Union(TypeEngine):
213
+ """
214
+ Represents a UNION type in DuckDB
215
+
216
+ ```python
217
+ from duckdb_sqlalchemy.datatypes import Union
218
+ from sqlalchemy import Table, Column, String
219
+
220
+ Table(
221
+ 'hello',
222
+ Column('name', Union({"name": String, "age": String})
223
+ )
224
+ ```
225
+ """
226
+
227
+ __visit_name__ = "union"
228
+ cache_ok = True
229
+ fields: Dict[str, TV]
230
+
231
+ def __init__(self, fields: Dict[str, TV]):
232
+ self.fields = fields
233
+ self._fields = _normalize_fields(fields)
234
+ self._fields_cache_key = _normalize_fields_cache_key(self._fields)
235
+
236
+ @util.memoized_property
237
+ def _static_cache_key(self): # type: ignore[override]
238
+ if self._fields_cache_key is None:
239
+ return (self.__class__,)
240
+ return (self.__class__, ("fields", self._fields_cache_key))
241
+
242
+
243
+ ISCHEMA_NAMES = {
244
+ "bignum": sqltypes.Numeric,
245
+ "hugeint": HugeInteger,
246
+ "uhugeint": UHugeInteger,
247
+ "tinyint": TinyInteger,
248
+ "utinyint": UTinyInteger,
249
+ "int8": BigInteger,
250
+ "int4": Integer,
251
+ "int2": SmallInteger,
252
+ "timetz": sqltypes.TIME,
253
+ "timestamptz": sqltypes.TIMESTAMP,
254
+ "float4": sqltypes.FLOAT,
255
+ "float8": sqltypes.FLOAT,
256
+ "usmallint": USmallInteger,
257
+ "uinteger": UInteger,
258
+ "ubigint": UBigInteger,
259
+ "timestamp_s": sqltypes.TIMESTAMP,
260
+ "timestamp_ms": sqltypes.TIMESTAMP,
261
+ "timestamp_ns": sqltypes.TIMESTAMP,
262
+ "enum": sqltypes.Enum,
263
+ "bool": sqltypes.BOOLEAN,
264
+ "varchar": String,
265
+ }
266
+ if IS_GT_1:
267
+ ISCHEMA_NAMES["varint"] = VarInt
268
+
269
+
270
+ def register_extension_types() -> None:
271
+ for subclass in types:
272
+ compiles(subclass, "duckdb")(compile_uint)
273
+
274
+
275
+ @compiles(Struct, "duckdb") # type: ignore[misc]
276
+ def visit_struct(
277
+ instance: Struct,
278
+ compiler: PGTypeCompiler,
279
+ identifier_preparer: PGIdentifierPreparer,
280
+ **kw: Any,
281
+ ) -> str:
282
+ return "STRUCT" + struct_or_union(instance, compiler, identifier_preparer, **kw)
283
+
284
+
285
+ @compiles(Union, "duckdb") # type: ignore[misc]
286
+ def visit_union(
287
+ instance: Union,
288
+ compiler: PGTypeCompiler,
289
+ identifier_preparer: PGIdentifierPreparer,
290
+ **kw: Any,
291
+ ) -> str:
292
+ return "UNION" + struct_or_union(instance, compiler, identifier_preparer, **kw)
293
+
294
+
295
+ def struct_or_union(
296
+ instance: typing.Union[Union, Struct],
297
+ compiler: PGTypeCompiler,
298
+ identifier_preparer: PGIdentifierPreparer,
299
+ **kw: Any,
300
+ ) -> str:
301
+ fields = getattr(instance, "_fields", None)
302
+ if fields is None:
303
+ fields = instance.fields
304
+ if fields is None:
305
+ raise exc.CompileError(f"DuckDB {repr(instance)} type requires fields")
306
+ return "({})".format(
307
+ ", ".join(
308
+ "{} {}".format(
309
+ identifier_preparer.quote_identifier(key),
310
+ process_type(
311
+ value, compiler, identifier_preparer=identifier_preparer, **kw
312
+ ),
313
+ )
314
+ for key, value in (fields.items() if isinstance(fields, dict) else fields)
315
+ )
316
+ )
317
+
318
+
319
+ def process_type(
320
+ value: typing.Union[TypeEngine, Type[TypeEngine]],
321
+ compiler: PGTypeCompiler,
322
+ **kw: Any,
323
+ ) -> str:
324
+ return compiler.process(type_api.to_instance(value), **kw)
325
+
326
+
327
+ @compiles(Map, "duckdb") # type: ignore[misc]
328
+ def visit_map(instance: Map, compiler: PGTypeCompiler, **kw: Any) -> str:
329
+ return "MAP({}, {})".format(
330
+ process_type(instance.key_type, compiler, **kw),
331
+ process_type(instance.value_type, compiler, **kw),
332
+ )