SQLDbWrpr 5.1.0__tar.gz → 5.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SQLDbWrpr
3
- Version: 5.1.0
3
+ Version: 5.2.0
4
4
  Summary: Wrapper for importing CSV and Text files into MySQL and Postgress
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE.txt
@@ -65,7 +65,7 @@ When both are supplied, `p_db_structure` takes precedence for backward compatibi
65
65
  - Import CSV data from files or in-memory rows, including single-volume and numbered multi-volume files.
66
66
  - Export full tables or custom SQL query results to CSV, including optional multi-volume exports.
67
67
  - **Database Support**: Includes MySQL and PostgreSQL wrappers with dialect-specific SQL rendering.
68
- - **User and Permission Management**: Create MySQL users and grant database rights.
68
+ - **User and Permission Management**: Create and delete MySQL users or PostgreSQL login roles, and grant backend-specific database or table rights.
69
69
  - **Batch Processing**: Configure import batch sizes for larger CSV loads.
70
70
 
71
71
  ### Project Structure
@@ -83,6 +83,17 @@ When both are supplied, `p_db_structure` takes precedence for backward compatibi
83
83
  pip install SqlDbWrpr
84
84
  ```
85
85
 
86
+ ### Running the Tests
87
+
88
+ The focused database tests use temporary MySQL 8.0 and PostgreSQL 16 Docker containers. Ensure Docker is running, then install the development dependencies and run the test suite:
89
+
90
+ ```powershell
91
+ poetry install --with dev
92
+ poetry run pytest
93
+ ```
94
+
95
+ The test fixtures wait for each database to become ready and remove the temporary containers after the test session.
96
+
86
97
  ### Quick Start With A Legacy Structure
87
98
 
88
99
  ```python
