dbtk 0.8.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.
- dbtk/__init__.py +54 -0
- dbtk/cli.py +169 -0
- dbtk/config.py +1194 -0
- dbtk/cursors.py +566 -0
- dbtk/database.py +959 -0
- dbtk/dbtk_sample.yml +90 -0
- dbtk/defaults.py +29 -0
- dbtk/etl/__init__.py +61 -0
- dbtk/etl/base_surge.py +257 -0
- dbtk/etl/bulk_surge.py +783 -0
- dbtk/etl/config_generators.py +458 -0
- dbtk/etl/data_surge.py +294 -0
- dbtk/etl/managers.py +734 -0
- dbtk/etl/table.py +1215 -0
- dbtk/etl/transforms/__init__.py +107 -0
- dbtk/etl/transforms/address.py +560 -0
- dbtk/etl/transforms/core.py +594 -0
- dbtk/etl/transforms/database.py +506 -0
- dbtk/etl/transforms/datetime.py +497 -0
- dbtk/etl/transforms/email.py +72 -0
- dbtk/etl/transforms/phone.py +670 -0
- dbtk/formats/__init__.py +18 -0
- dbtk/formats/edi.py +182 -0
- dbtk/logging_utils.py +310 -0
- dbtk/readers/__init__.py +26 -0
- dbtk/readers/base.py +644 -0
- dbtk/readers/csv.py +195 -0
- dbtk/readers/data_frame.py +119 -0
- dbtk/readers/excel.py +260 -0
- dbtk/readers/fixed_width.py +359 -0
- dbtk/readers/json.py +323 -0
- dbtk/readers/utils.py +394 -0
- dbtk/readers/xml.py +260 -0
- dbtk/record.py +710 -0
- dbtk/utils.py +537 -0
- dbtk/writers/__init__.py +46 -0
- dbtk/writers/base.py +718 -0
- dbtk/writers/csv.py +107 -0
- dbtk/writers/database.py +158 -0
- dbtk/writers/excel.py +1086 -0
- dbtk/writers/fixed_width.py +290 -0
- dbtk/writers/json.py +156 -0
- dbtk/writers/utils.py +41 -0
- dbtk/writers/xml.py +387 -0
- dbtk-0.8.0.dist-info/METADATA +305 -0
- dbtk-0.8.0.dist-info/RECORD +50 -0
- dbtk-0.8.0.dist-info/WHEEL +5 -0
- dbtk-0.8.0.dist-info/entry_points.txt +2 -0
- dbtk-0.8.0.dist-info/licenses/LICENSE.txt +7 -0
- dbtk-0.8.0.dist-info/top_level.txt +1 -0
dbtk/cursors.py
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
# dbtk/cursors.py
|
|
2
|
+
"""
|
|
3
|
+
Cursor classes that wrap database cursors and provide different return types.
|
|
4
|
+
All cursors delegate to the underlying database cursor stored in _cursor.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Any, Optional, Iterator, Callable, Union
|
|
10
|
+
|
|
11
|
+
from .record import Record
|
|
12
|
+
from .utils import ParamStyle, process_sql_parameters, normalize_field_name
|
|
13
|
+
from .defaults import settings
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
__all__ = ['Cursor', 'PreparedStatement']
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PreparedStatement:
|
|
20
|
+
"""
|
|
21
|
+
A prepared SQL statement loaded from a file with cached parameter mapping.
|
|
22
|
+
|
|
23
|
+
The statement is read from file once and SQL parameter conversion is performed
|
|
24
|
+
once based on the cursor's paramstyle. The prepared statement can then be
|
|
25
|
+
executed multiple times efficiently. It retains a reference to the cursor,
|
|
26
|
+
so it can be used in the same way as a regular cursor (fetchone(), fetchmany(), etc.).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, cursor, query: Optional[str] = None, filename: Optional[Union[str, Path]] = None, encoding: Optional[str] = 'utf-8-sig'):
|
|
30
|
+
"""
|
|
31
|
+
Create a prepared statement from a SQL file.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
cursor: The cursor that will execute this statement
|
|
35
|
+
query: SQL query string (optional)
|
|
36
|
+
filename: Path to SQL file (relative to CWD)
|
|
37
|
+
encoding: File encoding (default: utf-8-sig)
|
|
38
|
+
"""
|
|
39
|
+
self.cursor = cursor
|
|
40
|
+
|
|
41
|
+
if filename:
|
|
42
|
+
self.filename = filename
|
|
43
|
+
# Read SQL from file
|
|
44
|
+
with open(filename, encoding=encoding) as f:
|
|
45
|
+
original_sql = f.read()
|
|
46
|
+
elif query is not None:
|
|
47
|
+
self.filename = None
|
|
48
|
+
original_sql = query
|
|
49
|
+
else:
|
|
50
|
+
raise ValueError('Must provide either query or filename')
|
|
51
|
+
|
|
52
|
+
# Transform SQL for cursor's paramstyle
|
|
53
|
+
self.sql, self.param_names = process_sql_parameters(
|
|
54
|
+
original_sql,
|
|
55
|
+
cursor.paramstyle
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def __iter__(self):
|
|
59
|
+
"""Make prepared statement iterable."""
|
|
60
|
+
return self.cursor.__iter__()
|
|
61
|
+
|
|
62
|
+
def __next__(self):
|
|
63
|
+
"""Iterator protocol."""
|
|
64
|
+
return self.cursor.__next__()
|
|
65
|
+
|
|
66
|
+
def execute(self, bind_vars: Optional[dict] = None) -> Any:
|
|
67
|
+
"""
|
|
68
|
+
Execute the prepared statement with the given parameters.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
bind_vars: Dictionary of named parameters
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Cursor if return_cursor=True, else None
|
|
75
|
+
"""
|
|
76
|
+
if bind_vars is None:
|
|
77
|
+
bind_vars = {}
|
|
78
|
+
try:
|
|
79
|
+
params = self.cursor.prepare_params(self.param_names, bind_vars)
|
|
80
|
+
return self.cursor.execute(self.sql, params)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
source = self.filename or '<query>'
|
|
83
|
+
logger.error(
|
|
84
|
+
f"Error executing prepared statement from {source}\n"
|
|
85
|
+
f"Transformed SQL: {self.sql}\n"
|
|
86
|
+
f"Parameters: {bind_vars}"
|
|
87
|
+
)
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
def __getattr__(self, key: str):
|
|
91
|
+
"""Delegate attribute access to underlying cursor."""
|
|
92
|
+
return getattr(self.cursor, key)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Cursor:
|
|
96
|
+
"""
|
|
97
|
+
Basic cursor that returns query results as lists.
|
|
98
|
+
|
|
99
|
+
This is the base class for all DBTK cursor types. It wraps database-specific cursor
|
|
100
|
+
objects and provides a consistent interface plus additional functionality like SQL
|
|
101
|
+
file execution, parameter conversion, and prepared statements.
|
|
102
|
+
|
|
103
|
+
Cursor returns Record objects, which provide flexible access via dictionary keys,
|
|
104
|
+
attributes, or integer indices.
|
|
105
|
+
|
|
106
|
+
Attributes
|
|
107
|
+
----------
|
|
108
|
+
connection : Database
|
|
109
|
+
The database connection this cursor belongs to
|
|
110
|
+
paramstyle : str
|
|
111
|
+
Parameter style of the underlying database ('qmark', 'named', etc.)
|
|
112
|
+
placeholder : str
|
|
113
|
+
Placeholder string for bind parameters (e.g., '?', ':1', etc.)
|
|
114
|
+
description
|
|
115
|
+
Column metadata from the last query (delegated to underlying cursor)
|
|
116
|
+
|
|
117
|
+
Note
|
|
118
|
+
----
|
|
119
|
+
Cursors delegate attribute access to the underlying database-specific cursor,
|
|
120
|
+
so all native cursor functionality is available.
|
|
121
|
+
|
|
122
|
+
Example
|
|
123
|
+
-------
|
|
124
|
+
::
|
|
125
|
+
|
|
126
|
+
# Usually created via Database.cursor()
|
|
127
|
+
cursor = db.cursor('list')
|
|
128
|
+
cursor.execute("SELECT id, name, email FROM users WHERE status = :status",
|
|
129
|
+
{'status': 'active'})
|
|
130
|
+
|
|
131
|
+
for row in cursor:
|
|
132
|
+
user_id, name, email = row # Plain list - index access only
|
|
133
|
+
print(f"{user_id}: {name} ({email})")
|
|
134
|
+
|
|
135
|
+
See Also
|
|
136
|
+
--------
|
|
137
|
+
Record : Flexible data structure supporting dict, attribute, and index access
|
|
138
|
+
"""
|
|
139
|
+
# Attributes that live on this class and are not delegated to the underlying cursor
|
|
140
|
+
_local_attrs = [
|
|
141
|
+
'connection', 'debug', 'return_cursor',
|
|
142
|
+
'placeholder', 'paramstyle', 'record_factory', 'batch_size',
|
|
143
|
+
'_cursor', '_row_factory_invalid', '_statement', '_bind_vars', '_bulk_method'
|
|
144
|
+
]
|
|
145
|
+
# Attributes that are allowed to be passed in from the connection/configuration layer
|
|
146
|
+
WRAPPER_SETTINGS = ('batch_size', 'debug', 'return_cursor', 'fast_executemany')
|
|
147
|
+
|
|
148
|
+
def __init__(self,
|
|
149
|
+
connection,
|
|
150
|
+
batch_size: Optional[int] = None,
|
|
151
|
+
debug: Optional[bool] = False,
|
|
152
|
+
return_cursor: Optional[bool] = False,
|
|
153
|
+
**kwargs):
|
|
154
|
+
"""
|
|
155
|
+
Initialize a cursor for database operations.
|
|
156
|
+
|
|
157
|
+
Parameters
|
|
158
|
+
----------
|
|
159
|
+
connection : Database
|
|
160
|
+
Database connection object
|
|
161
|
+
batch_size: int, optional
|
|
162
|
+
How many rows to process at a time when using executemany() or bulk operations in DataSurge
|
|
163
|
+
debug : bool, default False
|
|
164
|
+
Enable debug output showing queries and bind variables
|
|
165
|
+
return_cursor : bool, default False
|
|
166
|
+
If True, execute() returns the cursor for method chaining
|
|
167
|
+
**kwargs
|
|
168
|
+
Additional arguments passed to the underlying database cursor
|
|
169
|
+
|
|
170
|
+
Example
|
|
171
|
+
-------
|
|
172
|
+
::
|
|
173
|
+
|
|
174
|
+
# Typically created via Database.cursor()
|
|
175
|
+
cursor = db.cursor()
|
|
176
|
+
|
|
177
|
+
# With debug enabled
|
|
178
|
+
cursor = db.cursor(debug=True)
|
|
179
|
+
|
|
180
|
+
# With method chaining
|
|
181
|
+
cursor = db.cursor(return_cursor=True)
|
|
182
|
+
results = cursor.execute("SELECT * FROM users").fetchall()
|
|
183
|
+
"""
|
|
184
|
+
self.connection = connection
|
|
185
|
+
self.debug = debug
|
|
186
|
+
self.record_factory = None
|
|
187
|
+
self._row_factory_invalid = True
|
|
188
|
+
self.return_cursor = return_cursor
|
|
189
|
+
if batch_size is None:
|
|
190
|
+
batch_size = settings.get('default_batch_size', 1000)
|
|
191
|
+
self.batch_size = batch_size
|
|
192
|
+
self._statement = None # Stores statement locally if adapter doesn't
|
|
193
|
+
self._bind_vars = None # Stores bind vars locally if adapter doesn't
|
|
194
|
+
self._bulk_method = None # Allows us to override executemany if needed
|
|
195
|
+
# remove any kwargs not intended for the underlying cursor
|
|
196
|
+
filtered_kwargs = {key: val for key, val in kwargs.items() if key not in self.WRAPPER_SETTINGS}
|
|
197
|
+
# Create underlying cursor
|
|
198
|
+
try:
|
|
199
|
+
if hasattr(self.connection, '_connection'):
|
|
200
|
+
self._cursor = self.connection._connection.cursor(**filtered_kwargs)
|
|
201
|
+
else:
|
|
202
|
+
self._cursor = self.connection.cursor(**filtered_kwargs)
|
|
203
|
+
except Exception as e:
|
|
204
|
+
raise TypeError(f'First argument must be a database connection object: {e}')
|
|
205
|
+
|
|
206
|
+
# Handle fast_executemany configuration for pyodbc
|
|
207
|
+
if 'fast_executemany' in kwargs:
|
|
208
|
+
if hasattr(self._cursor, 'fast_executemany'):
|
|
209
|
+
self._cursor.fast_executemany = kwargs['fast_executemany']
|
|
210
|
+
elif hasattr(self.connection, 'driver_name') and self.connection.driver_name == 'pyodbc_sqlserver':
|
|
211
|
+
logger.info(
|
|
212
|
+
"pyodbc with SQL Server detected. Consider setting cursor: {fast_executemany: true} "
|
|
213
|
+
"in your connection config for better bulk insert performance. Note: fast_executemany "
|
|
214
|
+
"may cause MemoryError with TEXT/NVARCHAR(MAX)/JSON columns - use VARCHAR types instead."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Set parameter style info
|
|
218
|
+
self.paramstyle = self.connection.driver.paramstyle
|
|
219
|
+
if hasattr(self.connection, 'placeholder'):
|
|
220
|
+
self.placeholder = self.connection.placeholder
|
|
221
|
+
|
|
222
|
+
# Ensure arraysize exists (some adapters don't have it)
|
|
223
|
+
if not hasattr(self._cursor, 'arraysize'):
|
|
224
|
+
self.__dict__['arraysize'] = 1000
|
|
225
|
+
|
|
226
|
+
def __getattr__(self, key: str) -> Any:
|
|
227
|
+
"""Delegate attribute access to underlying cursor."""
|
|
228
|
+
if key == 'statement' and not hasattr(self._cursor, 'statement'):
|
|
229
|
+
return self._statement
|
|
230
|
+
if key == 'bind_vars' and not hasattr(self._cursor, 'bind_vars'):
|
|
231
|
+
return self._bind_vars
|
|
232
|
+
return getattr(self._cursor, key)
|
|
233
|
+
|
|
234
|
+
def __setattr__(self, key: str, value: Any) -> None:
|
|
235
|
+
"""Set attributes on this cursor or delegate to underlying cursor."""
|
|
236
|
+
if key in self._local_attrs:
|
|
237
|
+
self.__dict__[key] = value
|
|
238
|
+
else:
|
|
239
|
+
setattr(self._cursor, key, value)
|
|
240
|
+
|
|
241
|
+
def __dir__(self) -> List[str]:
|
|
242
|
+
"""Return available attributes."""
|
|
243
|
+
return list(set(
|
|
244
|
+
dir(self._cursor) +
|
|
245
|
+
dir(self.__class__) +
|
|
246
|
+
self._local_attrs
|
|
247
|
+
))
|
|
248
|
+
|
|
249
|
+
def __iter__(self) -> Iterator:
|
|
250
|
+
"""Make cursor iterable."""
|
|
251
|
+
if self._is_ready():
|
|
252
|
+
return self
|
|
253
|
+
|
|
254
|
+
def __next__(self) -> Any:
|
|
255
|
+
"""Iterator protocol."""
|
|
256
|
+
row = self.fetchone()
|
|
257
|
+
if row is not None:
|
|
258
|
+
return row
|
|
259
|
+
else:
|
|
260
|
+
raise StopIteration
|
|
261
|
+
|
|
262
|
+
def prepare_params(self, param_names: list,
|
|
263
|
+
bind_vars: dict,
|
|
264
|
+
paramstyle: str = None) -> Any:
|
|
265
|
+
"""
|
|
266
|
+
Convert named parameters to the format required by the cursor's paramstyle.
|
|
267
|
+
Used automatically by PreparedStatement, Table and DataSurge classes.
|
|
268
|
+
|
|
269
|
+
Parameters
|
|
270
|
+
----------
|
|
271
|
+
param_names : list of str
|
|
272
|
+
Ordered list of parameter names as they appear in the SQL statement.
|
|
273
|
+
Missing names default to ``None`` with a debug-level log message.
|
|
274
|
+
bind_vars : dict
|
|
275
|
+
Dictionary of named parameter values keyed by parameter name.
|
|
276
|
+
paramstyle : str, optional
|
|
277
|
+
Override the cursor's native paramstyle for this call.
|
|
278
|
+
Must be one of the values in :class:`dbtk.utils.ParamStyle`.
|
|
279
|
+
Ignored if not a recognised style.
|
|
280
|
+
|
|
281
|
+
Returns
|
|
282
|
+
-------
|
|
283
|
+
tuple
|
|
284
|
+
For positional styles (``qmark``, ``numeric``, ``format``):
|
|
285
|
+
values ordered to match ``param_names``.
|
|
286
|
+
dict
|
|
287
|
+
For named styles (``named``, ``pyformat``):
|
|
288
|
+
subset of ``bind_vars`` containing only the required parameters.
|
|
289
|
+
|
|
290
|
+
Example
|
|
291
|
+
-------
|
|
292
|
+
::
|
|
293
|
+
from dbtk.utils import ParamStyle, process_sql_parameters
|
|
294
|
+
|
|
295
|
+
# query with :named or %(pyformat)s parameters
|
|
296
|
+
sql = "SELECT * FROM warriors WHERE nation = :nation AND rank = COALESCE(:rank, rank)"
|
|
297
|
+
|
|
298
|
+
# query rewritten in cursor's parameter style and parameter names in order they appear
|
|
299
|
+
query, params_names = process_sql_parameters(sql, ParamStyle.get_positional_style(cur.paramstyle))
|
|
300
|
+
|
|
301
|
+
# missing parameter defaults to None, extra parameters are ignored
|
|
302
|
+
cur.prepare_params(params_names, {'nation': 'Fire Nation', 'nick_name': 'Sparky'})
|
|
303
|
+
('Fire Nation', None)
|
|
304
|
+
"""
|
|
305
|
+
missing = set(param_names) - set(bind_vars.keys())
|
|
306
|
+
paramstyle = paramstyle if paramstyle and paramstyle in ParamStyle.values() else self.paramstyle
|
|
307
|
+
if missing:
|
|
308
|
+
logger.debug(f"Parameters not provided, defaulting to None: {', '.join(missing)}")
|
|
309
|
+
if paramstyle in ParamStyle.positional_styles():
|
|
310
|
+
# Build tuple in param_names order
|
|
311
|
+
return tuple(bind_vars.get(name) for name in param_names)
|
|
312
|
+
else:
|
|
313
|
+
# Return dict with only the params we need
|
|
314
|
+
return {name: bind_vars.get(name) for name in param_names}
|
|
315
|
+
|
|
316
|
+
def _detect_bulk_method(self) -> Callable:
|
|
317
|
+
"""
|
|
318
|
+
Detect and return the fastest bulk execution method for this cursor.
|
|
319
|
+
|
|
320
|
+
Called once per cursor, on first executemany(). Stores in self._bulk_method.
|
|
321
|
+
"""
|
|
322
|
+
adapter = self.connection.driver.__name__
|
|
323
|
+
if adapter == 'psycopg2':
|
|
324
|
+
try:
|
|
325
|
+
from psycopg2.extras import execute_batch
|
|
326
|
+
# Return a bound dispatcher: execute_batch(cur, sql, argslist, page_size)
|
|
327
|
+
def psycopg_batch(cur, sql, argslist):
|
|
328
|
+
page_size=getattr(self, 'batch_size', 1000)
|
|
329
|
+
return execute_batch(cur, sql, argslist, page_size=page_size)
|
|
330
|
+
|
|
331
|
+
logger.debug("Cursor upgraded: executemany → psycopg2.extras.execute_batch")
|
|
332
|
+
return psycopg_batch
|
|
333
|
+
except ImportError:
|
|
334
|
+
logger.debug("psycopg2.extras not available — using native executemany")
|
|
335
|
+
|
|
336
|
+
# Fallback for everything else (SQLite, MySQL, etc.)
|
|
337
|
+
return lambda cur, sql, argslist: cur.executemany(sql, argslist)
|
|
338
|
+
|
|
339
|
+
def _create_record_factory(self) -> None:
|
|
340
|
+
"""Create Record subclass with original column names from description."""
|
|
341
|
+
self._row_factory_invalid = False
|
|
342
|
+
|
|
343
|
+
# Get original column names from description (no transformation)
|
|
344
|
+
if not self.description:
|
|
345
|
+
original_columns = []
|
|
346
|
+
else:
|
|
347
|
+
original_columns = [col[0] for col in self.description]
|
|
348
|
+
|
|
349
|
+
# Create dynamic Record subclass and set fields
|
|
350
|
+
# set_fields() will handle normalization automatically
|
|
351
|
+
RecordClass = type('Record', (Record,), {})
|
|
352
|
+
RecordClass.set_fields(original_columns)
|
|
353
|
+
self.record_factory = RecordClass
|
|
354
|
+
|
|
355
|
+
def columns(self, normalized: bool = False) -> List[str]:
|
|
356
|
+
"""
|
|
357
|
+
Return list of column names.
|
|
358
|
+
|
|
359
|
+
Parameters
|
|
360
|
+
----------
|
|
361
|
+
normalized : bool, default False
|
|
362
|
+
If True, return normalized column names (sanitized for Python attributes).
|
|
363
|
+
If False, return original column names from database.
|
|
364
|
+
|
|
365
|
+
Returns
|
|
366
|
+
-------
|
|
367
|
+
List[str]
|
|
368
|
+
Column names in order
|
|
369
|
+
|
|
370
|
+
Example
|
|
371
|
+
-------
|
|
372
|
+
::
|
|
373
|
+
|
|
374
|
+
cursor.execute("SELECT 'First Name', 'User ID' FROM ...")
|
|
375
|
+
cursor.columns() # ['First Name', 'User ID']
|
|
376
|
+
cursor.columns(normalized=True) # ['first_name', 'user_id']
|
|
377
|
+
"""
|
|
378
|
+
if not self.description:
|
|
379
|
+
return []
|
|
380
|
+
|
|
381
|
+
if normalized:
|
|
382
|
+
# Return normalized column names
|
|
383
|
+
return [normalize_field_name(c[0]) for c in self.description]
|
|
384
|
+
else:
|
|
385
|
+
# Return original column names
|
|
386
|
+
return [c[0] for c in self.description]
|
|
387
|
+
|
|
388
|
+
def _is_ready(self) -> bool:
|
|
389
|
+
"""Check if ready and update record factory if columns changed."""
|
|
390
|
+
if self._cursor.description is None:
|
|
391
|
+
raise Exception('Query has not been run or did not succeed.')
|
|
392
|
+
elif self.record_factory is None:
|
|
393
|
+
self._create_record_factory()
|
|
394
|
+
elif self._row_factory_invalid:
|
|
395
|
+
# Check if columns have changed since last query
|
|
396
|
+
if hasattr(self.record_factory, '_fields'):
|
|
397
|
+
# Get current original column names from description
|
|
398
|
+
current_columns = [col[0] for col in self.description] if self.description else []
|
|
399
|
+
if self.record_factory._fields != current_columns:
|
|
400
|
+
self._create_record_factory()
|
|
401
|
+
else:
|
|
402
|
+
self._row_factory_invalid = False
|
|
403
|
+
else:
|
|
404
|
+
self._create_record_factory()
|
|
405
|
+
return True
|
|
406
|
+
|
|
407
|
+
def execute(self, query: str, bind_vars: tuple = ()) -> None:
|
|
408
|
+
"""Execute a database query."""
|
|
409
|
+
self._row_factory_invalid = True
|
|
410
|
+
|
|
411
|
+
if self.debug:
|
|
412
|
+
logger.debug(f'Query:\n{query}')
|
|
413
|
+
logger.debug(f'Bind vars:\n{bind_vars}')
|
|
414
|
+
|
|
415
|
+
# Store statement and bind_vars locally if the adapter doesn't
|
|
416
|
+
if not hasattr(self._cursor, 'statement'):
|
|
417
|
+
self.__dict__['_statement'] = query
|
|
418
|
+
if not hasattr(self._cursor, 'bind_vars'):
|
|
419
|
+
self.__dict__['_bind_vars'] = bind_vars
|
|
420
|
+
|
|
421
|
+
# some adapters return a cursor instead of the Database API specified None
|
|
422
|
+
_ = self._cursor.execute(query, bind_vars)
|
|
423
|
+
if self.return_cursor:
|
|
424
|
+
return self
|
|
425
|
+
else:
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
def execute_file(self, filename: Union[str, Path], bind_vars: Optional[dict] = None, **kwargs) -> Any:
|
|
429
|
+
"""
|
|
430
|
+
Execute SQL query from a file with named parameter substitution.
|
|
431
|
+
|
|
432
|
+
This is a convenience method for one-off queries. For queries that will be
|
|
433
|
+
executed multiple times, use prepare_file() instead for better performance.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
filename: Path to SQL file (relative to CWD)
|
|
437
|
+
bind_vars: Dictionary of named parameters
|
|
438
|
+
**kwargs:
|
|
439
|
+
encoding: File encoding (default: utf-8-sig)
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
Cursor if return_cursor=True, else None
|
|
443
|
+
|
|
444
|
+
Example:
|
|
445
|
+
cursor.execute_file('queries/get_user.sql', {'user_id': 123})
|
|
446
|
+
"""
|
|
447
|
+
encoding = kwargs.get('encoding', 'utf-8-sig')
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
# Read SQL from file
|
|
451
|
+
with open(filename, encoding=encoding) as f:
|
|
452
|
+
sql = f.read()
|
|
453
|
+
|
|
454
|
+
# Transform SQL for this cursor's paramstyle
|
|
455
|
+
from .database import ParamStyle
|
|
456
|
+
transformed_sql, param_names = process_sql_parameters(sql, self.paramstyle)
|
|
457
|
+
|
|
458
|
+
# Prepare parameters
|
|
459
|
+
if bind_vars:
|
|
460
|
+
params = self.prepare_params(param_names, bind_vars)
|
|
461
|
+
else:
|
|
462
|
+
params = None
|
|
463
|
+
|
|
464
|
+
return self.execute(transformed_sql, params)
|
|
465
|
+
|
|
466
|
+
except Exception as e:
|
|
467
|
+
statement = locals().get('transformed_sql', 'N/A')
|
|
468
|
+
logger.error(
|
|
469
|
+
f"Error executing SQL file: {filename}\n"
|
|
470
|
+
f"Transformed SQL: {statement}\n"
|
|
471
|
+
f"Parameters: {bind_vars}"
|
|
472
|
+
)
|
|
473
|
+
raise
|
|
474
|
+
|
|
475
|
+
def prepare_file(self, filename: Union[str, Path], encoding: str = 'utf-8-sig') -> PreparedStatement:
|
|
476
|
+
"""
|
|
477
|
+
Prepare a SQL statement from a file for repeated execution.
|
|
478
|
+
|
|
479
|
+
The SQL file is read once and parameter conversion is performed once.
|
|
480
|
+
The returned PreparedStatement can be executed multiple times efficiently.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
filename: Path to SQL file (relative to CWD)
|
|
484
|
+
encoding: File encoding (default: utf-8-sig)
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
PreparedStatement object
|
|
488
|
+
|
|
489
|
+
Example
|
|
490
|
+
-------
|
|
491
|
+
::
|
|
492
|
+
|
|
493
|
+
stmt = cursor.prepare_file('queries/insert_user.sql')
|
|
494
|
+
for user in users:
|
|
495
|
+
stmt.execute({'user_id': user.id, 'name': user.name})
|
|
496
|
+
"""
|
|
497
|
+
return PreparedStatement(self, filename=filename, encoding=encoding)
|
|
498
|
+
|
|
499
|
+
def executemany(self, query: str, bind_vars: List[tuple]) -> None:
|
|
500
|
+
"""Execute a query against multiple parameter sets."""
|
|
501
|
+
self._row_factory_invalid = True
|
|
502
|
+
|
|
503
|
+
if self.debug:
|
|
504
|
+
logger.debug(f'Executemany - Query:\n{query}')
|
|
505
|
+
logger.debug(f'Bind vars (first row):\n{bind_vars[0]}')
|
|
506
|
+
|
|
507
|
+
# Store statement and bind_vars (first row only) locally if the adapter doesn't
|
|
508
|
+
if not hasattr(self._cursor, 'statement'):
|
|
509
|
+
self.__dict__['_statement'] = query
|
|
510
|
+
if not hasattr(self._cursor, 'bind_vars'):
|
|
511
|
+
self.__dict__['_bind_vars'] = bind_vars[0]
|
|
512
|
+
|
|
513
|
+
if self._bulk_method is None:
|
|
514
|
+
# Detect and cache the fastest bulk execution method
|
|
515
|
+
self._bulk_method = self._detect_bulk_method()
|
|
516
|
+
|
|
517
|
+
_ = self._bulk_method(self._cursor, query, bind_vars)
|
|
518
|
+
if self.return_cursor:
|
|
519
|
+
return self
|
|
520
|
+
else:
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
def selectinto(self, query: str, bind_vars: tuple = ()) -> Any:
|
|
524
|
+
"""Execute query that must return exactly one row."""
|
|
525
|
+
self.execute(query, bind_vars)
|
|
526
|
+
rows = self.fetchmany(2)
|
|
527
|
+
|
|
528
|
+
if len(rows) == 0:
|
|
529
|
+
raise self.connection.driver.DatabaseError('No Data Found.')
|
|
530
|
+
elif len(rows) > 1:
|
|
531
|
+
raise self.connection.driver.DatabaseError(
|
|
532
|
+
'selectinto() must return one and only one row.'
|
|
533
|
+
)
|
|
534
|
+
else:
|
|
535
|
+
return rows[0]
|
|
536
|
+
|
|
537
|
+
def fetchone(self) -> Optional[Any]:
|
|
538
|
+
"""Fetch the next row."""
|
|
539
|
+
if self._is_ready():
|
|
540
|
+
row = self._cursor.fetchone()
|
|
541
|
+
if row:
|
|
542
|
+
return self.record_factory(*row)
|
|
543
|
+
return None
|
|
544
|
+
|
|
545
|
+
def fetchmany(self, size: Optional[int] = None) -> List[Any]:
|
|
546
|
+
"""Fetch the next set of rows."""
|
|
547
|
+
if size is None:
|
|
548
|
+
size = self._cursor.arraysize
|
|
549
|
+
|
|
550
|
+
if self._is_ready():
|
|
551
|
+
return [
|
|
552
|
+
self.record_factory(*row)
|
|
553
|
+
for row in self._cursor.fetchmany(size)
|
|
554
|
+
]
|
|
555
|
+
return []
|
|
556
|
+
|
|
557
|
+
def fetchall(self) -> List[Any]:
|
|
558
|
+
"""Fetch all remaining rows."""
|
|
559
|
+
if self._is_ready():
|
|
560
|
+
return [
|
|
561
|
+
self.record_factory(*row)
|
|
562
|
+
for row in self._cursor.fetchall()
|
|
563
|
+
]
|
|
564
|
+
return []
|
|
565
|
+
|
|
566
|
+
|