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.
Files changed (50) hide show
  1. dbtk/__init__.py +54 -0
  2. dbtk/cli.py +169 -0
  3. dbtk/config.py +1194 -0
  4. dbtk/cursors.py +566 -0
  5. dbtk/database.py +959 -0
  6. dbtk/dbtk_sample.yml +90 -0
  7. dbtk/defaults.py +29 -0
  8. dbtk/etl/__init__.py +61 -0
  9. dbtk/etl/base_surge.py +257 -0
  10. dbtk/etl/bulk_surge.py +783 -0
  11. dbtk/etl/config_generators.py +458 -0
  12. dbtk/etl/data_surge.py +294 -0
  13. dbtk/etl/managers.py +734 -0
  14. dbtk/etl/table.py +1215 -0
  15. dbtk/etl/transforms/__init__.py +107 -0
  16. dbtk/etl/transforms/address.py +560 -0
  17. dbtk/etl/transforms/core.py +594 -0
  18. dbtk/etl/transforms/database.py +506 -0
  19. dbtk/etl/transforms/datetime.py +497 -0
  20. dbtk/etl/transforms/email.py +72 -0
  21. dbtk/etl/transforms/phone.py +670 -0
  22. dbtk/formats/__init__.py +18 -0
  23. dbtk/formats/edi.py +182 -0
  24. dbtk/logging_utils.py +310 -0
  25. dbtk/readers/__init__.py +26 -0
  26. dbtk/readers/base.py +644 -0
  27. dbtk/readers/csv.py +195 -0
  28. dbtk/readers/data_frame.py +119 -0
  29. dbtk/readers/excel.py +260 -0
  30. dbtk/readers/fixed_width.py +359 -0
  31. dbtk/readers/json.py +323 -0
  32. dbtk/readers/utils.py +394 -0
  33. dbtk/readers/xml.py +260 -0
  34. dbtk/record.py +710 -0
  35. dbtk/utils.py +537 -0
  36. dbtk/writers/__init__.py +46 -0
  37. dbtk/writers/base.py +718 -0
  38. dbtk/writers/csv.py +107 -0
  39. dbtk/writers/database.py +158 -0
  40. dbtk/writers/excel.py +1086 -0
  41. dbtk/writers/fixed_width.py +290 -0
  42. dbtk/writers/json.py +156 -0
  43. dbtk/writers/utils.py +41 -0
  44. dbtk/writers/xml.py +387 -0
  45. dbtk-0.8.0.dist-info/METADATA +305 -0
  46. dbtk-0.8.0.dist-info/RECORD +50 -0
  47. dbtk-0.8.0.dist-info/WHEEL +5 -0
  48. dbtk-0.8.0.dist-info/entry_points.txt +2 -0
  49. dbtk-0.8.0.dist-info/licenses/LICENSE.txt +7 -0
  50. 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