@@ -167,6 +178,115 @@ db = PostgreSQL(
167
178
  db.import_csv("Users", p_csv_db=[("Username",), ("alice",)], p_vol_type="Single")
168
179
  ```
169
180
 
181
+ ## Function Reference
182
+
183
+ This section documents the callable API in `src/sqldbwrpr/sqldbwrpr.py`.
184
+
185
+ ### `SchemaSourceError`
186
+
187
+ - `SchemaSourceError(ValueError)`: Raised when no usable schema source is provided to initialize a wrapper (`p_db_structure`, `p_sqlalchemy_metadata`, or `p_sqlalchemy_base.metadata`).
188
+
189
+ ### `SQLDbWrpr` (base wrapper)
190
+
191
+ - `__init__(p_host_name, p_user_name, p_password, p_recreate_db, p_db_name, p_db_structure, p_sqlalchemy_base, p_sqlalchemy_metadata, p_batch_size, p_bar_len, p_msg_width, p_verbose, p_db_port, p_ssl_ca, p_ssl_key, p_ssl_cert)`
192
+ - Initializes shared wrapper state, resolves schema source, configures import/export behavior, and precomputes char vs non-char field maps.
193
+ - Schema resolution order: explicit `p_db_structure` first, then SQLAlchemy metadata, else raises `SchemaSourceError`.
194
+ - `close()`
195
+ - Closes the active DB connection if present.
196
+ - `build_column_sql(p_field_name, p_field_type, p_field_params, p_field_comment)`
197
+ - Builds the column fragment for `CREATE TABLE` in the active dialect (base implementation is MySQL-oriented).
198
+ - Applies `AUTO_INCREMENT`, `UNSIGNED`, `NOT NULL`, `ZEROFILL`, defaults, and comments based on field parameters.
199
+ - `build_index_sql(p_table_name, p_idx_name, p_idx_fields, p_unique=False)`
200
+ - Builds an index clause with sort directions from legacy index metadata.
201
+ - `build_insert_sql(p_table_name, p_header, p_replace=False)`
202
+ - Builds an `INSERT` or `REPLACE` statement with backend parameter placeholders.
203
+ - `param_placeholder()`
204
+ - Returns the DB-API parameter token used by execution (`%s`).
205
+ - `quote_identifier(p_identifier)`
206
+ - Quotes SQL identifiers when a backend quote character is configured; otherwise returns plain identifier text.
207
+ - `quote_identifier_list(p_identifiers)`
208
+ - Quotes and joins a list of identifiers using commas.
209
+ - `render_default_sql(p_field_type, p_default_value)`
210
+ - Renders a `DEFAULT` clause, quoting string-like defaults for char/varchar types.
211
+ - `render_field_type(p_field_type, p_field_params)`
212
+ - Renders legacy type arrays into SQL type declarations (e.g., `varchar(n)`, `decimal(p,s)`).
213
+ - `create_db()`
214
+ - Base database recreation flow: drops an existing DB and creates/selects a fresh one.
215
+ - `create_tables()`
216
+ - Validates legacy schema metadata and creates tables, keys, indexes, and constraints in dependency order.
217
+ - Splits table creation and post-create operations where backend rules require deferred statements.
218
+ - `create_users(p_admin_user, p_new_users)`
219
+ - Creates MySQL users if they do not already exist.
220
+ - `delete_users(p_admin_user, p_del_users)`
221
+ - Drops MySQL users that exist in `mysql.user`.
222
+ - `_err_broken_rec(p_sql_str, p_csv_db_slice)`
223
+ - Retry helper used during imports to isolate/log failing rows and stop on first unrecoverable record.
224
+ - `export_to_csv(p_csv_path, p_table_name, p_delimiter="|", p_strip_chars="", p__vol_size=0, p_sql_query="")`
225
+ - Exports table/query results to CSV in single-file or multi-volume mode.
226
+ - Multi-volume mode chunks output by record count and generates numbered files.
227
+ - `get_db_field_types()`
228
+ - Populates per-table lists of character vs non-character columns for type-sensitive import handling.
229
+ - `grant_rights(p_admin_user, p_user_rights)`
230
+ - Grants MySQL rights and corresponding grant-option privileges according to supplied rights tuples.
231
+ - `import_csv(p_table_name, p_csv_file_name="", p_key="", p_header="", p_del_head=False, p_csv_db="", p_csv_corr_str_file_name="", p_vol_type="Multi", p_verbose=False, p_replace=False)`
232
+ - Imports CSV data from file(s) or in-memory rows.
233
+ - Supports single- and multi-volume input, optional header override/removal, correction-string preprocessing, type conversion, date normalization, and batched insert/replace.
234
+ - `import_and_split_csv(p_split_struct, p_data, p_header="", p_insert_header=False, p_verbose=False, p_debug=False)`
235
+ - Splits an input dataset into multiple destination table payloads using declarative field mappings and transform commands, then imports each generated dataset.
236
+ - `from_sqlalchemy_metadata(p_sqlalchemy_metadata)` (`@classmethod`)
237
+ - Converts SQLAlchemy `MetaData` (sorted tables) to the legacy `db_structure` dictionary.
238
+ - `resolve_db_structure(p_db_structure=None, p_sqlalchemy_base=None, p_sqlalchemy_metadata=None)` (`@staticmethod`)
239
+ - Central schema source resolver used by constructors; raises `SchemaSourceError` when none are provided.
240
+ - `_action_to_legacy_code(p_action)` (`@staticmethod`)
241
+ - Maps SQLAlchemy FK action text (e.g., `CASCADE`, `SET NULL`) to legacy action codes (`C`, `N`, etc.).
242
+ - `_build_default_field_params()` (`@staticmethod`)
243
+ - Produces the baseline legacy field-parameter block used during SQLAlchemy conversion.
244
+ - `_column_type_to_legacy(p_column)` (`@staticmethod`)
245
+ - Converts a SQLAlchemy column type object into the legacy type-array format.
246
+ - `_column_to_legacy_field(p_column)` (`@staticmethod`)
247
+ - Converts one SQLAlchemy column into a legacy field definition, including nullability, autoincrement, defaults, and comments.
248
+ - `_set_foreign_keys(p_table, p_table_structure)` (`@staticmethod`)
249
+ - Writes legacy foreign-key metadata onto converted table fields.
250
+ - `_set_indexes(p_table, p_table_structure)` (`@staticmethod`)
251
+ - Writes legacy index metadata for converted table fields.
252
+ - `_set_primary_key(p_table, p_table_structure)` (`@staticmethod`)
253
+ - Marks primary-key columns in converted legacy table definitions.
254
+ - `_table_to_legacy_structure(p_table)` (`@staticmethod`)
255
+ - Converts an entire SQLAlchemy table into a legacy table structure and enriches it with keys/indexes.
256
+ - `_print_err_msg(p_err, p_msg="")` (`@staticmethod`)
257
+ - Formats and prints database error details before termination paths.
258
+
259
+ ### `MySQL(SQLDbWrpr)`
260
+
261
+ - `__init__(p_host_name, p_user_name, p_password, p_user_rights, p_recreate_db, p_db_name, p_db_structure, p_sqlalchemy_base, p_sqlalchemy_metadata, p_batch_size, p_bar_len, p_msg_width, p_verbose, p_admin_username, p_admin_user_password, p_db_port, **kwargs)`
262
+ - Opens a MySQL connection and cursor, optionally recreates database/tables, or selects the configured DB.
263
+ - Includes fallback logic to create and grant rights to missing users when valid admin credentials and rights data are supplied.
264
+
265
+ ### `PostgreSQL(SQLDbWrpr)`
266
+
267
+ - `__init__(p_host_name, p_user_name, p_password, p_recreate_db, p_db_name, p_db_structure, p_sqlalchemy_base, p_sqlalchemy_metadata, p_batch_size, p_bar_len, p_msg_width, p_verbose, p_db_port, p_maintenance_db, **kwargs)`
268
+ - Configures PostgreSQL-specific behavior (quoted identifiers, non-inline indexes), opens connection, and optionally recreates DB/tables.
269
+ - `build_column_sql(p_field_name, p_field_type, p_field_params, p_field_comment)`
270
+ - PostgreSQL-specific column SQL renderer; handles non-AI `NOT NULL` and defaults.
271
+ - `build_index_sql(p_table_name, p_idx_name, p_idx_fields, p_unique=False)`
272
+ - Builds PostgreSQL `CREATE INDEX`/`CREATE UNIQUE INDEX` statements.
273
+ - `build_insert_sql(p_table_name, p_header, p_replace=False)`
274
+ - Builds PostgreSQL `INSERT`; when `p_replace=True`, builds `ON CONFLICT` upsert/no-op behavior from primary-key metadata.
275
+ - `create_db()`
276
+ - PostgreSQL DB recreation flow via `pg_database` lookup, optional forced drop, create, and reconnect.
277
+ - `create_users(p_admin_user, p_new_users)`
278
+ - Creates PostgreSQL login roles that do not already exist, using the supplied passwords.
279
+ - `delete_users(p_admin_user, p_del_users)`
280
+ - Drops PostgreSQL roles that currently exist.
281
+ - `grant_rights(p_admin_user, p_user_rights)`
282
+ - Grants database-level rights when the table target is `*`; otherwise grants table-level rights. PostgreSQL ignores the host field in each rights entry.
283
+ - `render_default_sql(p_field_type, p_default_value)`
284
+ - Renders PostgreSQL defaults with proper escaping for string values.
285
+ - `render_field_type(p_field_type, p_field_params)`
286
+ - Maps legacy types to PostgreSQL types, including `SERIAL`/`BIGSERIAL` for autoincrement fields.
287
+ - `_connect(p_db_name)`
288
+ - Internal connector helper returning a `psycopg` connection for the requested database.
289
+
170
290
  ______________________________________________________________________
171
291
 
172
292
  ## Updating ReleaseNotes Instructions
@@ -29,7 +29,7 @@ When both are supplied, `p_db_structure` takes precedence for backward compatibi
29
29
  - Import CSV data from files or in-memory rows, including single-volume and numbered multi-volume files.
30
30
  - Export full tables or custom SQL query results to CSV, including optional multi-volume exports.
31
31
  - **Database Support**: Includes MySQL and PostgreSQL wrappers with dialect-specific SQL rendering.
32
- - **User and Permission Management**: Create MySQL users and grant database rights.
32
+ - **User and Permission Management**: Create and delete MySQL users or PostgreSQL login roles, and grant backend-specific database or table rights.
33
33
  - **Batch Processing**: Configure import batch sizes for larger CSV loads.
34
34
 
35
35
  ### Project Structure
@@ -47,6 +47,17 @@ When both are supplied, `p_db_structure` takes precedence for backward compatibi
47
47
  pip install SqlDbWrpr
48
48
  ```
49
49
 
50
+ ### Running the Tests
51
+
52
+ The focused database tests use temporary MySQL 8.0 and PostgreSQL 16 Docker containers. Ensure Docker is running, then install the development dependencies and run the test suite:
53
+
54
+ ```powershell
55
+ poetry install --with dev
56
+ poetry run pytest
57
+ ```
58
+
59
+ The test fixtures wait for each database to become ready and remove the temporary containers after the test session.
60
+
50
61
  ### Quick Start With A Legacy Structure
51
62
 
52
63
  ```python
@@ -131,6 +142,115 @@ db = PostgreSQL(
131
142
  db.import_csv("Users", p_csv_db=[("Username",), ("alice",)], p_vol_type="Single")
132
143
  ```
133
144
 
145
+ ## Function Reference
146
+
147
+ This section documents the callable API in `src/sqldbwrpr/sqldbwrpr.py`.
148
+
149
+ ### `SchemaSourceError`
150
+
151
+ - `SchemaSourceError(ValueError)`: Raised when no usable schema source is provided to initialize a wrapper (`p_db_structure`, `p_sqlalchemy_metadata`, or `p_sqlalchemy_base.metadata`).
152
+
153
+ ### `SQLDbWrpr` (base wrapper)
154
+
155
+ - `__init__(p_host_name, p_user_name, p_password, p_recreate_db, p_db_name, p_db_structure, p_sqlalchemy_base, p_sqlalchemy_metadata, p_batch_size, p_bar_len, p_msg_width, p_verbose, p_db_port, p_ssl_ca, p_ssl_key, p_ssl_cert)`
156
+ - Initializes shared wrapper state, resolves schema source, configures import/export behavior, and precomputes char vs non-char field maps.
157
+ - Schema resolution order: explicit `p_db_structure` first, then SQLAlchemy metadata, else raises `SchemaSourceError`.
158
+ - `close()`
159
+ - Closes the active DB connection if present.
160
+ - `build_column_sql(p_field_name, p_field_type, p_field_params, p_field_comment)`
161
+ - Builds the column fragment for `CREATE TABLE` in the active dialect (base implementation is MySQL-oriented).
162
+ - Applies `AUTO_INCREMENT`, `UNSIGNED`, `NOT NULL`, `ZEROFILL`, defaults, and comments based on field parameters.
163
+ - `build_index_sql(p_table_name, p_idx_name, p_idx_fields, p_unique=False)`
164
+ - Builds an index clause with sort directions from legacy index metadata.
165
+ - `build_insert_sql(p_table_name, p_header, p_replace=False)`
166
+ - Builds an `INSERT` or `REPLACE` statement with backend parameter placeholders.
167
+ - `param_placeholder()`
168
+ - Returns the DB-API parameter token used by execution (`%s`).
169
+ - `quote_identifier(p_identifier)`
170
+ - Quotes SQL identifiers when a backend quote character is configured; otherwise returns plain identifier text.
171
+ - `quote_identifier_list(p_identifiers)`
172
+ - Quotes and joins a list of identifiers using commas.
173
+ - `render_default_sql(p_field_type, p_default_value)`
174
+ - Renders a `DEFAULT` clause, quoting string-like defaults for char/varchar types.
175
+ - `render_field_type(p_field_type, p_field_params)`
176
+ - Renders legacy type arrays into SQL type declarations (e.g., `varchar(n)`, `decimal(p,s)`).
177
+ - `create_db()`
178
+ - Base database recreation flow: drops an existing DB and creates/selects a fresh one.
179
+ - `create_tables()`
180
+ - Validates legacy schema metadata and creates tables, keys, indexes, and constraints in dependency order.
181
+ - Splits table creation and post-create operations where backend rules require deferred statements.
182
+ - `create_users(p_admin_user, p_new_users)`
183
+ - Creates MySQL users if they do not already exist.
184
+ - `delete_users(p_admin_user, p_del_users)`
185
+ - Drops MySQL users that exist in `mysql.user`.
186
+ - `_err_broken_rec(p_sql_str, p_csv_db_slice)`
187
+ - Retry helper used during imports to isolate/log failing rows and stop on first unrecoverable record.
188
+ - `export_to_csv(p_csv_path, p_table_name, p_delimiter="|", p_strip_chars="", p__vol_size=0, p_sql_query="")`
189
+ - Exports table/query results to CSV in single-file or multi-volume mode.
190
+ - Multi-volume mode chunks output by record count and generates numbered files.
191
+ - `get_db_field_types()`
192
+ - Populates per-table lists of character vs non-character columns for type-sensitive import handling.
193
+ - `grant_rights(p_admin_user, p_user_rights)`
194
+ - Grants MySQL rights and corresponding grant-option privileges according to supplied rights tuples.
195
+ - `import_csv(p_table_name, p_csv_file_name="", p_key="", p_header="", p_del_head=False, p_csv_db="", p_csv_corr_str_file_name="", p_vol_type="Multi", p_verbose=False, p_replace=False)`
196
+ - Imports CSV data from file(s) or in-memory rows.
197
+ - Supports single- and multi-volume input, optional header override/removal, correction-string preprocessing, type conversion, date normalization, and batched insert/replace.
198
+ - `import_and_split_csv(p_split_struct, p_data, p_header="", p_insert_header=False, p_verbose=False, p_debug=False)`
199
+ - Splits an input dataset into multiple destination table payloads using declarative field mappings and transform commands, then imports each generated dataset.
200
+ - `from_sqlalchemy_metadata(p_sqlalchemy_metadata)` (`@classmethod`)
201
+ - Converts SQLAlchemy `MetaData` (sorted tables) to the legacy `db_structure` dictionary.
202
+ - `resolve_db_structure(p_db_structure=None, p_sqlalchemy_base=None, p_sqlalchemy_metadata=None)` (`@staticmethod`)
203
+ - Central schema source resolver used by constructors; raises `SchemaSourceError` when none are provided.
204
+ - `_action_to_legacy_code(p_action)` (`@staticmethod`)
205
+ - Maps SQLAlchemy FK action text (e.g., `CASCADE`, `SET NULL`) to legacy action codes (`C`, `N`, etc.).
206
+ - `_build_default_field_params()` (`@staticmethod`)
207
+ - Produces the baseline legacy field-parameter block used during SQLAlchemy conversion.
208
+ - `_column_type_to_legacy(p_column)` (`@staticmethod`)
209
+ - Converts a SQLAlchemy column type object into the legacy type-array format.
210
+ - `_column_to_legacy_field(p_column)` (`@staticmethod`)
211
+ - Converts one SQLAlchemy column into a legacy field definition, including nullability, autoincrement, defaults, and comments.
212
+ - `_set_foreign_keys(p_table, p_table_structure)` (`@staticmethod`)
213
+ - Writes legacy foreign-key metadata onto converted table fields.
214
+ - `_set_indexes(p_table, p_table_structure)` (`@staticmethod`)
215
+ - Writes legacy index metadata for converted table fields.
216
+ - `_set_primary_key(p_table, p_table_structure)` (`@staticmethod`)
217
+ - Marks primary-key columns in converted legacy table definitions.
218
+ - `_table_to_legacy_structure(p_table)` (`@staticmethod`)
219
+ - Converts an entire SQLAlchemy table into a legacy table structure and enriches it with keys/indexes.
220
+ - `_print_err_msg(p_err, p_msg="")` (`@staticmethod`)
221
+ - Formats and prints database error details before termination paths.
222
+
223
+ ### `MySQL(SQLDbWrpr)`
224
+
225
+ - `__init__(p_host_name, p_user_name, p_password, p_user_rights, p_recreate_db, p_db_name, p_db_structure, p_sqlalchemy_base, p_sqlalchemy_metadata, p_batch_size, p_bar_len, p_msg_width, p_verbose, p_admin_username, p_admin_user_password, p_db_port, **kwargs)`
226
+ - Opens a MySQL connection and cursor, optionally recreates database/tables, or selects the configured DB.
227
+ - Includes fallback logic to create and grant rights to missing users when valid admin credentials and rights data are supplied.
228
+
229
+ ### `PostgreSQL(SQLDbWrpr)`
230
+
231
+ - `__init__(p_host_name, p_user_name, p_password, p_recreate_db, p_db_name, p_db_structure, p_sqlalchemy_base, p_sqlalchemy_metadata, p_batch_size, p_bar_len, p_msg_width, p_verbose, p_db_port, p_maintenance_db, **kwargs)`
232
+ - Configures PostgreSQL-specific behavior (quoted identifiers, non-inline indexes), opens connection, and optionally recreates DB/tables.
233
+ - `build_column_sql(p_field_name, p_field_type, p_field_params, p_field_comment)`
234
+ - PostgreSQL-specific column SQL renderer; handles non-AI `NOT NULL` and defaults.
235
+ - `build_index_sql(p_table_name, p_idx_name, p_idx_fields, p_unique=False)`
236
+ - Builds PostgreSQL `CREATE INDEX`/`CREATE UNIQUE INDEX` statements.
237
+ - `build_insert_sql(p_table_name, p_header, p_replace=False)`
238
+ - Builds PostgreSQL `INSERT`; when `p_replace=True`, builds `ON CONFLICT` upsert/no-op behavior from primary-key metadata.
239
+ - `create_db()`
240
+ - PostgreSQL DB recreation flow via `pg_database` lookup, optional forced drop, create, and reconnect.
241
+ - `create_users(p_admin_user, p_new_users)`
242
+ - Creates PostgreSQL login roles that do not already exist, using the supplied passwords.
243
+ - `delete_users(p_admin_user, p_del_users)`
244
+ - Drops PostgreSQL roles that currently exist.
245
+ - `grant_rights(p_admin_user, p_user_rights)`
246
+ - Grants database-level rights when the table target is `*`; otherwise grants table-level rights. PostgreSQL ignores the host field in each rights entry.
247
+ - `render_default_sql(p_field_type, p_default_value)`
248
+ - Renders PostgreSQL defaults with proper escaping for string values.
249
+ - `render_field_type(p_field_type, p_field_params)`
250
+ - Maps legacy types to PostgreSQL types, including `SERIAL`/`BIGSERIAL` for autoincrement fields.
251
+ - `_connect(p_db_name)`
252
+ - Internal connector helper returning a `psycopg` connection for the requested database.
253
+
134
254
  ______________________________________________________________________
135
255
 
136
256
  ## Updating ReleaseNotes Instructions
@@ -6,7 +6,7 @@
6
6
 
7
7
  [project]
8
8
  name = 'SQLDbWrpr'
9
- version = '5.1.0'
9
+ version = '5.2.0'
10
10
  description = 'Wrapper for importing CSV and Text files into MySQL and Postgress'
11
11
  authors = [
12
12
  { name = "Hendrik du Toit", email = "hendrik@brightedge.co.za" }
@@ -17,13 +17,7 @@ import mysql.connector
17
17
  import psycopg
18
18
  from beetools import msg as bm
19
19
  from mysql.connector import errorcode
20
-
21
- # from pathlib import Path
22
-
23
-
24
- # _PROJ_DESC = __doc__.split("\n")[0]
25
- # _PROJ_PATH = Path(__file__)
26
- # _PROJ_NAME = _PROJ_PATH.stem
20
+ from psycopg import sql as psycopg_sql
27
21
 
28
22
 
29
23
  class SchemaSourceError(ValueError):
@@ -52,7 +46,7 @@ class SQLDbWrpr:
52
46
  p_ssl_key=None,
53
47
  p_ssl_cert=None,
54
48
  ):
55
- """Initialize common wrapper state and resolve the database schema.
49
+ """Initialise a common wrapper state and resolve the database schema.
56
50
 
57
51
  A legacy `p_db_structure` dictionary takes precedence. If it is not
58
52
  supplied, SQLAlchemy metadata is read from `p_sqlalchemy_metadata` or
@@ -101,6 +95,7 @@ class SQLDbWrpr:
101
95
  self.inline_indexes = True
102
96
  self.msg_width = p_msg_width
103
97
  self.non_char_fields = {}
98
+ self.owns_connection = True
104
99
  self._password = p_password
105
100
  self.re_create_db = p_recreate_db
106
101
  self.silent = p_verbose
@@ -110,10 +105,16 @@ class SQLDbWrpr:
110
105
  self.get_db_field_types()
111
106
  self.db_port = p_db_port
112
107
 
108
+ def _execute_import_rows(self, p_table_name, p_header, p_rows, p_replace):
109
+ """Execute one prepared import batch without managing its transaction."""
110
+ self.cur.executemany(self.build_insert_sql(p_table_name, p_header, p_replace), p_rows)
111
+
113
112
  def close(self):
114
- """Close the active database connection."""
115
- if self.conn:
113
+ """Close owned connections, or just this wrapper's cursor on a borrowed connection."""
114
+ if self.conn and self.owns_connection:
116
115
  self.conn.close()
116
+ elif self.cur:
117
+ self.cur.close()
117
118
 
118
119
  def build_column_sql(self, p_field_name, p_field_type, p_field_params, p_field_comment):
119
120
  """Build SQL for one column in a CREATE TABLE statement."""
@@ -548,40 +549,28 @@ class SQLDbWrpr:
548
549
  return success
549
550
 
550
551
  def create_users(self, p_admin_user, p_new_users):
551
- """Create MySQL users that do not already exist."""
552
- c_user_name = 0
553
- self.cur.execute("SELECT User, Host FROM mysql.user")
554
- curr_users = self.cur.fetchall()
555
- for user in p_new_users:
556
- if not user[c_user_name] in curr_users:
557
- try:
558
- self.cur.execute(
559
- "CREATE USER IF NOT EXISTS '{}'@'{}' IDENTIFIED BY '{}'".format(
560
- user[0], self.host_name, user[1]
561
- )
562
- )
563
- except self.db_error as err:
564
- self._print_err_msg(err, "Could not create user")
565
- self.close()
566
- sys.exit()
567
- self.conn.commit()
568
- self.success = True
552
+ """Create database users.
553
+
554
+ Concrete database backends must implement this because user management
555
+ SQL differs between databases.
556
+ """
557
+ raise NotImplementedError(f"{self.__class__.__name__} must implement create_users()")
569
558
 
570
559
  def delete_users(self, p_admin_user, p_del_users):
571
- c_user_name = 0
572
- # c_password = 1
573
- c_host = 2
574
- self.cur.execute("SELECT User FROM mysql.user")
575
- curr_users = [x[0] for x in self.cur.fetchall()]
576
- for user in p_del_users:
577
- if user[c_user_name] in curr_users:
578
- try:
579
- self.cur.execute(f"DROP USER '{user[c_user_name]}'@'{user[c_host]}'")
580
- except self.db_error as err:
581
- self._print_err_msg(err, "Could not delete user")
582
- self.close()
583
- sys.exit()
584
- self.success = True
560
+ """Delete database users.
561
+
562
+ Concrete database backends must implement this because user management
563
+ SQL differs between databases.
564
+ """
565
+ raise NotImplementedError(f"{self.__class__.__name__} must implement delete_users()")
566
+
567
+ def grant_rights(self, p_admin_user, p_user_rights):
568
+ """Grant database rights.
569
+
570
+ Concrete database backends must implement this because privilege syntax
571
+ differs between databases.
572
+ """
573
+ raise NotImplementedError(f"{self.__class__.__name__} must implement grant_rights()")
585
574
 
586
575
  def _err_broken_rec(self, p_sql_str, p_csv_db_slice):
587
576
  """Write broken record to logger"""
@@ -609,11 +598,11 @@ class SQLDbWrpr:
609
598
  """Export a table to a csv file
610
599
 
611
600
  Parameters
612
- - p_csv_path - Path name of the file to be exported
613
- - p_table_name = '' - Table name to export
614
- - p_delimeter = '|' - Field delimiter to use
601
+ - p_csv_path - Path name of the file to be exported
602
+ - p_table_name = '' - Table name to export
603
+ - p_delimeter = '|' - Field delimiter to use
615
604
  - p_strip_chars = '' - characters to strip from text
616
- - p__vol_size = 0 - Create a multiple volume export. p__vol_size is
605
+ - p__vol_size = 0 - Create a multiple-volume export. p__vol_size is
617
606
  the number of records per file. 0 will create
618
607
  only one volume.
619
608
  """
@@ -768,41 +757,6 @@ class SQLDbWrpr:
768
757
  else:
769
758
  self.non_char_fields[p_table_name].append(field)
770
759
 
771
- def grant_rights(self, p_admin_user, p_user_rights):
772
- """Grant configured MySQL rights to users."""
773
- c_user_name = 0
774
- # c_password = 1
775
- c_host = 1
776
- c_db = 2
777
- c_table = 3
778
- c_rights = 4
779
- # success = True
780
- for right in p_user_rights:
781
- try:
782
- sql_str = "GRANT {} ON {}.{} TO '{}'@'{}'".format(
783
- ",".join(right[c_rights:]),
784
- right[c_db],
785
- right[c_table],
786
- right[c_user_name],
787
- right[c_host],
788
- )
789
- self.cur.execute(sql_str)
790
- self.conn.commit()
791
- sql_str = "GRANT {} ON {}.{} TO '{}'@'{}' WITH GRANT OPTION".format(
792
- ",".join(right[c_rights:]),
793
- right[c_db],
794
- right[c_table],
795
- right[c_user_name],
796
- right[c_host],
797
- )
798
- self.cur.execute(sql_str)
799
- self.conn.commit()
800
- except self.db_error as err:
801
- self._print_err_msg(err)
802
- self.close()
803
- sys.exit()
804
- self.success = True
805
-
806
760
  def import_csv(
807
761
  self,
808
762
  p_table_name,
@@ -815,6 +769,7 @@ class SQLDbWrpr:
815
769
  p_vol_type="Multi",
816
770
  p_verbose=False,
817
771
  p_replace=False,
772
+ p_manage_transaction=True,
818
773
  ):
819
774
  """Import CSV rows into a database table.
820
775
 
@@ -843,7 +798,11 @@ class SQLDbWrpr:
843
798
  - p_replace = False
844
799
  - False - INSERT into database
845
800
  - True - REPLACE into database
801
+ - p_manage_transaction = True
802
+ - False leaves commits and rollbacks to the caller and propagates database errors.
846
803
  """
804
+ if not p_manage_transaction and self.conn.autocommit:
805
+ raise ValueError("Caller-managed imports require autocommit=False")
847
806
 
848
807
  def import_volume(p_csv_db, p_header, p_verbose):
849
808
  """Prepare and write one in-memory CSV volume to the active table."""
@@ -958,6 +917,12 @@ class SQLDbWrpr:
958
917
 
959
918
  def write_to_table(p_csv_db):
960
919
  """Write prepared rows to the destination table."""
920
+ if not p_manage_transaction:
921
+ for start in range(1, len(p_csv_db), self.batch_size):
922
+ self._execute_import_rows(
923
+ p_table_name, header, p_csv_db[start : start + self.batch_size], p_replace
924
+ )
925
+ return
961
926
  i = 1
962
927
  j = 0 # In case batch size is more than all records
963
928
  list_len = len(p_csv_db)
@@ -1085,6 +1050,7 @@ class SQLDbWrpr:
1085
1050
  p_insert_header=False,
1086
1051
  p_verbose=False,
1087
1052
  p_debug=False,
1053
+ p_manage_transaction=True,
1088
1054
  ):
