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,1411 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
import warnings
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from typing import (
|
|
8
|
+
TYPE_CHECKING,
|
|
9
|
+
Any,
|
|
10
|
+
Collection,
|
|
11
|
+
Dict,
|
|
12
|
+
Iterable,
|
|
13
|
+
List,
|
|
14
|
+
Optional,
|
|
15
|
+
Sequence,
|
|
16
|
+
Tuple,
|
|
17
|
+
Type,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
import duckdb
|
|
21
|
+
import sqlalchemy
|
|
22
|
+
from packaging.version import Version
|
|
23
|
+
from sqlalchemy import pool, select, sql, text, util
|
|
24
|
+
from sqlalchemy import types as sqltypes
|
|
25
|
+
from sqlalchemy.dialects.postgresql import UUID, insert
|
|
26
|
+
from sqlalchemy.dialects.postgresql.base import (
|
|
27
|
+
PGDialect,
|
|
28
|
+
PGIdentifierPreparer,
|
|
29
|
+
PGInspector,
|
|
30
|
+
)
|
|
31
|
+
from sqlalchemy.dialects.postgresql.psycopg2 import PGDialect_psycopg2
|
|
32
|
+
from sqlalchemy.engine.default import DefaultDialect, DefaultExecutionContext
|
|
33
|
+
from sqlalchemy.engine.reflection import cache
|
|
34
|
+
from sqlalchemy.engine.url import URL as SAURL
|
|
35
|
+
from sqlalchemy.exc import NoSuchTableError
|
|
36
|
+
from sqlalchemy.ext.compiler import compiles
|
|
37
|
+
from sqlalchemy.sql import bindparam
|
|
38
|
+
from sqlalchemy.sql.selectable import Select
|
|
39
|
+
|
|
40
|
+
from ._supports import has_comment_support
|
|
41
|
+
from .bulk import copy_from_csv, copy_from_parquet, copy_from_rows
|
|
42
|
+
from .capabilities import get_capabilities
|
|
43
|
+
from .config import apply_config, get_core_config
|
|
44
|
+
from .datatypes import ISCHEMA_NAMES, register_extension_types
|
|
45
|
+
from .motherduck import (
|
|
46
|
+
DIALECT_QUERY_KEYS,
|
|
47
|
+
MotherDuckURL,
|
|
48
|
+
append_query_to_database,
|
|
49
|
+
create_engine_from_paths,
|
|
50
|
+
create_motherduck_engine,
|
|
51
|
+
extract_path_query_from_config,
|
|
52
|
+
split_url_query,
|
|
53
|
+
stable_session_hint,
|
|
54
|
+
)
|
|
55
|
+
from .olap import read_csv, read_csv_auto, read_parquet, table_function
|
|
56
|
+
from .url import URL, make_url
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
from sqlalchemy.dialects.postgresql.base import PGExecutionContext
|
|
60
|
+
except ImportError: # pragma: no cover - fallback for older SQLAlchemy
|
|
61
|
+
PGExecutionContext = DefaultExecutionContext
|
|
62
|
+
|
|
63
|
+
__version__ = "0.19.0"
|
|
64
|
+
sqlalchemy_version = sqlalchemy.__version__
|
|
65
|
+
SQLALCHEMY_VERSION = Version(sqlalchemy_version)
|
|
66
|
+
SQLALCHEMY_2 = SQLALCHEMY_VERSION >= Version("2.0.0")
|
|
67
|
+
duckdb_version: str = duckdb.__version__
|
|
68
|
+
_capabilities = get_capabilities(duckdb_version)
|
|
69
|
+
supports_attach: bool = _capabilities.supports_attach
|
|
70
|
+
supports_user_agent: bool = _capabilities.supports_user_agent
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
from sqlalchemy.engine import Connection
|
|
74
|
+
from sqlalchemy.engine.reflection import ReflectedCheckConstraint, ReflectedIndex
|
|
75
|
+
|
|
76
|
+
from .capabilities import DuckDBCapabilities
|
|
77
|
+
|
|
78
|
+
register_extension_types()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"Dialect",
|
|
83
|
+
"ConnectionWrapper",
|
|
84
|
+
"CursorWrapper",
|
|
85
|
+
"DBAPI",
|
|
86
|
+
"DuckDBEngineWarning",
|
|
87
|
+
"insert", # reexport of sqlalchemy.dialects.postgresql.insert
|
|
88
|
+
"MotherDuckURL",
|
|
89
|
+
"URL",
|
|
90
|
+
"create_engine_from_paths",
|
|
91
|
+
"create_motherduck_engine",
|
|
92
|
+
"make_url",
|
|
93
|
+
"stable_session_hint",
|
|
94
|
+
"table_function",
|
|
95
|
+
"read_parquet",
|
|
96
|
+
"read_csv",
|
|
97
|
+
"read_csv_auto",
|
|
98
|
+
"copy_from_parquet",
|
|
99
|
+
"copy_from_csv",
|
|
100
|
+
"copy_from_rows",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DBAPI:
|
|
105
|
+
paramstyle = "numeric_dollar" if SQLALCHEMY_2 else "qmark"
|
|
106
|
+
apilevel = duckdb.apilevel
|
|
107
|
+
threadsafety = duckdb.threadsafety
|
|
108
|
+
|
|
109
|
+
# this is being fixed upstream to add a proper exception hierarchy
|
|
110
|
+
Error = getattr(duckdb, "Error", RuntimeError)
|
|
111
|
+
TransactionException = getattr(duckdb, "TransactionException", Error)
|
|
112
|
+
ParserException = getattr(duckdb, "ParserException", Error)
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def Binary(x: Any) -> Any:
|
|
116
|
+
return x
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class DuckDBInspector(PGInspector):
|
|
120
|
+
def get_check_constraints(
|
|
121
|
+
self, table_name: str, schema: Optional[str] = None, **kw: Any
|
|
122
|
+
) -> List["ReflectedCheckConstraint"]:
|
|
123
|
+
try:
|
|
124
|
+
return super().get_check_constraints(table_name, schema, **kw)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
raise NotImplementedError() from e
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ConnectionWrapper:
|
|
130
|
+
__c: duckdb.DuckDBPyConnection
|
|
131
|
+
notices: List[str]
|
|
132
|
+
autocommit = None # duckdb doesn't support setting autocommit
|
|
133
|
+
closed = False
|
|
134
|
+
|
|
135
|
+
def __init__(self, c: duckdb.DuckDBPyConnection) -> None:
|
|
136
|
+
self.__c = c
|
|
137
|
+
self.notices = list()
|
|
138
|
+
|
|
139
|
+
def cursor(self) -> "CursorWrapper":
|
|
140
|
+
return CursorWrapper(self.__c, self)
|
|
141
|
+
|
|
142
|
+
def __getattr__(self, name: str) -> Any:
|
|
143
|
+
return getattr(self.__c, name)
|
|
144
|
+
|
|
145
|
+
def close(self) -> None:
|
|
146
|
+
self.__c.close()
|
|
147
|
+
self.closed = True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_REGISTER_NAME_KEYS = ("name", "view_name", "table")
|
|
151
|
+
_REGISTER_DATA_KEYS = ("df", "dataframe", "relation", "data")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_register_params(parameters: Optional[Any]) -> Tuple[str, Any]:
|
|
155
|
+
if parameters is None:
|
|
156
|
+
raise ValueError("register requires a view name and data")
|
|
157
|
+
if isinstance(parameters, dict):
|
|
158
|
+
view_name = None
|
|
159
|
+
for key in _REGISTER_NAME_KEYS:
|
|
160
|
+
if key in parameters:
|
|
161
|
+
view_name = parameters[key]
|
|
162
|
+
break
|
|
163
|
+
df = None
|
|
164
|
+
for key in _REGISTER_DATA_KEYS:
|
|
165
|
+
if key in parameters:
|
|
166
|
+
df = parameters[key]
|
|
167
|
+
break
|
|
168
|
+
if view_name is None or df is None:
|
|
169
|
+
raise ValueError("register requires a view name and data (tuple or dict)")
|
|
170
|
+
return view_name, df
|
|
171
|
+
if isinstance(parameters, (list, tuple)) and len(parameters) == 2:
|
|
172
|
+
return parameters[0], parameters[1]
|
|
173
|
+
raise ValueError("register requires a view name and data (tuple or dict)")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class CursorWrapper:
|
|
177
|
+
__c: duckdb.DuckDBPyConnection
|
|
178
|
+
__connection_wrapper: "ConnectionWrapper"
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self, c: duckdb.DuckDBPyConnection, connection_wrapper: "ConnectionWrapper"
|
|
182
|
+
) -> None:
|
|
183
|
+
self.__c = c
|
|
184
|
+
self.__connection_wrapper = connection_wrapper
|
|
185
|
+
|
|
186
|
+
def executemany(
|
|
187
|
+
self,
|
|
188
|
+
statement: str,
|
|
189
|
+
parameters: Optional[List[Dict]] = None,
|
|
190
|
+
context: Optional[Any] = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
if not parameters:
|
|
193
|
+
params = []
|
|
194
|
+
elif isinstance(parameters, list):
|
|
195
|
+
params = parameters
|
|
196
|
+
else:
|
|
197
|
+
params = list(parameters)
|
|
198
|
+
self.__c.executemany(statement, params)
|
|
199
|
+
|
|
200
|
+
def execute(
|
|
201
|
+
self,
|
|
202
|
+
statement: str,
|
|
203
|
+
parameters: Optional[Tuple] = None,
|
|
204
|
+
context: Optional[Any] = None,
|
|
205
|
+
) -> None:
|
|
206
|
+
try:
|
|
207
|
+
norm = statement.strip().lower().rstrip(";")
|
|
208
|
+
if norm == "commit": # this is largely for ipython-sql
|
|
209
|
+
self.__c.commit()
|
|
210
|
+
elif norm.startswith("register"):
|
|
211
|
+
view_name, df = _parse_register_params(parameters)
|
|
212
|
+
self.__c.register(view_name, df)
|
|
213
|
+
elif norm == "show transaction isolation level":
|
|
214
|
+
self.__c.execute("select 'read committed' as transaction_isolation")
|
|
215
|
+
elif parameters is None:
|
|
216
|
+
self.__c.execute(statement)
|
|
217
|
+
else:
|
|
218
|
+
self.__c.execute(statement, parameters)
|
|
219
|
+
except RuntimeError as e:
|
|
220
|
+
if e.args[0].startswith("Not implemented Error"):
|
|
221
|
+
raise NotImplementedError(*e.args) from e
|
|
222
|
+
elif (
|
|
223
|
+
e.args[0]
|
|
224
|
+
== "TransactionContext Error: cannot commit - no transaction is active"
|
|
225
|
+
):
|
|
226
|
+
return
|
|
227
|
+
else:
|
|
228
|
+
raise e
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def connection(self) -> Any:
|
|
232
|
+
return self.__connection_wrapper
|
|
233
|
+
|
|
234
|
+
def close(self) -> None:
|
|
235
|
+
pass # closing cursors is not supported in duckdb
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def description(self) -> Any:
|
|
239
|
+
desc = self.__c.description
|
|
240
|
+
if desc is None:
|
|
241
|
+
return None
|
|
242
|
+
fixed = []
|
|
243
|
+
for col in desc:
|
|
244
|
+
if len(col) >= 2:
|
|
245
|
+
type_code = col[1]
|
|
246
|
+
try:
|
|
247
|
+
hash(type_code)
|
|
248
|
+
fixed.append(col)
|
|
249
|
+
except TypeError:
|
|
250
|
+
fixed.append((col[0], str(type_code), *col[2:]))
|
|
251
|
+
else:
|
|
252
|
+
fixed.append(col)
|
|
253
|
+
return fixed
|
|
254
|
+
|
|
255
|
+
def __getattr__(self, name: str) -> Any:
|
|
256
|
+
return getattr(self.__c, name)
|
|
257
|
+
|
|
258
|
+
def fetchmany(self, size: Optional[int] = None) -> List:
|
|
259
|
+
if size is None:
|
|
260
|
+
return self.__c.fetchmany()
|
|
261
|
+
else:
|
|
262
|
+
return self.__c.fetchmany(size)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class DuckDBEngineWarning(Warning):
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def index_warning() -> None:
|
|
270
|
+
warnings.warn(
|
|
271
|
+
"duckdb-sqlalchemy doesn't yet support reflection on indices",
|
|
272
|
+
DuckDBEngineWarning,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _normalize_execution_options(execution_options: Dict[str, Any]) -> Dict[str, Any]:
|
|
277
|
+
if (
|
|
278
|
+
"duckdb_insertmanyvalues_page_size" in execution_options
|
|
279
|
+
and "insertmanyvalues_page_size" not in execution_options
|
|
280
|
+
):
|
|
281
|
+
execution_options = dict(execution_options)
|
|
282
|
+
execution_options["insertmanyvalues_page_size"] = execution_options[
|
|
283
|
+
"duckdb_insertmanyvalues_page_size"
|
|
284
|
+
]
|
|
285
|
+
return execution_options
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class DuckDBArrowResult:
|
|
289
|
+
def __init__(self, result: Any) -> None:
|
|
290
|
+
self._result = result
|
|
291
|
+
self._arrow = None
|
|
292
|
+
|
|
293
|
+
def _fetch_arrow(self) -> Any:
|
|
294
|
+
if self._arrow is not None:
|
|
295
|
+
return self._arrow
|
|
296
|
+
cursor = getattr(self._result, "cursor", None)
|
|
297
|
+
if cursor is None:
|
|
298
|
+
cursor = getattr(self._result, "_cursor", None)
|
|
299
|
+
if cursor is None or not hasattr(cursor, "fetch_arrow_table"):
|
|
300
|
+
raise NotImplementedError("Arrow results are not available on this cursor")
|
|
301
|
+
self._arrow = cursor.fetch_arrow_table()
|
|
302
|
+
return self._arrow
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def arrow(self) -> Any:
|
|
306
|
+
return self._fetch_arrow()
|
|
307
|
+
|
|
308
|
+
def all(self) -> Any:
|
|
309
|
+
return self._fetch_arrow()
|
|
310
|
+
|
|
311
|
+
def fetchall(self) -> Any:
|
|
312
|
+
return self._fetch_arrow()
|
|
313
|
+
|
|
314
|
+
def __getattr__(self, name: str) -> Any:
|
|
315
|
+
return getattr(self._result, name)
|
|
316
|
+
|
|
317
|
+
def __iter__(self) -> Any:
|
|
318
|
+
return iter(self._result)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class DuckDBExecutionContext(PGExecutionContext):
|
|
322
|
+
@classmethod
|
|
323
|
+
def _init_compiled(
|
|
324
|
+
cls,
|
|
325
|
+
dialect: "Dialect",
|
|
326
|
+
connection: Any,
|
|
327
|
+
dbapi_connection: Any,
|
|
328
|
+
execution_options: Dict[str, Any],
|
|
329
|
+
compiled: Any,
|
|
330
|
+
parameters: Any,
|
|
331
|
+
invoked_statement: Any,
|
|
332
|
+
extracted_parameters: Any,
|
|
333
|
+
cache_hit: Any = None,
|
|
334
|
+
) -> Any:
|
|
335
|
+
execution_options = _normalize_execution_options(execution_options)
|
|
336
|
+
return super()._init_compiled(
|
|
337
|
+
dialect,
|
|
338
|
+
connection,
|
|
339
|
+
dbapi_connection,
|
|
340
|
+
execution_options,
|
|
341
|
+
compiled,
|
|
342
|
+
parameters,
|
|
343
|
+
invoked_statement,
|
|
344
|
+
extracted_parameters,
|
|
345
|
+
cache_hit,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
@classmethod
|
|
349
|
+
def _init_statement(
|
|
350
|
+
cls,
|
|
351
|
+
dialect: "Dialect",
|
|
352
|
+
connection: Any,
|
|
353
|
+
dbapi_connection: Any,
|
|
354
|
+
execution_options: Dict[str, Any],
|
|
355
|
+
statement: str,
|
|
356
|
+
parameters: Any,
|
|
357
|
+
) -> Any:
|
|
358
|
+
execution_options = _normalize_execution_options(execution_options)
|
|
359
|
+
return super()._init_statement(
|
|
360
|
+
dialect,
|
|
361
|
+
connection,
|
|
362
|
+
dbapi_connection,
|
|
363
|
+
execution_options,
|
|
364
|
+
statement,
|
|
365
|
+
parameters,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def _setup_result_proxy(self) -> Any:
|
|
369
|
+
arraysize = self.execution_options.get("duckdb_arraysize")
|
|
370
|
+
if arraysize is None:
|
|
371
|
+
arraysize = self.execution_options.get("arraysize")
|
|
372
|
+
if arraysize is not None and hasattr(self.cursor, "arraysize"):
|
|
373
|
+
self.cursor.arraysize = arraysize
|
|
374
|
+
result = super()._setup_result_proxy()
|
|
375
|
+
if self.execution_options.get("duckdb_arrow") and getattr(
|
|
376
|
+
result, "returns_rows", False
|
|
377
|
+
):
|
|
378
|
+
return DuckDBArrowResult(result)
|
|
379
|
+
return result
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _looks_like_motherduck(database: Optional[str], config: Dict[str, Any]) -> bool:
|
|
383
|
+
if database is not None and (
|
|
384
|
+
database.startswith("md:") or database.startswith("motherduck:")
|
|
385
|
+
):
|
|
386
|
+
return True
|
|
387
|
+
motherduck_keys = {
|
|
388
|
+
"motherduck_token",
|
|
389
|
+
"attach_mode",
|
|
390
|
+
"saas_mode",
|
|
391
|
+
"session_hint",
|
|
392
|
+
"access_mode",
|
|
393
|
+
"dbinstance_inactivity_ttl",
|
|
394
|
+
"motherduck_dbinstance_inactivity_ttl",
|
|
395
|
+
}
|
|
396
|
+
return any(k in config for k in motherduck_keys)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
DISCONNECT_ERROR_PATTERNS = (
|
|
400
|
+
"connection closed",
|
|
401
|
+
"connection reset",
|
|
402
|
+
"connection refused",
|
|
403
|
+
"broken pipe",
|
|
404
|
+
"socket",
|
|
405
|
+
"network is unreachable",
|
|
406
|
+
"timed out",
|
|
407
|
+
"timeout",
|
|
408
|
+
"could not connect",
|
|
409
|
+
"failed to connect",
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
TRANSIENT_ERROR_PATTERNS = (
|
|
413
|
+
"temporarily unavailable",
|
|
414
|
+
"service unavailable",
|
|
415
|
+
"http error: 429",
|
|
416
|
+
"http error: 503",
|
|
417
|
+
"http error: 504",
|
|
418
|
+
"rate limit",
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _is_idempotent_statement(statement: str) -> bool:
|
|
423
|
+
normalized = statement.strip().lower()
|
|
424
|
+
return normalized.startswith(("select", "show", "describe", "pragma", "explain"))
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _is_transient_error(error: BaseException) -> bool:
|
|
428
|
+
message = str(error).lower()
|
|
429
|
+
if any(pattern in message for pattern in DISCONNECT_ERROR_PATTERNS):
|
|
430
|
+
return False
|
|
431
|
+
return any(pattern in message for pattern in TRANSIENT_ERROR_PATTERNS)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _pool_override_from_url(url: SAURL) -> Optional[str]:
|
|
435
|
+
value = None
|
|
436
|
+
if "duckdb_sqlalchemy_pool" in url.query:
|
|
437
|
+
value = url.query.get("duckdb_sqlalchemy_pool")
|
|
438
|
+
elif "pool" in url.query:
|
|
439
|
+
value = url.query.get("pool")
|
|
440
|
+
if value is None:
|
|
441
|
+
value = os.getenv("DUCKDB_SQLALCHEMY_POOL")
|
|
442
|
+
if isinstance(value, (list, tuple)):
|
|
443
|
+
value = value[0] if value else None
|
|
444
|
+
if value is None:
|
|
445
|
+
return None
|
|
446
|
+
return str(value).lower()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _apply_motherduck_defaults(config: Dict[str, Any], database: Optional[str]) -> None:
|
|
450
|
+
if "motherduck_token" not in config:
|
|
451
|
+
token = os.getenv("motherduck_token") or os.getenv("MOTHERDUCK_TOKEN")
|
|
452
|
+
if token and _looks_like_motherduck(database, config):
|
|
453
|
+
config["motherduck_token"] = token
|
|
454
|
+
|
|
455
|
+
if "motherduck_token" in config and not isinstance(config["motherduck_token"], str):
|
|
456
|
+
raise TypeError("motherduck_token must be a string")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _normalize_motherduck_config(config: Dict[str, Any]) -> None:
|
|
460
|
+
if (
|
|
461
|
+
"dbinstance_inactivity_ttl" in config
|
|
462
|
+
and "motherduck_dbinstance_inactivity_ttl" not in config
|
|
463
|
+
):
|
|
464
|
+
config["motherduck_dbinstance_inactivity_ttl"] = config[
|
|
465
|
+
"dbinstance_inactivity_ttl"
|
|
466
|
+
]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class DuckDBIdentifierPreparer(PGIdentifierPreparer):
|
|
470
|
+
def __init__(self, dialect: "Dialect", **kwargs: Any) -> None:
|
|
471
|
+
super().__init__(dialect, **kwargs)
|
|
472
|
+
|
|
473
|
+
self.reserved_words.update(
|
|
474
|
+
{
|
|
475
|
+
keyword_name
|
|
476
|
+
for (keyword_name,) in duckdb.cursor()
|
|
477
|
+
.execute(
|
|
478
|
+
"select keyword_name from duckdb_keywords() where keyword_category == 'reserved'"
|
|
479
|
+
)
|
|
480
|
+
.fetchall()
|
|
481
|
+
}
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def _separate(self, name: Optional[str]) -> Tuple[Optional[Any], Optional[str]]:
|
|
485
|
+
"""
|
|
486
|
+
Get database name and schema name from schema if it contains a database name
|
|
487
|
+
Format:
|
|
488
|
+
<db_name>.<schema_name>
|
|
489
|
+
db_name and schema_name are double quoted if contains spaces or double quotes
|
|
490
|
+
"""
|
|
491
|
+
database_name, schema_name = None, name
|
|
492
|
+
if name is not None and "." in name:
|
|
493
|
+
database_name, schema_name = (
|
|
494
|
+
max(s) for s in re.findall(r'"([^.]+)"|([^.]+)', name)
|
|
495
|
+
)
|
|
496
|
+
return database_name, schema_name
|
|
497
|
+
|
|
498
|
+
def format_schema(self, name: str) -> str:
|
|
499
|
+
"""Prepare a quoted schema name."""
|
|
500
|
+
database_name, schema_name = self._separate(name)
|
|
501
|
+
if database_name is None or schema_name is None:
|
|
502
|
+
return self.quote(name)
|
|
503
|
+
return ".".join(self.quote(str(_n)) for _n in [database_name, schema_name])
|
|
504
|
+
|
|
505
|
+
def quote_schema(self, schema: str, force: Any = None) -> str:
|
|
506
|
+
"""
|
|
507
|
+
Conditionally quote a schema name.
|
|
508
|
+
|
|
509
|
+
:param schema: string schema name
|
|
510
|
+
:param force: unused
|
|
511
|
+
"""
|
|
512
|
+
return self.format_schema(schema)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class DuckDBNullType(sqltypes.NullType):
|
|
516
|
+
def result_processor(self, dialect: Any, coltype: object) -> Any:
|
|
517
|
+
if coltype == "JSON":
|
|
518
|
+
return sqltypes.JSON().result_processor(dialect, coltype)
|
|
519
|
+
else:
|
|
520
|
+
return super().result_processor(dialect, coltype)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
class Dialect(PGDialect_psycopg2):
|
|
524
|
+
name = "duckdb"
|
|
525
|
+
driver = "duckdb_sqlalchemy"
|
|
526
|
+
_has_events = False
|
|
527
|
+
supports_statement_cache = True
|
|
528
|
+
supports_comments = False
|
|
529
|
+
supports_sane_rowcount = False
|
|
530
|
+
supports_server_side_cursors = False
|
|
531
|
+
execution_ctx_cls = DuckDBExecutionContext
|
|
532
|
+
div_is_floordiv = False # TODO: tweak this to be based on DuckDB version
|
|
533
|
+
inspector = DuckDBInspector
|
|
534
|
+
insertmanyvalues_page_size = 1000
|
|
535
|
+
use_insertmanyvalues = SQLALCHEMY_2
|
|
536
|
+
use_insertmanyvalues_wo_returning = SQLALCHEMY_2
|
|
537
|
+
duckdb_copy_threshold = 10000
|
|
538
|
+
_capabilities: "DuckDBCapabilities"
|
|
539
|
+
colspecs = util.update_copy(
|
|
540
|
+
PGDialect.colspecs,
|
|
541
|
+
{
|
|
542
|
+
# the psycopg2 driver registers a _PGNumeric with custom logic for
|
|
543
|
+
# postgres type_codes (such as 701 for float) that duckdb doesn't have
|
|
544
|
+
sqltypes.Numeric: sqltypes.Numeric,
|
|
545
|
+
sqltypes.JSON: sqltypes.JSON,
|
|
546
|
+
UUID: UUID,
|
|
547
|
+
},
|
|
548
|
+
)
|
|
549
|
+
ischema_names = util.update_copy(
|
|
550
|
+
PGDialect.ischema_names,
|
|
551
|
+
ISCHEMA_NAMES,
|
|
552
|
+
)
|
|
553
|
+
preparer = DuckDBIdentifierPreparer
|
|
554
|
+
identifier_preparer: DuckDBIdentifierPreparer
|
|
555
|
+
|
|
556
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
557
|
+
kwargs["use_native_hstore"] = False
|
|
558
|
+
super().__init__(*args, **kwargs)
|
|
559
|
+
self._capabilities = get_capabilities(duckdb.__version__)
|
|
560
|
+
|
|
561
|
+
def initialize(self, connection: "Connection") -> None:
|
|
562
|
+
DefaultDialect.initialize(self, connection)
|
|
563
|
+
self._capabilities = get_capabilities(duckdb.__version__)
|
|
564
|
+
self.supports_comments = has_comment_support()
|
|
565
|
+
|
|
566
|
+
@property
|
|
567
|
+
def capabilities(self) -> "DuckDBCapabilities":
|
|
568
|
+
return self._capabilities
|
|
569
|
+
|
|
570
|
+
def type_descriptor(self, typeobj: Type[sqltypes.TypeEngine]) -> Any: # type: ignore[override]
|
|
571
|
+
res = super().type_descriptor(typeobj)
|
|
572
|
+
|
|
573
|
+
if isinstance(res, sqltypes.NullType):
|
|
574
|
+
return DuckDBNullType()
|
|
575
|
+
|
|
576
|
+
return res
|
|
577
|
+
|
|
578
|
+
def connect(self, *cargs: Any, **cparams: Any) -> Any:
|
|
579
|
+
core_keys = get_core_config()
|
|
580
|
+
preload_extensions = cparams.pop("preload_extensions", [])
|
|
581
|
+
config = dict(cparams.get("config", {}))
|
|
582
|
+
cparams["config"] = config
|
|
583
|
+
config.update(cparams.pop("url_config", {}))
|
|
584
|
+
for key in DIALECT_QUERY_KEYS:
|
|
585
|
+
config.pop(key, None)
|
|
586
|
+
_apply_motherduck_defaults(config, cparams.get("database"))
|
|
587
|
+
path_query = extract_path_query_from_config(config)
|
|
588
|
+
if path_query:
|
|
589
|
+
cparams["database"] = append_query_to_database(
|
|
590
|
+
cparams.get("database"), path_query
|
|
591
|
+
)
|
|
592
|
+
_normalize_motherduck_config(config)
|
|
593
|
+
|
|
594
|
+
ext = {k: config.pop(k) for k in list(config) if k not in core_keys}
|
|
595
|
+
if supports_user_agent:
|
|
596
|
+
user_agent = (
|
|
597
|
+
f"duckdb-sqlalchemy/{__version__}(sqlalchemy/{sqlalchemy_version})"
|
|
598
|
+
)
|
|
599
|
+
if "custom_user_agent" in config:
|
|
600
|
+
user_agent = f"{user_agent} {config['custom_user_agent']}"
|
|
601
|
+
config["custom_user_agent"] = user_agent
|
|
602
|
+
|
|
603
|
+
filesystems = cparams.pop("register_filesystems", [])
|
|
604
|
+
|
|
605
|
+
conn = duckdb.connect(*cargs, **cparams)
|
|
606
|
+
|
|
607
|
+
for extension in preload_extensions:
|
|
608
|
+
conn.execute(f"LOAD {extension}")
|
|
609
|
+
|
|
610
|
+
for filesystem in filesystems:
|
|
611
|
+
conn.register_filesystem(filesystem)
|
|
612
|
+
|
|
613
|
+
apply_config(self, conn, ext)
|
|
614
|
+
|
|
615
|
+
return ConnectionWrapper(conn)
|
|
616
|
+
|
|
617
|
+
def on_connect(self) -> None:
|
|
618
|
+
pass
|
|
619
|
+
|
|
620
|
+
@classmethod
|
|
621
|
+
def get_pool_class(cls, url: SAURL) -> Type[pool.Pool]:
|
|
622
|
+
pool_override = _pool_override_from_url(url)
|
|
623
|
+
if pool_override == "queue":
|
|
624
|
+
return pool.QueuePool
|
|
625
|
+
if pool_override in {"singleton", "singletonthreadpool"}:
|
|
626
|
+
return pool.SingletonThreadPool
|
|
627
|
+
if pool_override in {"null", "nullpool"}:
|
|
628
|
+
return pool.NullPool
|
|
629
|
+
if url.database and url.database.startswith(":memory:"):
|
|
630
|
+
return pool.SingletonThreadPool
|
|
631
|
+
if _looks_like_motherduck(url.database, dict(url.query)):
|
|
632
|
+
return pool.NullPool
|
|
633
|
+
return pool.NullPool
|
|
634
|
+
|
|
635
|
+
@staticmethod
|
|
636
|
+
def dbapi(**kwargs: Any) -> Type[DBAPI]:
|
|
637
|
+
return DBAPI
|
|
638
|
+
|
|
639
|
+
def _get_server_version_info(self, connection: "Connection") -> Tuple[int, int]:
|
|
640
|
+
return (8, 0)
|
|
641
|
+
|
|
642
|
+
def get_default_isolation_level(self, dbapi_conn):
|
|
643
|
+
return self.get_isolation_level(dbapi_conn)
|
|
644
|
+
|
|
645
|
+
def do_rollback(self, dbapi_connection: Any) -> None:
|
|
646
|
+
try:
|
|
647
|
+
super().do_rollback(dbapi_connection)
|
|
648
|
+
except DBAPI.TransactionException as e:
|
|
649
|
+
if (
|
|
650
|
+
e.args[0]
|
|
651
|
+
!= "TransactionContext Error: cannot rollback - no transaction is active"
|
|
652
|
+
):
|
|
653
|
+
raise e
|
|
654
|
+
|
|
655
|
+
def do_begin(self, dbapi_connection: Any) -> None:
|
|
656
|
+
dbapi_connection.begin()
|
|
657
|
+
|
|
658
|
+
def get_view_names(
|
|
659
|
+
self,
|
|
660
|
+
connection: Any,
|
|
661
|
+
schema: Optional[Any] = None,
|
|
662
|
+
include: Optional[Any] = None,
|
|
663
|
+
**kw: Any,
|
|
664
|
+
) -> Any:
|
|
665
|
+
s = """
|
|
666
|
+
SELECT table_name
|
|
667
|
+
FROM information_schema.tables
|
|
668
|
+
WHERE
|
|
669
|
+
table_type='VIEW'
|
|
670
|
+
AND table_schema = :schema_name
|
|
671
|
+
"""
|
|
672
|
+
params = {}
|
|
673
|
+
database_name = None
|
|
674
|
+
|
|
675
|
+
if schema is not None:
|
|
676
|
+
database_name, schema = self.identifier_preparer._separate(schema)
|
|
677
|
+
else:
|
|
678
|
+
schema = "main"
|
|
679
|
+
|
|
680
|
+
params.update({"schema_name": schema})
|
|
681
|
+
|
|
682
|
+
if database_name is not None:
|
|
683
|
+
s += "AND table_catalog = :database_name\n"
|
|
684
|
+
params.update({"database_name": database_name})
|
|
685
|
+
|
|
686
|
+
rs = connection.execute(text(s), params)
|
|
687
|
+
return [view for (view,) in rs]
|
|
688
|
+
|
|
689
|
+
@cache # type: ignore[call-arg]
|
|
690
|
+
def get_schema_names(self, connection: "Connection", **kw: "Any"): # type: ignore[no-untyped-def]
|
|
691
|
+
"""
|
|
692
|
+
Return unquoted database_name.schema_name unless either contains spaces or double quotes.
|
|
693
|
+
In that case, escape double quotes and then wrap in double quotes.
|
|
694
|
+
SQLAlchemy definition of a schema includes database name for databases like SQL Server (Ex: databasename.dbo)
|
|
695
|
+
(see https://docs.sqlalchemy.org/en/20/dialects/mssql.html#multipart-schema-names)
|
|
696
|
+
"""
|
|
697
|
+
|
|
698
|
+
if not supports_attach:
|
|
699
|
+
return super().get_schema_names(connection, **kw)
|
|
700
|
+
|
|
701
|
+
s = """
|
|
702
|
+
SELECT database_name, schema_name AS nspname
|
|
703
|
+
FROM duckdb_schemas()
|
|
704
|
+
WHERE schema_name NOT LIKE 'pg\\_%' ESCAPE '\\'
|
|
705
|
+
ORDER BY database_name, nspname
|
|
706
|
+
"""
|
|
707
|
+
rs = connection.execute(text(s))
|
|
708
|
+
|
|
709
|
+
qs = self.identifier_preparer.quote_schema
|
|
710
|
+
return [qs(".".join(nspname)) for nspname in rs]
|
|
711
|
+
|
|
712
|
+
def _build_query_where(
|
|
713
|
+
self,
|
|
714
|
+
table_name: Optional[str] = None,
|
|
715
|
+
schema_name: Optional[str] = None,
|
|
716
|
+
database_name: Optional[str] = None,
|
|
717
|
+
) -> Tuple[str, Dict[str, str]]:
|
|
718
|
+
sql = ""
|
|
719
|
+
params = {}
|
|
720
|
+
|
|
721
|
+
# If no database name is provided, try to get it from the schema name
|
|
722
|
+
# specified as "<db name>.<schema name>"
|
|
723
|
+
# If only a schema name is found, database_name will return None
|
|
724
|
+
if database_name is None and schema_name is not None:
|
|
725
|
+
database_name, schema_name = self.identifier_preparer._separate(schema_name)
|
|
726
|
+
|
|
727
|
+
if table_name is not None:
|
|
728
|
+
sql += "AND table_name = :table_name\n"
|
|
729
|
+
params.update({"table_name": table_name})
|
|
730
|
+
|
|
731
|
+
if schema_name is not None:
|
|
732
|
+
sql += "AND schema_name = :schema_name\n"
|
|
733
|
+
params.update({"schema_name": schema_name})
|
|
734
|
+
|
|
735
|
+
if database_name is not None:
|
|
736
|
+
sql += "AND database_name = :database_name\n"
|
|
737
|
+
params.update({"database_name": database_name})
|
|
738
|
+
|
|
739
|
+
return sql, params
|
|
740
|
+
|
|
741
|
+
@cache # type: ignore[call-arg]
|
|
742
|
+
def get_table_names(self, connection: "Connection", schema=None, **kw: "Any"): # type: ignore[no-untyped-def]
|
|
743
|
+
"""
|
|
744
|
+
Return unquoted database_name.schema_name unless either contains spaces or double quotes.
|
|
745
|
+
In that case, escape double quotes and then wrap in double quotes.
|
|
746
|
+
SQLAlchemy definition of a schema includes database name for databases like SQL Server (Ex: databasename.dbo)
|
|
747
|
+
(see https://docs.sqlalchemy.org/en/20/dialects/mssql.html#multipart-schema-names)
|
|
748
|
+
"""
|
|
749
|
+
|
|
750
|
+
if not supports_attach:
|
|
751
|
+
return super().get_table_names(connection, schema, **kw)
|
|
752
|
+
|
|
753
|
+
s = """
|
|
754
|
+
SELECT database_name, schema_name, table_name
|
|
755
|
+
FROM duckdb_tables()
|
|
756
|
+
WHERE schema_name NOT LIKE 'pg\\_%' ESCAPE '\\'
|
|
757
|
+
"""
|
|
758
|
+
sql, params = self._build_query_where(schema_name=schema)
|
|
759
|
+
s += sql
|
|
760
|
+
rs = connection.execute(text(s), params)
|
|
761
|
+
|
|
762
|
+
return [
|
|
763
|
+
table
|
|
764
|
+
for (
|
|
765
|
+
db,
|
|
766
|
+
sc,
|
|
767
|
+
table,
|
|
768
|
+
) in rs
|
|
769
|
+
]
|
|
770
|
+
|
|
771
|
+
@cache # type: ignore[call-arg]
|
|
772
|
+
def get_table_oid( # type: ignore[no-untyped-def]
|
|
773
|
+
self,
|
|
774
|
+
connection: "Connection",
|
|
775
|
+
table_name: str,
|
|
776
|
+
schema: "Optional[str]" = None,
|
|
777
|
+
**kw: "Any",
|
|
778
|
+
):
|
|
779
|
+
"""Fetch the oid for (database.)schema.table_name.
|
|
780
|
+
The schema name can be formatted either as database.schema or just the schema name.
|
|
781
|
+
In the latter scenario the schema associated with the default database is used.
|
|
782
|
+
"""
|
|
783
|
+
s = """
|
|
784
|
+
SELECT oid, table_name
|
|
785
|
+
FROM (
|
|
786
|
+
SELECT table_oid AS oid, table_name, database_name, schema_name FROM duckdb_tables()
|
|
787
|
+
UNION ALL BY NAME
|
|
788
|
+
SELECT view_oid AS oid , view_name AS table_name, database_name, schema_name FROM duckdb_views()
|
|
789
|
+
)
|
|
790
|
+
WHERE schema_name NOT LIKE 'pg\\_%' ESCAPE '\\'
|
|
791
|
+
"""
|
|
792
|
+
sql, params = self._build_query_where(table_name=table_name, schema_name=schema)
|
|
793
|
+
s += sql
|
|
794
|
+
|
|
795
|
+
rs = connection.execute(text(s), params)
|
|
796
|
+
table_oid = rs.scalar()
|
|
797
|
+
if table_oid is None:
|
|
798
|
+
raise NoSuchTableError(table_name)
|
|
799
|
+
return table_oid
|
|
800
|
+
|
|
801
|
+
def _duckdb_table_exists(
|
|
802
|
+
self, connection: "Connection", table_name: str, schema: Optional[str]
|
|
803
|
+
) -> bool:
|
|
804
|
+
sql = """
|
|
805
|
+
SELECT 1
|
|
806
|
+
FROM duckdb_tables()
|
|
807
|
+
WHERE table_name = :table_name
|
|
808
|
+
"""
|
|
809
|
+
params: Dict[str, Any] = {"table_name": table_name}
|
|
810
|
+
if schema is not None:
|
|
811
|
+
database_name, schema_name = self.identifier_preparer._separate(schema)
|
|
812
|
+
sql += "AND schema_name = :schema_name\n"
|
|
813
|
+
params["schema_name"] = schema_name
|
|
814
|
+
if database_name is not None:
|
|
815
|
+
sql += "AND database_name = :database_name\n"
|
|
816
|
+
params["database_name"] = database_name
|
|
817
|
+
return connection.execute(text(sql), params).first() is not None
|
|
818
|
+
|
|
819
|
+
def _duckdb_columns(
|
|
820
|
+
self, connection: "Connection", table_name: str, schema: Optional[str]
|
|
821
|
+
) -> Optional[List[Dict[str, Any]]]:
|
|
822
|
+
sql = """
|
|
823
|
+
SELECT column_name, column_default, is_nullable, data_type, comment, column_index
|
|
824
|
+
FROM duckdb_columns()
|
|
825
|
+
WHERE table_name = :table_name
|
|
826
|
+
"""
|
|
827
|
+
params: Dict[str, Any] = {"table_name": table_name}
|
|
828
|
+
if schema is not None:
|
|
829
|
+
database_name, schema_name = self.identifier_preparer._separate(schema)
|
|
830
|
+
sql += "AND schema_name = :schema_name\n"
|
|
831
|
+
params["schema_name"] = schema_name
|
|
832
|
+
if database_name is not None:
|
|
833
|
+
sql += "AND database_name = :database_name\n"
|
|
834
|
+
params["database_name"] = database_name
|
|
835
|
+
sql += "ORDER BY column_index"
|
|
836
|
+
rows = list(connection.execute(text(sql), params).mappings())
|
|
837
|
+
if not rows:
|
|
838
|
+
return None
|
|
839
|
+
columns: List[Dict[str, Any]] = []
|
|
840
|
+
for row in rows:
|
|
841
|
+
coltype = self._reflect_type( # type: ignore[attr-defined]
|
|
842
|
+
row["data_type"],
|
|
843
|
+
{},
|
|
844
|
+
{},
|
|
845
|
+
type_description=f"column '{row['column_name']}'",
|
|
846
|
+
collation=None,
|
|
847
|
+
)
|
|
848
|
+
columns.append(
|
|
849
|
+
{
|
|
850
|
+
"name": row["column_name"],
|
|
851
|
+
"type": coltype,
|
|
852
|
+
"nullable": bool(row["is_nullable"]),
|
|
853
|
+
"default": row["column_default"],
|
|
854
|
+
"autoincrement": False,
|
|
855
|
+
"comment": row["comment"],
|
|
856
|
+
}
|
|
857
|
+
)
|
|
858
|
+
return columns
|
|
859
|
+
|
|
860
|
+
def has_table(
|
|
861
|
+
self,
|
|
862
|
+
connection: "Connection",
|
|
863
|
+
table_name: str,
|
|
864
|
+
schema: Optional[str] = None,
|
|
865
|
+
**kw: Any,
|
|
866
|
+
) -> bool:
|
|
867
|
+
try:
|
|
868
|
+
return self.get_table_oid(connection, table_name, schema) is not None
|
|
869
|
+
except NoSuchTableError:
|
|
870
|
+
return False
|
|
871
|
+
|
|
872
|
+
@cache # type: ignore[call-arg]
|
|
873
|
+
def get_columns( # type: ignore[no-untyped-def]
|
|
874
|
+
self, connection: "Connection", table_name: str, schema=None, **kw: Any
|
|
875
|
+
):
|
|
876
|
+
try:
|
|
877
|
+
return super().get_columns(connection, table_name, schema=schema, **kw)
|
|
878
|
+
except NoSuchTableError:
|
|
879
|
+
columns = self._duckdb_columns(connection, table_name, schema)
|
|
880
|
+
if columns is None:
|
|
881
|
+
raise
|
|
882
|
+
return columns
|
|
883
|
+
|
|
884
|
+
@cache # type: ignore[call-arg]
|
|
885
|
+
def get_foreign_keys( # type: ignore[no-untyped-def]
|
|
886
|
+
self, connection: "Connection", table_name: str, schema=None, **kw: Any
|
|
887
|
+
):
|
|
888
|
+
try:
|
|
889
|
+
return super().get_foreign_keys(connection, table_name, schema=schema, **kw)
|
|
890
|
+
except NoSuchTableError:
|
|
891
|
+
if self._duckdb_table_exists(connection, table_name, schema):
|
|
892
|
+
return []
|
|
893
|
+
raise
|
|
894
|
+
|
|
895
|
+
@cache # type: ignore[call-arg]
|
|
896
|
+
def get_unique_constraints( # type: ignore[no-untyped-def]
|
|
897
|
+
self, connection: "Connection", table_name: str, schema=None, **kw: Any
|
|
898
|
+
):
|
|
899
|
+
try:
|
|
900
|
+
return super().get_unique_constraints(
|
|
901
|
+
connection, table_name, schema=schema, **kw
|
|
902
|
+
)
|
|
903
|
+
except NoSuchTableError:
|
|
904
|
+
if self._duckdb_table_exists(connection, table_name, schema):
|
|
905
|
+
return []
|
|
906
|
+
raise
|
|
907
|
+
|
|
908
|
+
@cache # type: ignore[call-arg]
|
|
909
|
+
def get_check_constraints( # type: ignore[no-untyped-def]
|
|
910
|
+
self, connection: "Connection", table_name: str, schema=None, **kw: Any
|
|
911
|
+
):
|
|
912
|
+
try:
|
|
913
|
+
return super().get_check_constraints(
|
|
914
|
+
connection, table_name, schema=schema, **kw
|
|
915
|
+
)
|
|
916
|
+
except NoSuchTableError:
|
|
917
|
+
if self._duckdb_table_exists(connection, table_name, schema):
|
|
918
|
+
return []
|
|
919
|
+
raise
|
|
920
|
+
|
|
921
|
+
def get_indexes(
|
|
922
|
+
self,
|
|
923
|
+
connection: "Connection",
|
|
924
|
+
table_name: str,
|
|
925
|
+
schema: Optional[str] = None,
|
|
926
|
+
**kw: Any,
|
|
927
|
+
) -> List["ReflectedIndex"]:
|
|
928
|
+
index_warning()
|
|
929
|
+
return []
|
|
930
|
+
|
|
931
|
+
# the following methods are for SQLA2 compatibility
|
|
932
|
+
def get_multi_indexes(
|
|
933
|
+
self,
|
|
934
|
+
connection: "Connection",
|
|
935
|
+
schema: Optional[str] = None,
|
|
936
|
+
filter_names: Optional[Collection[str]] = None,
|
|
937
|
+
scope: Any = None,
|
|
938
|
+
kind: Any = None,
|
|
939
|
+
**kw: Any,
|
|
940
|
+
) -> Iterable[Tuple[Any, Any]]:
|
|
941
|
+
index_warning()
|
|
942
|
+
return []
|
|
943
|
+
|
|
944
|
+
def create_connect_args(self, url: SAURL) -> Tuple[tuple, dict]:
|
|
945
|
+
opts = url.translate_connect_args(database="database")
|
|
946
|
+
path_query, url_config = split_url_query(dict(url.query))
|
|
947
|
+
opts["url_config"] = url_config
|
|
948
|
+
opts["database"] = append_query_to_database(opts.get("database"), path_query)
|
|
949
|
+
return (), opts
|
|
950
|
+
|
|
951
|
+
@classmethod
|
|
952
|
+
def import_dbapi(cls) -> Any:
|
|
953
|
+
return cls.dbapi()
|
|
954
|
+
|
|
955
|
+
def _get_execution_options(self, context: Optional[Any]) -> Dict[str, Any]:
|
|
956
|
+
if context is None:
|
|
957
|
+
return {}
|
|
958
|
+
return getattr(context, "execution_options", {}) or {}
|
|
959
|
+
|
|
960
|
+
def _bulk_insert_via_register(
|
|
961
|
+
self,
|
|
962
|
+
cursor: Any,
|
|
963
|
+
context: Any,
|
|
964
|
+
parameters: Sequence[Any],
|
|
965
|
+
) -> bool:
|
|
966
|
+
if not parameters:
|
|
967
|
+
return False
|
|
968
|
+
compiled = getattr(context, "compiled", None)
|
|
969
|
+
if compiled is None:
|
|
970
|
+
return False
|
|
971
|
+
if getattr(compiled, "effective_returning", None):
|
|
972
|
+
return False
|
|
973
|
+
stmt = getattr(compiled, "statement", None)
|
|
974
|
+
table = getattr(stmt, "table", None)
|
|
975
|
+
if table is None:
|
|
976
|
+
return False
|
|
977
|
+
if getattr(stmt, "_post_values_clause", None) is not None:
|
|
978
|
+
return False
|
|
979
|
+
|
|
980
|
+
column_keys = getattr(compiled, "column_keys", None)
|
|
981
|
+
if not column_keys:
|
|
982
|
+
column_keys = getattr(compiled, "positiontup", None)
|
|
983
|
+
if not column_keys and isinstance(parameters[0], dict):
|
|
984
|
+
column_keys = list(parameters[0].keys())
|
|
985
|
+
if not column_keys:
|
|
986
|
+
return False
|
|
987
|
+
|
|
988
|
+
column_names = [
|
|
989
|
+
getattr(column_key, "key", column_key) for column_key in column_keys
|
|
990
|
+
]
|
|
991
|
+
|
|
992
|
+
data = None
|
|
993
|
+
if isinstance(parameters[0], dict):
|
|
994
|
+
try:
|
|
995
|
+
import pandas as pd # type: ignore[import-not-found]
|
|
996
|
+
|
|
997
|
+
rows = parameters if isinstance(parameters, list) else list(parameters)
|
|
998
|
+
data = pd.DataFrame.from_records(rows, columns=column_names)
|
|
999
|
+
except Exception:
|
|
1000
|
+
data = None
|
|
1001
|
+
if data is None:
|
|
1002
|
+
try:
|
|
1003
|
+
import pyarrow as pa # type: ignore[import-not-found]
|
|
1004
|
+
|
|
1005
|
+
rows = (
|
|
1006
|
+
parameters if isinstance(parameters, list) else list(parameters)
|
|
1007
|
+
)
|
|
1008
|
+
data = pa.Table.from_pylist(rows)
|
|
1009
|
+
if column_names:
|
|
1010
|
+
data = data.select(column_names)
|
|
1011
|
+
except Exception:
|
|
1012
|
+
return False
|
|
1013
|
+
else:
|
|
1014
|
+
try:
|
|
1015
|
+
import pandas as pd # type: ignore[import-not-found]
|
|
1016
|
+
|
|
1017
|
+
rows = parameters if isinstance(parameters, list) else list(parameters)
|
|
1018
|
+
data = pd.DataFrame(rows, columns=column_names)
|
|
1019
|
+
except Exception:
|
|
1020
|
+
data = None
|
|
1021
|
+
if data is None:
|
|
1022
|
+
try:
|
|
1023
|
+
import pyarrow as pa # type: ignore[import-not-found]
|
|
1024
|
+
|
|
1025
|
+
rows = (
|
|
1026
|
+
parameters if isinstance(parameters, list) else list(parameters)
|
|
1027
|
+
)
|
|
1028
|
+
if rows:
|
|
1029
|
+
cols = list(zip(*rows))
|
|
1030
|
+
else:
|
|
1031
|
+
cols = [[] for _ in column_names]
|
|
1032
|
+
data = pa.Table.from_arrays(cols, names=column_names)
|
|
1033
|
+
except Exception:
|
|
1034
|
+
return False
|
|
1035
|
+
|
|
1036
|
+
view_name = f"__duckdb_sa_bulk_{uuid.uuid4().hex}"
|
|
1037
|
+
dbapi_conn = cursor.connection
|
|
1038
|
+
dbapi_conn.register(view_name, data)
|
|
1039
|
+
preparer = getattr(context, "identifier_preparer", self.identifier_preparer)
|
|
1040
|
+
target = preparer.format_table(table)
|
|
1041
|
+
columns = ", ".join(preparer.quote(col) for col in column_names)
|
|
1042
|
+
insert_sql = (
|
|
1043
|
+
f"INSERT INTO {target} ({columns}) SELECT {columns} FROM {view_name}"
|
|
1044
|
+
)
|
|
1045
|
+
try:
|
|
1046
|
+
cursor.execute(insert_sql)
|
|
1047
|
+
finally:
|
|
1048
|
+
try:
|
|
1049
|
+
dbapi_conn.unregister(view_name)
|
|
1050
|
+
except Exception:
|
|
1051
|
+
pass
|
|
1052
|
+
return True
|
|
1053
|
+
|
|
1054
|
+
def do_executemany(
|
|
1055
|
+
self, cursor: Any, statement: Any, parameters: Any, context: Optional[Any] = ...
|
|
1056
|
+
) -> None:
|
|
1057
|
+
if (
|
|
1058
|
+
context is not None
|
|
1059
|
+
and getattr(context, "isinsert", False)
|
|
1060
|
+
and parameters
|
|
1061
|
+
and isinstance(parameters, (list, tuple))
|
|
1062
|
+
):
|
|
1063
|
+
options = self._get_execution_options(context)
|
|
1064
|
+
copy_threshold = options.get(
|
|
1065
|
+
"duckdb_copy_threshold", self.duckdb_copy_threshold
|
|
1066
|
+
)
|
|
1067
|
+
if copy_threshold and len(parameters) >= copy_threshold:
|
|
1068
|
+
if self._bulk_insert_via_register(cursor, context, parameters):
|
|
1069
|
+
return None
|
|
1070
|
+
return DefaultDialect.do_executemany(
|
|
1071
|
+
self, cursor, statement, parameters, context
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
def is_disconnect(self, e: Exception, connection: Any, cursor: Any) -> bool:
|
|
1075
|
+
message = str(e).lower()
|
|
1076
|
+
return any(pattern in message for pattern in DISCONNECT_ERROR_PATTERNS)
|
|
1077
|
+
|
|
1078
|
+
def _execute_with_retry(
|
|
1079
|
+
self,
|
|
1080
|
+
cursor: Any,
|
|
1081
|
+
statement: str,
|
|
1082
|
+
parameters: Any,
|
|
1083
|
+
context: Optional[Any],
|
|
1084
|
+
executor: Any,
|
|
1085
|
+
) -> Any:
|
|
1086
|
+
options = self._get_execution_options(context)
|
|
1087
|
+
retry_count = int(options.get("duckdb_retry_count", 0) or 0)
|
|
1088
|
+
if options.get("duckdb_retry_on_transient") and retry_count == 0:
|
|
1089
|
+
retry_count = 1
|
|
1090
|
+
if retry_count <= 0 or not _is_idempotent_statement(statement):
|
|
1091
|
+
return executor()
|
|
1092
|
+
backoff = options.get("duckdb_retry_backoff")
|
|
1093
|
+
attempt = 0
|
|
1094
|
+
while True:
|
|
1095
|
+
try:
|
|
1096
|
+
return executor()
|
|
1097
|
+
except Exception as exc:
|
|
1098
|
+
if attempt >= retry_count or not _is_transient_error(exc):
|
|
1099
|
+
raise
|
|
1100
|
+
attempt += 1
|
|
1101
|
+
if backoff:
|
|
1102
|
+
time.sleep(float(backoff))
|
|
1103
|
+
|
|
1104
|
+
def do_execute(
|
|
1105
|
+
self,
|
|
1106
|
+
cursor: Any,
|
|
1107
|
+
statement: str,
|
|
1108
|
+
parameters: Any,
|
|
1109
|
+
context: Optional[Any] = None,
|
|
1110
|
+
) -> None:
|
|
1111
|
+
def executor() -> Any:
|
|
1112
|
+
return DefaultDialect.do_execute(
|
|
1113
|
+
self, cursor, statement, parameters, context
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
self._execute_with_retry(cursor, statement, parameters, context, executor)
|
|
1117
|
+
|
|
1118
|
+
def do_execute_no_params(
|
|
1119
|
+
self,
|
|
1120
|
+
cursor: Any,
|
|
1121
|
+
statement: str,
|
|
1122
|
+
context: Optional[Any] = None,
|
|
1123
|
+
) -> None:
|
|
1124
|
+
def executor() -> Any:
|
|
1125
|
+
return DefaultDialect.do_execute_no_params(self, cursor, statement, context)
|
|
1126
|
+
|
|
1127
|
+
self._execute_with_retry(cursor, statement, None, context, executor)
|
|
1128
|
+
|
|
1129
|
+
def _pg_class_filter_scope_schema(
|
|
1130
|
+
self,
|
|
1131
|
+
query: Select,
|
|
1132
|
+
schema: Optional[str],
|
|
1133
|
+
scope: Any,
|
|
1134
|
+
pg_class_table: Any = None,
|
|
1135
|
+
) -> Any:
|
|
1136
|
+
# Scope by schema, but strip any database prefix (DuckDB uses db.schema).
|
|
1137
|
+
# This will not work if a schema or table name is not unique!
|
|
1138
|
+
if hasattr(super(), "_pg_class_filter_scope_schema"):
|
|
1139
|
+
schema_arg = schema
|
|
1140
|
+
if schema is not None:
|
|
1141
|
+
_, schema_name = self.identifier_preparer._separate(schema)
|
|
1142
|
+
schema_arg = schema_name
|
|
1143
|
+
return getattr(super(), "_pg_class_filter_scope_schema")(
|
|
1144
|
+
query,
|
|
1145
|
+
schema=schema_arg,
|
|
1146
|
+
scope=scope,
|
|
1147
|
+
pg_class_table=pg_class_table,
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
@lru_cache()
|
|
1151
|
+
def _columns_query(self, schema, has_filter_names, scope, kind): # type: ignore[no-untyped-def]
|
|
1152
|
+
if not SQLALCHEMY_2:
|
|
1153
|
+
return super()._columns_query(schema, has_filter_names, scope, kind) # type: ignore[misc]
|
|
1154
|
+
|
|
1155
|
+
# DuckDB versions before 1.4 don't expose pg_collation; skip collation
|
|
1156
|
+
# reflection to avoid Catalog Errors during SQLAlchemy 2.x reflection.
|
|
1157
|
+
from sqlalchemy.dialects.postgresql import base as pg_base
|
|
1158
|
+
|
|
1159
|
+
pg_catalog = pg_base.pg_catalog
|
|
1160
|
+
REGCLASS = pg_base.REGCLASS
|
|
1161
|
+
TEXT = pg_base.TEXT
|
|
1162
|
+
OID = pg_base.OID
|
|
1163
|
+
|
|
1164
|
+
server_version_info = self.server_version_info or (0,)
|
|
1165
|
+
|
|
1166
|
+
generated = (
|
|
1167
|
+
pg_catalog.pg_attribute.c.attgenerated.label("generated")
|
|
1168
|
+
if server_version_info >= (12,)
|
|
1169
|
+
else sql.null().label("generated")
|
|
1170
|
+
)
|
|
1171
|
+
if server_version_info >= (10,):
|
|
1172
|
+
identity = (
|
|
1173
|
+
select(
|
|
1174
|
+
sql.func.json_build_object(
|
|
1175
|
+
"always",
|
|
1176
|
+
pg_catalog.pg_attribute.c.attidentity == "a",
|
|
1177
|
+
"start",
|
|
1178
|
+
pg_catalog.pg_sequence.c.seqstart,
|
|
1179
|
+
"increment",
|
|
1180
|
+
pg_catalog.pg_sequence.c.seqincrement,
|
|
1181
|
+
"minvalue",
|
|
1182
|
+
pg_catalog.pg_sequence.c.seqmin,
|
|
1183
|
+
"maxvalue",
|
|
1184
|
+
pg_catalog.pg_sequence.c.seqmax,
|
|
1185
|
+
"cache",
|
|
1186
|
+
pg_catalog.pg_sequence.c.seqcache,
|
|
1187
|
+
"cycle",
|
|
1188
|
+
pg_catalog.pg_sequence.c.seqcycle,
|
|
1189
|
+
type_=sqltypes.JSON(),
|
|
1190
|
+
)
|
|
1191
|
+
)
|
|
1192
|
+
.select_from(pg_catalog.pg_sequence)
|
|
1193
|
+
.where(
|
|
1194
|
+
pg_catalog.pg_attribute.c.attidentity != "",
|
|
1195
|
+
pg_catalog.pg_sequence.c.seqrelid
|
|
1196
|
+
== sql.cast(
|
|
1197
|
+
sql.cast(
|
|
1198
|
+
pg_catalog.pg_get_serial_sequence(
|
|
1199
|
+
sql.cast(
|
|
1200
|
+
sql.cast(
|
|
1201
|
+
pg_catalog.pg_attribute.c.attrelid,
|
|
1202
|
+
REGCLASS,
|
|
1203
|
+
),
|
|
1204
|
+
TEXT,
|
|
1205
|
+
),
|
|
1206
|
+
pg_catalog.pg_attribute.c.attname,
|
|
1207
|
+
),
|
|
1208
|
+
REGCLASS,
|
|
1209
|
+
),
|
|
1210
|
+
OID,
|
|
1211
|
+
),
|
|
1212
|
+
)
|
|
1213
|
+
.correlate(pg_catalog.pg_attribute)
|
|
1214
|
+
.scalar_subquery()
|
|
1215
|
+
.label("identity_options")
|
|
1216
|
+
)
|
|
1217
|
+
else:
|
|
1218
|
+
identity = sql.null().label("identity_options")
|
|
1219
|
+
|
|
1220
|
+
default = (
|
|
1221
|
+
select(
|
|
1222
|
+
pg_catalog.pg_get_expr(
|
|
1223
|
+
pg_catalog.pg_attrdef.c.adbin,
|
|
1224
|
+
pg_catalog.pg_attrdef.c.adrelid,
|
|
1225
|
+
)
|
|
1226
|
+
)
|
|
1227
|
+
.select_from(pg_catalog.pg_attrdef)
|
|
1228
|
+
.where(
|
|
1229
|
+
pg_catalog.pg_attrdef.c.adrelid == pg_catalog.pg_attribute.c.attrelid,
|
|
1230
|
+
pg_catalog.pg_attrdef.c.adnum == pg_catalog.pg_attribute.c.attnum,
|
|
1231
|
+
pg_catalog.pg_attribute.c.atthasdef,
|
|
1232
|
+
)
|
|
1233
|
+
.correlate(pg_catalog.pg_attribute)
|
|
1234
|
+
.scalar_subquery()
|
|
1235
|
+
.label("default")
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
collate = sql.null().label("collation")
|
|
1239
|
+
|
|
1240
|
+
relkinds = self._kind_to_relkinds(kind)
|
|
1241
|
+
query = (
|
|
1242
|
+
select(
|
|
1243
|
+
pg_catalog.pg_attribute.c.attname.label("name"),
|
|
1244
|
+
pg_catalog.format_type(
|
|
1245
|
+
pg_catalog.pg_attribute.c.atttypid,
|
|
1246
|
+
pg_catalog.pg_attribute.c.atttypmod,
|
|
1247
|
+
).label("format_type"),
|
|
1248
|
+
default,
|
|
1249
|
+
pg_catalog.pg_attribute.c.attnotnull.label("not_null"),
|
|
1250
|
+
pg_catalog.pg_class.c.relname.label("table_name"),
|
|
1251
|
+
pg_catalog.pg_description.c.description.label("comment"),
|
|
1252
|
+
generated,
|
|
1253
|
+
identity,
|
|
1254
|
+
collate,
|
|
1255
|
+
)
|
|
1256
|
+
.select_from(pg_catalog.pg_class)
|
|
1257
|
+
.outerjoin(
|
|
1258
|
+
pg_catalog.pg_attribute,
|
|
1259
|
+
sql.and_(
|
|
1260
|
+
pg_catalog.pg_class.c.oid == pg_catalog.pg_attribute.c.attrelid,
|
|
1261
|
+
pg_catalog.pg_attribute.c.attnum > 0,
|
|
1262
|
+
~pg_catalog.pg_attribute.c.attisdropped,
|
|
1263
|
+
),
|
|
1264
|
+
)
|
|
1265
|
+
.outerjoin(
|
|
1266
|
+
pg_catalog.pg_description,
|
|
1267
|
+
sql.and_(
|
|
1268
|
+
pg_catalog.pg_description.c.objoid
|
|
1269
|
+
== pg_catalog.pg_attribute.c.attrelid,
|
|
1270
|
+
pg_catalog.pg_description.c.objsubid
|
|
1271
|
+
== pg_catalog.pg_attribute.c.attnum,
|
|
1272
|
+
),
|
|
1273
|
+
)
|
|
1274
|
+
.where(self._pg_class_relkind_condition(relkinds))
|
|
1275
|
+
.order_by(pg_catalog.pg_class.c.relname, pg_catalog.pg_attribute.c.attnum)
|
|
1276
|
+
)
|
|
1277
|
+
query = self._pg_class_filter_scope_schema(query, schema, scope=scope)
|
|
1278
|
+
if has_filter_names:
|
|
1279
|
+
query = query.where(
|
|
1280
|
+
pg_catalog.pg_class.c.relname.in_(bindparam("filter_names"))
|
|
1281
|
+
)
|
|
1282
|
+
return query
|
|
1283
|
+
|
|
1284
|
+
# FIXME: this method is a hack around the fact that we use a single cursor for all queries inside a connection,
|
|
1285
|
+
# and this is required to fix get_multi_columns
|
|
1286
|
+
def get_multi_columns(
|
|
1287
|
+
self,
|
|
1288
|
+
connection: "Connection",
|
|
1289
|
+
schema: Optional[str] = None,
|
|
1290
|
+
filter_names: Optional[Collection[str]] = None,
|
|
1291
|
+
scope: Any = None,
|
|
1292
|
+
kind: Any = None,
|
|
1293
|
+
**kw: Any,
|
|
1294
|
+
) -> Any:
|
|
1295
|
+
"""
|
|
1296
|
+
Copyright 2005-2023 SQLAlchemy authors and contributors <see AUTHORS file>.
|
|
1297
|
+
|
|
1298
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
1299
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
1300
|
+
the Software without restriction, including without limitation the rights to
|
|
1301
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
1302
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
|
1303
|
+
so, subject to the following conditions:
|
|
1304
|
+
|
|
1305
|
+
The above copyright notice and this permission notice shall be included in all
|
|
1306
|
+
copies or substantial portions of the Software.
|
|
1307
|
+
|
|
1308
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
1309
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
1310
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
1311
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
1312
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
1313
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
1314
|
+
SOFTWARE.
|
|
1315
|
+
"""
|
|
1316
|
+
|
|
1317
|
+
scope = kw.get("scope", scope)
|
|
1318
|
+
kind = kw.get("kind", kind)
|
|
1319
|
+
has_filter_names, params = self._prepare_filter_names(filter_names) # type: ignore[attr-defined]
|
|
1320
|
+
query = self._columns_query(schema, has_filter_names, scope, kind) # type: ignore[attr-defined]
|
|
1321
|
+
rows = list(connection.execute(query, params).mappings())
|
|
1322
|
+
|
|
1323
|
+
# dictionary with (name, ) if default search path or (schema, name)
|
|
1324
|
+
# as keys
|
|
1325
|
+
domains: Dict[tuple, dict] = {}
|
|
1326
|
+
"""
|
|
1327
|
+
TODO: fix these pg_collation errors in SQLA2
|
|
1328
|
+
domains = {
|
|
1329
|
+
((d["schema"], d["name"]) if not d["visible"] else (d["name"],)): d
|
|
1330
|
+
for d in self._load_domains( # type: ignore[attr-defined]
|
|
1331
|
+
connection, schema="*", info_cache=kw.get("info_cache")
|
|
1332
|
+
)
|
|
1333
|
+
}
|
|
1334
|
+
"""
|
|
1335
|
+
|
|
1336
|
+
# dictionary with (name, ) if default search path or (schema, name)
|
|
1337
|
+
# as keys
|
|
1338
|
+
enums = dict(
|
|
1339
|
+
(
|
|
1340
|
+
((rec["name"],), rec)
|
|
1341
|
+
if rec["visible"]
|
|
1342
|
+
else ((rec["schema"], rec["name"]), rec)
|
|
1343
|
+
)
|
|
1344
|
+
for rec in self._load_enums( # type: ignore[attr-defined]
|
|
1345
|
+
connection, schema="*", info_cache=kw.get("info_cache")
|
|
1346
|
+
)
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1349
|
+
columns = self._get_columns_info(rows, domains, enums, schema) # type: ignore[attr-defined]
|
|
1350
|
+
|
|
1351
|
+
return columns.items()
|
|
1352
|
+
|
|
1353
|
+
# fix for https://github.com/leonardovida/duckdb-sqlalchemy/issues/1128
|
|
1354
|
+
# (Overrides sqlalchemy method)
|
|
1355
|
+
@lru_cache()
|
|
1356
|
+
def _comment_query( # type: ignore[no-untyped-def]
|
|
1357
|
+
self, schema: str, has_filter_names: bool, scope: Any, kind: Any
|
|
1358
|
+
):
|
|
1359
|
+
if SQLALCHEMY_VERSION >= Version("2.0.36"):
|
|
1360
|
+
from sqlalchemy.dialects.postgresql import ( # type: ignore[attr-defined]
|
|
1361
|
+
pg_catalog,
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
if (
|
|
1365
|
+
hasattr(super(), "_kind_to_relkinds")
|
|
1366
|
+
and hasattr(super(), "_pg_class_filter_scope_schema")
|
|
1367
|
+
and hasattr(super(), "_pg_class_relkind_condition")
|
|
1368
|
+
):
|
|
1369
|
+
relkinds = getattr(super(), "_kind_to_relkinds")(kind)
|
|
1370
|
+
query = (
|
|
1371
|
+
select(
|
|
1372
|
+
pg_catalog.pg_class.c.relname,
|
|
1373
|
+
pg_catalog.pg_description.c.description,
|
|
1374
|
+
)
|
|
1375
|
+
.select_from(pg_catalog.pg_class)
|
|
1376
|
+
.outerjoin(
|
|
1377
|
+
pg_catalog.pg_description,
|
|
1378
|
+
sql.and_(
|
|
1379
|
+
pg_catalog.pg_class.c.oid
|
|
1380
|
+
== pg_catalog.pg_description.c.objoid,
|
|
1381
|
+
pg_catalog.pg_description.c.objsubid == 0,
|
|
1382
|
+
),
|
|
1383
|
+
)
|
|
1384
|
+
.where(getattr(super(), "_pg_class_relkind_condition")(relkinds))
|
|
1385
|
+
)
|
|
1386
|
+
query = self._pg_class_filter_scope_schema(query, schema, scope)
|
|
1387
|
+
if has_filter_names:
|
|
1388
|
+
query = query.where(
|
|
1389
|
+
pg_catalog.pg_class.c.relname.in_(bindparam("filter_names"))
|
|
1390
|
+
)
|
|
1391
|
+
return query
|
|
1392
|
+
else:
|
|
1393
|
+
if hasattr(super(), "_comment_query"):
|
|
1394
|
+
return getattr(super(), "_comment_query")(
|
|
1395
|
+
schema, has_filter_names, scope, kind
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
if SQLALCHEMY_VERSION >= Version("2.0.14"):
|
|
1400
|
+
from sqlalchemy import TryCast # type: ignore[attr-defined]
|
|
1401
|
+
|
|
1402
|
+
@compiles(TryCast, "duckdb") # type: ignore[misc]
|
|
1403
|
+
def visit_try_cast(
|
|
1404
|
+
instance: TryCast,
|
|
1405
|
+
compiler: Any,
|
|
1406
|
+
**kw: Any,
|
|
1407
|
+
) -> str:
|
|
1408
|
+
return "TRY_CAST({} AS {})".format(
|
|
1409
|
+
compiler.process(instance.clause, **kw),
|
|
1410
|
+
compiler.process(instance.typeclause, **kw),
|
|
1411
|
+
)
|