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/etl/data_surge.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# dbtk/etl/data_surge.py
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
import re
|
|
6
|
+
from typing import Iterable, Optional
|
|
7
|
+
|
|
8
|
+
from .base_surge import BaseSurge
|
|
9
|
+
from ..utils import batch_iterable
|
|
10
|
+
from ..record import Record
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DataSurge(BaseSurge):
|
|
16
|
+
"""
|
|
17
|
+
Handles bulk ETL operations by delegating to a stateful Table instance.
|
|
18
|
+
|
|
19
|
+
Note: The Table instance's state (self.values) is modified during processing.
|
|
20
|
+
Ensure the Table is not used concurrently by other operations or threads.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
table : Table
|
|
25
|
+
Table instance with column definitions and cursor
|
|
26
|
+
batch_size : int, optional
|
|
27
|
+
Number of records per batch (default: cursor.batch_size or 1000)
|
|
28
|
+
use_transaction : bool, optional
|
|
29
|
+
Wrap all operations in a transaction (default: False)
|
|
30
|
+
pass_through : bool, optional
|
|
31
|
+
Skip transformation and validation, using source data directly (default: False).
|
|
32
|
+
Only compatible for inserts. Not compatible with columns with database expressions `db_expr`
|
|
33
|
+
|
|
34
|
+
**When to use:**
|
|
35
|
+
|
|
36
|
+
- Database-to-database copies with identical schemas
|
|
37
|
+
- Pre-transformed data from upstream pipelines (already validated)
|
|
38
|
+
- Raw positional tuples pre-ordered for binding parameters
|
|
39
|
+
|
|
40
|
+
**What's skipped:** Field mapping, type coercion, default values, null value
|
|
41
|
+
handling, required field validation, and Table.set_values() overhead.
|
|
42
|
+
|
|
43
|
+
**Warning:** Do NOT use if records might have missing required fields, mismatched
|
|
44
|
+
field names, need type transformations, or data quality is uncertain. All database
|
|
45
|
+
constraints (primary keys, foreign keys) still apply.
|
|
46
|
+
|
|
47
|
+
Attributes
|
|
48
|
+
----------
|
|
49
|
+
total_read : int
|
|
50
|
+
Total rows read from source. 1-based (first row = 1). Includes
|
|
51
|
+
both loaded and skipped rows.
|
|
52
|
+
total_loaded : int
|
|
53
|
+
Total rows successfully loaded.
|
|
54
|
+
skipped : int
|
|
55
|
+
Total rows skipped due to missing required fields.
|
|
56
|
+
skip_details : dict
|
|
57
|
+
Skip tracking grouped by reason. Key is a frozenset of missing
|
|
58
|
+
required field names. Value is a dict with:
|
|
59
|
+
|
|
60
|
+
- ``count``: total rows skipped for this reason
|
|
61
|
+
- ``sample``: list of up to 20 1-based row numbers (for debugging)
|
|
62
|
+
|
|
63
|
+
Example::
|
|
64
|
+
|
|
65
|
+
{frozenset({'primary_name'}): {'count': 5, 'sample': [937887, 957847, ...]}}
|
|
66
|
+
|
|
67
|
+
Examples
|
|
68
|
+
--------
|
|
69
|
+
Standard ETL with transformation::
|
|
70
|
+
|
|
71
|
+
table = Table(..., cursor=cursor)
|
|
72
|
+
surge = DataSurge(table, batch_size=1000, use_transaction=True)
|
|
73
|
+
errors = surge.insert(records, raise_error=False)
|
|
74
|
+
|
|
75
|
+
Fast database-to-database copy (matching schemas)::
|
|
76
|
+
|
|
77
|
+
# Source and destination schemas match exactly
|
|
78
|
+
surge = DataSurge(dest_table, batch_size=5000, pass_through=True)
|
|
79
|
+
surge.insert(source_cursor)
|
|
80
|
+
|
|
81
|
+
Pre-transformed data (already validated)::
|
|
82
|
+
|
|
83
|
+
# Data already transformed and validated by upstream process
|
|
84
|
+
surge = DataSurge(table, pass_through=True)
|
|
85
|
+
surge.insert(validated_records)
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, table, batch_size: Optional[int] = None, use_transaction: bool = False, pass_through: bool = False):
|
|
89
|
+
"""
|
|
90
|
+
Initialize DataSurge for bulk operations.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
table: Table instance with schema metadata
|
|
94
|
+
batch_size: Number of records per batch
|
|
95
|
+
use_transaction: Use transaction for all operations (default: False)
|
|
96
|
+
pass_through: Skip transformation/validation for trusted data (default: False)
|
|
97
|
+
"""
|
|
98
|
+
super().__init__(table, batch_size=batch_size, pass_through=pass_through)
|
|
99
|
+
self.use_transaction = use_transaction
|
|
100
|
+
# Swap to positional parameters if named to save memory in bind parameters
|
|
101
|
+
self.table.force_positional()
|
|
102
|
+
self._sql_statements = {} # Only for modified SQL (merge temp table hack)
|
|
103
|
+
|
|
104
|
+
def get_sql(self, operation: str) -> str:
|
|
105
|
+
"""Get SQL for operation, checking local modifications first."""
|
|
106
|
+
if operation in self._sql_statements:
|
|
107
|
+
return self._sql_statements[operation]
|
|
108
|
+
return self.table.get_sql(operation)
|
|
109
|
+
|
|
110
|
+
def insert(self, records: Iterable[Record], raise_error: bool = True) -> int:
|
|
111
|
+
"""Perform bulk INSERT on records."""
|
|
112
|
+
return self.load(records, operation="insert", raise_error=raise_error)
|
|
113
|
+
|
|
114
|
+
def update(self, records: Iterable[Record], raise_error: bool = True) -> int:
|
|
115
|
+
"""Perform bulk UPDATE on records."""
|
|
116
|
+
return self.load(records, operation="update", raise_error=raise_error)
|
|
117
|
+
|
|
118
|
+
def delete(self, records: Iterable[Record], raise_error: bool = True) -> int:
|
|
119
|
+
"""Perform bulk DELETE on records."""
|
|
120
|
+
return self.load(records, operation="delete", raise_error=raise_error)
|
|
121
|
+
|
|
122
|
+
def merge(self, records: Iterable[Record], raise_error: bool = True) -> int:
|
|
123
|
+
"""
|
|
124
|
+
Perform bulk MERGE using either direct upsert or temporary table strategy.
|
|
125
|
+
"""
|
|
126
|
+
use_upsert = self.table._should_use_upsert()
|
|
127
|
+
|
|
128
|
+
if use_upsert:
|
|
129
|
+
return self.load(records, operation="merge", raise_error=raise_error)
|
|
130
|
+
else:
|
|
131
|
+
return self._merge_with_temp_table(records, raise_error)
|
|
132
|
+
|
|
133
|
+
def _execute_batches(self, records, operation, sql, raise_error):
|
|
134
|
+
"""Execute batches with executemany."""
|
|
135
|
+
errors = 0
|
|
136
|
+
skipped = 0
|
|
137
|
+
|
|
138
|
+
for batch in batch_iterable(records, self.batch_size):
|
|
139
|
+
batch_params = []
|
|
140
|
+
for record in batch:
|
|
141
|
+
params = self._transform_row(record)
|
|
142
|
+
if params is None:
|
|
143
|
+
skipped += 1
|
|
144
|
+
continue
|
|
145
|
+
batch_params.append(params)
|
|
146
|
+
|
|
147
|
+
if batch_params:
|
|
148
|
+
try:
|
|
149
|
+
self.cursor.executemany(sql, batch_params)
|
|
150
|
+
self.total_loaded += len(batch_params)
|
|
151
|
+
self.table.counts[operation] += len(batch_params)
|
|
152
|
+
except self.cursor.connection.driver.DatabaseError as e:
|
|
153
|
+
logger.error(f"{operation.capitalize()} batch failed for {self.table.name}: {str(e)}")
|
|
154
|
+
if raise_error:
|
|
155
|
+
raise
|
|
156
|
+
errors += len(batch_params)
|
|
157
|
+
|
|
158
|
+
return errors, skipped
|
|
159
|
+
|
|
160
|
+
def load(
|
|
161
|
+
self,
|
|
162
|
+
records: Iterable[Record],
|
|
163
|
+
operation: Optional[str] = None,
|
|
164
|
+
raise_error: bool = True,
|
|
165
|
+
) -> int:
|
|
166
|
+
"""
|
|
167
|
+
Core bulk execution using executemany() — shared path for insert/update/delete/merge.
|
|
168
|
+
"""
|
|
169
|
+
self.start_time = time.monotonic()
|
|
170
|
+
operation = (operation or self.operation).lower()
|
|
171
|
+
if operation not in ("insert", "update", "delete", "merge"):
|
|
172
|
+
msg = f"Invalid operation: {operation}"
|
|
173
|
+
logger.exception(msg)
|
|
174
|
+
raise ValueError(msg)
|
|
175
|
+
if self.pass_through:
|
|
176
|
+
if operation != "insert":
|
|
177
|
+
msg = f"Operation {operation} is not compatible with pass_through mode."
|
|
178
|
+
logger.exception(msg)
|
|
179
|
+
raise ValueError(msg)
|
|
180
|
+
expr_cols = self.table.db_expr_cols()
|
|
181
|
+
if expr_cols:
|
|
182
|
+
msg = f"Columns with `db_expr` are incompatible with pass_through mode. cols: {expr_cols}"
|
|
183
|
+
logger.exception(msg)
|
|
184
|
+
raise ValueError(msg)
|
|
185
|
+
|
|
186
|
+
self.operation = operation
|
|
187
|
+
sql = self.get_sql(operation)
|
|
188
|
+
|
|
189
|
+
if self.use_transaction:
|
|
190
|
+
with self.cursor.connection.transaction():
|
|
191
|
+
errors, skipped = self._execute_batches(records, operation, sql, raise_error)
|
|
192
|
+
else:
|
|
193
|
+
errors, skipped = self._execute_batches(records, operation, sql, raise_error)
|
|
194
|
+
|
|
195
|
+
self.skipped += skipped
|
|
196
|
+
self._log_summary()
|
|
197
|
+
return errors
|
|
198
|
+
|
|
199
|
+
def _merge_with_temp_table(self, records: Iterable[Record], raise_error: bool) -> int:
|
|
200
|
+
"""Perform bulk merge using temporary table (for databases requiring true MERGE)."""
|
|
201
|
+
records_list = list(records)
|
|
202
|
+
if not records_list:
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
db_type = self.cursor.connection.database_type
|
|
206
|
+
# Postgres and MySQL will
|
|
207
|
+
if db_type == 'oracle':
|
|
208
|
+
temp_name = re.sub(r'[^A-Z0-9]+', '_', f"GTT_{self.table.name.upper()}")
|
|
209
|
+
|
|
210
|
+
# Get column definitions from table
|
|
211
|
+
col_info = self.table.get_column_definitions(all_cols=True)
|
|
212
|
+
col_defs = [f"{col_name} {sql_type}" for col_name, _, _, _, _, sql_type in col_info]
|
|
213
|
+
col_defs_str = ', '.join(col_defs)
|
|
214
|
+
create_sql = f"CREATE GLOBAL TEMPORARY TABLE {temp_name} ({col_defs_str}) ON COMMIT PRESERVE ROWS"
|
|
215
|
+
|
|
216
|
+
if db_type == 'sqlserver':
|
|
217
|
+
temp_name = f"#{re.sub(r'[^A-Z0-9]+', '_', self.table.name.upper())}"
|
|
218
|
+
|
|
219
|
+
# Get column definitions from table
|
|
220
|
+
col_info = self.table.get_column_definitions()
|
|
221
|
+
col_defs = [f"[{col_name}] {sql_type} NULL" for col_name, _, _, _, _, sql_type in col_info]
|
|
222
|
+
col_defs_str = ', '.join(col_defs)
|
|
223
|
+
create_sql = f"CREATE TABLE {temp_name} ({col_defs_str})"
|
|
224
|
+
|
|
225
|
+
# Drop temp table if it exists from previous run, then create fresh
|
|
226
|
+
try:
|
|
227
|
+
self.cursor.execute(f"TRUNCATE TABLE {temp_name}")
|
|
228
|
+
table_exists = True
|
|
229
|
+
except self.cursor.connection.driver.DatabaseError:
|
|
230
|
+
# Table doesn't exist yet, which is fine
|
|
231
|
+
table_exists = False
|
|
232
|
+
|
|
233
|
+
if not table_exists:
|
|
234
|
+
self.cursor.execute(create_sql)
|
|
235
|
+
logger.debug(f"Created temp table: {create_sql}")
|
|
236
|
+
|
|
237
|
+
# Use temporary table for bulk insert
|
|
238
|
+
from .table import Table
|
|
239
|
+
temp_table = Table(
|
|
240
|
+
name=temp_name,
|
|
241
|
+
columns=self.table.columns,
|
|
242
|
+
cursor=self.cursor,
|
|
243
|
+
is_temp=True
|
|
244
|
+
)
|
|
245
|
+
temp_surge = DataSurge(temp_table, batch_size=self.batch_size)
|
|
246
|
+
errors = temp_surge.insert(records_list, raise_error=raise_error)
|
|
247
|
+
|
|
248
|
+
if errors:
|
|
249
|
+
if db_type == 'oracle':
|
|
250
|
+
self.cursor.execute(f"TRUNCATE TABLE {temp_name}")
|
|
251
|
+
self.cursor.connection.commit()
|
|
252
|
+
else:
|
|
253
|
+
self.cursor.execute(f"DROP TABLE {temp_name}")
|
|
254
|
+
return errors
|
|
255
|
+
|
|
256
|
+
# Transfer record fields from temp table to main table for proper merge column exclusion
|
|
257
|
+
self.table.calc_update_excludes(temp_table._record_fields)
|
|
258
|
+
|
|
259
|
+
merge_sql = self.table.get_sql('merge')
|
|
260
|
+
|
|
261
|
+
# Replace the USING clause to point to temp table and store modified version
|
|
262
|
+
modified_merge = re.sub(
|
|
263
|
+
r'USING\s*\(.*?\)\s*s',
|
|
264
|
+
f'USING {temp_name} s',
|
|
265
|
+
merge_sql,
|
|
266
|
+
flags=re.DOTALL
|
|
267
|
+
)
|
|
268
|
+
self._sql_statements['merge'] = modified_merge
|
|
269
|
+
logger.debug(f"Modified merge sql: {modified_merge}")
|
|
270
|
+
try:
|
|
271
|
+
if self.use_transaction:
|
|
272
|
+
with self.cursor.connection.transaction():
|
|
273
|
+
self.cursor.execute(self.get_sql('merge'))
|
|
274
|
+
else:
|
|
275
|
+
self.cursor.execute(self.get_sql('merge'))
|
|
276
|
+
|
|
277
|
+
loaded = len(records_list) - errors
|
|
278
|
+
self.table.counts['merge'] += loaded
|
|
279
|
+
logger.info(f"MERGE via temp table → {loaded:,} records into {self.table.name}")
|
|
280
|
+
except self.cursor.connection.driver.DatabaseError as e:
|
|
281
|
+
logger.error(f"Merge failed: {e}")
|
|
282
|
+
if raise_error:
|
|
283
|
+
raise
|
|
284
|
+
errors += len(records_list) - errors
|
|
285
|
+
finally:
|
|
286
|
+
try:
|
|
287
|
+
if db_type == 'oracle':
|
|
288
|
+
self.cursor.execute(f"TRUNCATE TABLE {temp_name}")
|
|
289
|
+
else:
|
|
290
|
+
self.cursor.execute(f"DROP TABLE {temp_name}")
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.warning(f"Failed to clear temp table {temp_name}: {e}")
|
|
293
|
+
|
|
294
|
+
return errors
|