1089
1055
  """Split CSV rows into one or more destination table imports.
1090
1056
 
@@ -1247,6 +1213,7 @@ class SQLDbWrpr:
1247
1213
  p_header=new_header,
1248
1214
  p_verbose=p_verbose,
1249
1215
  p_replace=p_split_struct[seq]["Replace"],
1216
+ p_manage_transaction=p_manage_transaction,
1250
1217
  )
1251
1218
 
1252
1219
  @classmethod
@@ -1532,6 +1499,77 @@ class MySQL(SQLDbWrpr):
1532
1499
  self.success = True
1533
1500
  pass
1534
1501
 
1502
+ def create_users(self, p_admin_user, p_new_users):
1503
+ """Create MySQL users that do not already exist."""
1504
+ c_user_name = 0
1505
+ self.cur.execute("SELECT User, Host FROM mysql.user")
1506
+ curr_users = self.cur.fetchall()
1507
+ for user in p_new_users:
1508
+ user_key = (user[c_user_name], self.host_name)
1509
+ if user_key not in curr_users:
1510
+ try:
1511
+ self.cur.execute(
1512
+ "CREATE USER IF NOT EXISTS '{}'@'{}' IDENTIFIED BY '{}'".format(
1513
+ user[0], self.host_name, user[1]
1514
+ )
1515
+ )
1516
+ except self.db_error as err:
1517
+ self._print_err_msg(err, "Could not create user")
1518
+ self.close()
1519
+ sys.exit()
1520
+ self.conn.commit()
1521
+ self.success = True
1522
+
1523
+ def delete_users(self, p_admin_user, p_del_users):
1524
+ """Delete MySQL users that exist."""
1525
+ c_user_name = 0
1526
+ c_host = 2
1527
+ self.cur.execute("SELECT CAST(User AS CHAR) FROM mysql.user")
1528
+ curr_users = [x[0] for x in self.cur.fetchall()]
1529
+ for user in p_del_users:
1530
+ if user[c_user_name] in curr_users:
1531
+ try:
1532
+ self.cur.execute(f"DROP USER '{user[c_user_name]}'@'{user[c_host]}'")
1533
+ except self.db_error as err:
1534
+ self._print_err_msg(err, "Could not delete user")
1535
+ self.close()
1536
+ sys.exit()
1537
+ self.conn.commit()
1538
+ self.success = True
1539
+
1540
+ def grant_rights(self, p_admin_user, p_user_rights):
1541
+ """Grant configured MySQL rights to users."""
1542
+ c_user_name = 0
1543
+ c_host = 1
1544
+ c_db = 2
1545
+ c_table = 3
1546
+ c_rights = 4
1547
+ for right in p_user_rights:
1548
+ try:
1549
+ sql_str = "GRANT {} ON {}.{} TO '{}'@'{}'".format(
1550
+ ",".join(right[c_rights:]),
1551
+ right[c_db],
1552
+ right[c_table],
1553
+ right[c_user_name],
1554
+ right[c_host],
1555
+ )
1556
+ self.cur.execute(sql_str)
1557
+ self.conn.commit()
1558
+ sql_str = "GRANT {} ON {}.{} TO '{}'@'{}' WITH GRANT OPTION".format(
1559
+ ",".join(right[c_rights:]),
1560
+ right[c_db],
1561
+ right[c_table],
1562
+ right[c_user_name],
1563
+ right[c_host],
1564
+ )
1565
+ self.cur.execute(sql_str)
1566
+ self.conn.commit()
1567
+ except self.db_error as err:
1568
+ self._print_err_msg(err)
1569
+ self.close()
1570
+ sys.exit()
1571
+ self.success = True
1572
+
1535
1573
 
1536
1574
  class PostgreSQL(SQLDbWrpr):
1537
1575
  """Wrapper for PostgreSQL databases."""
@@ -1552,9 +1590,19 @@ class PostgreSQL(SQLDbWrpr):
1552
1590
  p_verbose=False,
1553
1591
  p_db_port="5432",
1554
1592
  p_maintenance_db="postgres",
1593
+ p_connection=None,
1555
1594
  **kwargs,
1556
1595
  ):
1557
- """Create a PostgreSQL wrapper and optionally recreate the target database."""
1596
+ """Create a PostgreSQL wrapper, optionally borrowing a caller-owned DB-API connection.
1597
+
1598
+ A borrowed connection is never closed or reconfigured. Use
1599
+ p_manage_transaction=False on imports to keep its transaction caller-owned.
1600
+ Database recreation is unavailable on borrowed connections.
1601
+ """
1602
+ if p_connection is not None and p_recreate_db:
1603
+ raise ValueError("Cannot recreate a database on a borrowed connection")
1604
+ if p_batch_size < 1:
1605
+ raise ValueError("Batch size must be positive")
1558
1606
  super().__init__(
1559
1607
  p_host_name=p_host_name,
1560
1608
  p_user_name=p_user_name,
@@ -1576,14 +1624,27 @@ class PostgreSQL(SQLDbWrpr):
1576
1624
  self.maintenance_db = p_maintenance_db
1577
1625
  self._connection_kwargs = kwargs
1578
1626
  connect_db = self.maintenance_db if self.re_create_db else self.db_name
1579
- self.conn = self._connect(connect_db)
1580
- self.conn.autocommit = True
1627
+ self.owns_connection = p_connection is None
1628
+ self.conn = self._connect(connect_db) if self.owns_connection else p_connection
1629
+ if self.owns_connection:
1630
+ self.conn.autocommit = True
1581
1631
  self.cur = self.conn.cursor()
1582
1632
  if self.re_create_db:
1583
1633
  if self.create_db():
1584
1634
  self.create_tables()
1585
1635
  self.success = True
1586
1636
 
1637
+ def _execute_import_rows(self, p_table_name, p_header, p_rows, p_replace):
1638
+ """Use batched VALUES for psycopg2; retain native executemany for other drivers."""
1639
+ if type(self.conn).__module__.split(".")[0] == "psycopg2":
1640
+ # Borrowing this driver implies the host already has psycopg2 installed.
1641
+ from psycopg2.extras import execute_values
1642
+
1643
+ statement = self.build_insert_sql(p_table_name, p_header, p_replace, p_multi_row=True)
1644
+ execute_values(self.cur, statement, p_rows, page_size=self.batch_size)
1645
+ else:
1646
+ super()._execute_import_rows(p_table_name, p_header, p_rows, p_replace)
1647
+
1587
1648
  def build_column_sql(self, p_field_name, p_field_type, p_field_params, p_field_comment):
1588
1649
  """Build PostgreSQL SQL for one column in a CREATE TABLE statement."""
1589
1650
  sql_str = f"{self.quote_identifier(p_field_name)} {self.render_field_type(p_field_type, p_field_params)}"
@@ -1606,12 +1667,13 @@ class PostgreSQL(SQLDbWrpr):
1606
1667
  ",".join(index_fields),
1607
1668
  )
1608
1669
 
1609
- def build_insert_sql(self, p_table_name, p_header, p_replace=False):
1670
+ def build_insert_sql(self, p_table_name, p_header, p_replace=False, p_multi_row=False):
1610
1671
  """Build a PostgreSQL INSERT or upsert statement."""
1611
- insert_sql = "INSERT INTO {} ({}) VALUES ({})".format(
1672
+ values_sql = "%s" if p_multi_row else f"({','.join(self.param_placeholder() for _ in p_header)})"
1673
+ insert_sql = "INSERT INTO {} ({}) VALUES {}".format(
1612
1674
  self.quote_identifier(p_table_name),
1613
1675
  ",".join([self.quote_identifier(str(x)) for x in p_header]),
1614
- ",".join([self.param_placeholder() for x in range(len(p_header))]),
1676
+ values_sql,
1615
1677
  )
1616
1678
  if not p_replace:
1617
1679
  return insert_sql
@@ -1685,6 +1747,32 @@ class PostgreSQL(SQLDbWrpr):
1685
1747
  return f"DECIMAL({str(p_field_type[1])}, {str(p_field_type[2])})"
1686
1748
  return type_map.get(field_type, field_type.upper())
1687
1749
 
1750
+ def update_rows(self, p_table_name, p_header, p_rows, p_key="id"):
1751
+ """Update supplied columns in bounded batches; the caller owns commit and rollback.
1752
+
1753
+ p_header must contain p_key and at least one update column. Unlike an
1754
+ upsert, this never creates missing records or supplies defaults.
1755
+ """
1756
+ if p_key not in p_header or len(set(p_header)) != len(p_header) or len(p_header) < 2:
1757
+ raise ValueError("Updates require a unique header containing the key and update columns")
1758
+ if self.conn.autocommit:
1759
+ raise ValueError("Caller-managed updates require autocommit=False")
1760
+ fields = [field for field in p_header if field != p_key]
1761
+ indexes = [p_header.index(field) for field in [*fields, p_key]]
1762
+ assignments = ",".join(f"{self.quote_identifier(field)} = %s" for field in fields)
1763
+ statement = (
1764
+ f"UPDATE {self.quote_identifier(p_table_name)} SET {assignments} "
1765
+ f"WHERE {self.quote_identifier(p_key)} = %s"
1766
+ )
1767
+ for start in range(0, len(p_rows), self.batch_size):
1768
+ rows = [tuple(row[index] for index in indexes) for row in p_rows[start : start + self.batch_size]]
1769
+ if type(self.conn).__module__.split(".")[0] == "psycopg2":
1770
+ from psycopg2.extras import execute_batch
1771
+
1772
+ execute_batch(self.cur, statement, rows, page_size=self.batch_size)
1773
+ else:
1774
+ self.cur.executemany(statement, rows)
1775
+
1688
1776
  def _connect(self, p_db_name):
1689
1777
  """Open a PostgreSQL connection."""
1690
1778
  return psycopg.connect(
@@ -1695,3 +1783,81 @@ class PostgreSQL(SQLDbWrpr):
1695
1783
  port=self.db_port,
1696
1784
  **self._connection_kwargs,
1697
1785
  )
1786
+
1787
+ def create_users(self, p_admin_user, p_new_users):
1788
+ """Create PostgreSQL login roles that do not already exist."""
1789
+ c_user_name = 0
1790
+ c_password = 1
1791
+
1792
+ self.cur.execute("SELECT rolname FROM pg_roles WHERE rolcanlogin = true")
1793
+ curr_users = [row[0] for row in self.cur.fetchall()]
1794
+
1795
+ for user in p_new_users:
1796
+ user_name = user[c_user_name]
1797
+ password = user[c_password]
1798
+
1799
+ if user_name not in curr_users:
1800
+ try:
1801
+ self.cur.execute(
1802
+ psycopg_sql.SQL("CREATE USER {} WITH PASSWORD {}").format(
1803
+ psycopg_sql.Identifier(user_name),
1804
+ psycopg_sql.Literal(password),
1805
+ )
1806
+ )
1807
+ except self.db_error as err:
1808
+ self._print_err_msg(err, "Could not create user")
1809
+ self.close()
1810
+ sys.exit()
1811
+ self.success = True
1812
+
1813
+ def delete_users(self, p_admin_user, p_del_users):
1814
+ """Delete PostgreSQL users that exist."""
1815
+ c_user_name = 0
1816
+
1817
+ self.cur.execute("SELECT rolname FROM pg_roles")
1818
+ curr_users = [row[0] for row in self.cur.fetchall()]
1819
+
1820
+ for user in p_del_users:
1821
+ user_name = user[c_user_name]
1822
+
1823
+ if user_name in curr_users:
1824
+ try:
1825
+ self.cur.execute(f"DROP USER {self.quote_identifier(user_name)}")
1826
+ except self.db_error as err:
1827
+ self._print_err_msg(err, "Could not delete user")
1828
+ self.close()
1829
+ sys.exit()
1830
+ self.success = True
1831
+
1832
+ def grant_rights(self, p_admin_user, p_user_rights):
1833
+ """Grant configured PostgreSQL rights to users.
1834
+
1835
+ Expected right format:
1836
+ [user_name, host, database, table, right1, right2, ...]
1837
+ The host value is ignored because PostgreSQL does not grant privileges
1838
+ by user/host pair like MySQL.
1839
+ """
1840
+ c_user_name = 0
1841
+ c_db = 2
1842
+ c_table = 3
1843
+ c_rights = 4
1844
+
1845
+ for right in p_user_rights:
1846
+ try:
1847
+ rights_sql = ",".join(right[c_rights:])
1848
+ user_sql = self.quote_identifier(right[c_user_name])
1849
+ db_sql = self.quote_identifier(right[c_db])
1850
+ table_sql = self.quote_identifier(right[c_table])
1851
+
1852
+ if right[c_table] == "*":
1853
+ sql_str = f"GRANT {rights_sql} ON DATABASE {db_sql} TO {user_sql}"
1854
+ else:
1855
+ sql_str = f"GRANT {rights_sql} ON TABLE {table_sql} TO {user_sql}"
1856
+
1857
+ self.cur.execute(sql_str)
1858
+ self.conn.commit()
1859
+ except self.db_error as err:
1860
+ self._print_err_msg(err)
1861
+ self.close()
1862
+ sys.exit()
1863
+ self.success = True
File without changes