SQLDbWrpr 5.0.0__py3-none-any.whl → 5.1.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.
sqldbwrpr/sqldbwrpr.py CHANGED
@@ -1,8 +1,8 @@
1
- """Wrapper for importing CSV and Text files into MySQL and Postgresql.
1
+ """Database wrapper utilities for MySQL and PostgreSQL.
2
2
 
3
- Only the MySQL implementation is working. The MSSQL ODBC implementation
4
- is giving me a lot of shit with the connection and I gave up for now to
5
- get it working.
3
+ SqlDbWrpr creates databases and tables from either the legacy dictionary
4
+ structure or SQLAlchemy metadata, then imports and exports CSV data through a
5
+ small DB-API based wrapper.
6
6
  """
7
7
 
8
8
  import datetime
@@ -14,9 +14,8 @@ import csvwrpr
14
14
  import displayfx
15
15
  import fixdate
16
16
  import mysql.connector
17
- import pyodbc
17
+ import psycopg
18
18
  from beetools import msg as bm
19
- from mysql.connector import Error
20
19
  from mysql.connector import errorcode
21
20
 
22
21
  # from pathlib import Path
@@ -27,8 +26,12 @@ from mysql.connector import errorcode
27
26
  # _PROJ_NAME = _PROJ_PATH.stem
28
27
 
29
28
 
29
+ class SchemaSourceError(ValueError):
30
+ """Raised when no supported database schema source is supplied."""
31
+
32
+
30
33
  class SQLDbWrpr:
31
- """This module creates a wrapper for the MySql database."""
34
+ """Base wrapper for schema creation and CSV import/export operations."""
32
35
 
33
36
  def __init__(
34
37
  self,
@@ -38,6 +41,8 @@ class SQLDbWrpr:
38
41
  p_recreate_db=False,
39
42
  p_db_name="",
40
43
  p_db_structure=None,
44
+ p_sqlalchemy_base=None,
45
+ p_sqlalchemy_metadata=None,
41
46
  p_batch_size=10000,
42
47
  p_bar_len=50,
43
48
  p_msg_width=50,
@@ -47,18 +52,25 @@ class SQLDbWrpr:
47
52
  p_ssl_key=None,
48
53
  p_ssl_cert=None,
49
54
  ):
50
- """Create database with supplied structure and return a connector to the database
55
+ """Initialize common wrapper state and resolve the database schema.
56
+
57
+ A legacy `p_db_structure` dictionary takes precedence. If it is not
58
+ supplied, SQLAlchemy metadata is read from `p_sqlalchemy_metadata` or
59
+ from `p_sqlalchemy_base.metadata`. A `SchemaSourceError` is raised when
60
+ none of those schema sources are available.
51
61
 
52
62
  Parameters
53
- - p_host_name = Host to connect to
54
- - p_user_name = User name for connection
55
- - p_password = paswword of user
56
- - ReCreate = Recresate the database or connect to existing database
57
- - db_name =
58
- - table_details: Details of the tables to be created
59
- - batch_size: Bulk data will be managed by batch_size to commit
60
- - p_bar_len: Length for the progress bar
61
- - p_msg_width: Width of message before progress bar
63
+ - p_host_name: Host to connect to.
64
+ - p_user_name: User name for the connection.
65
+ - p_password: Password for the connection.
66
+ - p_recreate_db: Recreate the database when the backend supports it.
67
+ - p_db_name: Database name.
68
+ - p_db_structure: Legacy SqlDbWrpr table/field dictionary.
69
+ - p_sqlalchemy_base: Declarative base that exposes SQLAlchemy metadata.
70
+ - p_sqlalchemy_metadata: SQLAlchemy MetaData instance.
71
+ - p_batch_size: Number of rows committed per import batch.
72
+ - p_bar_len: Length of progress bars.
73
+ - p_msg_width: Width of progress messages.
62
74
  """
63
75
  self.logger_name = __name__
64
76
  self.logger = logging.getLogger(self.logger_name)
@@ -70,10 +82,12 @@ class SQLDbWrpr:
70
82
  self.conn = None
71
83
  self.cur = None
72
84
  self.db_name = p_db_name
73
- if p_db_structure:
74
- self.db_structure = p_db_structure
75
- else:
76
- self.db_structure = {}
85
+ self.db_structure = self.resolve_db_structure(
86
+ p_db_structure=p_db_structure,
87
+ p_sqlalchemy_base=p_sqlalchemy_base,
88
+ p_sqlalchemy_metadata=p_sqlalchemy_metadata,
89
+ )
90
+ self.db_error = mysql.connector.Error
77
91
  self.delimiter = ","
78
92
  self.fkey_ref_act = {
79
93
  "C": "CASCADE",
@@ -82,7 +96,9 @@ class SQLDbWrpr:
82
96
  "N": "SET NULL",
83
97
  }
84
98
  self.host_name = p_host_name
99
+ self.identifier_quote = ""
85
100
  self.idx_type = {"U": "UNIQUE", "F": "FULLTEXT", "S": "SPATIAL"}
101
+ self.inline_indexes = True
86
102
  self.msg_width = p_msg_width
87
103
  self.non_char_fields = {}
88
104
  self._password = p_password
@@ -95,10 +111,75 @@ class SQLDbWrpr:
95
111
  self.db_port = p_db_port
96
112
 
97
113
  def close(self):
98
- """Close the connention"""
114
+ """Close the active database connection."""
99
115
  if self.conn:
100
116
  self.conn.close()
101
117
 
118
+ def build_column_sql(self, p_field_name, p_field_type, p_field_params, p_field_comment):
119
+ """Build SQL for one column in a CREATE TABLE statement."""
120
+ sql_str = f"{self.quote_identifier(p_field_name)} {self.render_field_type(p_field_type, p_field_params)}"
121
+ if p_field_params["AI"] == "Y":
122
+ sql_str += " AUTO_INCREMENT"
123
+ if p_field_params["UN"] == "Y" and p_field_params["AI"] != "Y":
124
+ sql_str += " UNSIGNED"
125
+ if p_field_params["NN"] == "Y":
126
+ sql_str += " NOT NULL"
127
+ if p_field_params["ZF"] == "Y":
128
+ sql_str += " ZEROFILL"
129
+ if p_field_params["DEF"]:
130
+ sql_str += self.render_default_sql(p_field_type, p_field_params["DEF"])
131
+ if p_field_comment:
132
+ sql_str += f' COMMENT "{p_field_comment}"'
133
+ return sql_str
134
+
135
+ def build_index_sql(self, p_table_name, p_idx_name, p_idx_fields, p_unique=False):
136
+ """Build an index clause for the active SQL dialect."""
137
+ idx_type = "UNIQUE INDEX" if p_unique else "INDEX"
138
+ index_fields = []
139
+ for field_det in p_idx_fields:
140
+ index_fields.append(f"{self.quote_identifier(field_det[0])} {self.sort_order[field_det[2]]}")
141
+ return f"{idx_type} {self.quote_identifier(p_idx_name)} ({','.join(index_fields)}) VISIBLE, "
142
+
143
+ def build_insert_sql(self, p_table_name, p_header, p_replace=False):
144
+ """Build SQL for inserting rows into a table."""
145
+ insert_command = "REPLACE" if p_replace else "INSERT"
146
+ return "{} INTO {} ({}) VALUES ({})".format(
147
+ insert_command,
148
+ self.quote_identifier(p_table_name),
149
+ ",".join([self.quote_identifier(str(x)) for x in p_header]),
150
+ ",".join([self.param_placeholder() for x in range(len(p_header))]),
151
+ )
152
+
153
+ def param_placeholder(self):
154
+ """Return the DB-API placeholder used by this backend."""
155
+ return "%s"
156
+
157
+ def quote_identifier(self, p_identifier):
158
+ """Quote an SQL identifier for this backend."""
159
+ if not self.identifier_quote:
160
+ return str(p_identifier)
161
+ escaped_identifier = str(p_identifier).replace(self.identifier_quote, self.identifier_quote * 2)
162
+ return f"{self.identifier_quote}{escaped_identifier}{self.identifier_quote}"
163
+
164
+ def quote_identifier_list(self, p_identifiers):
165
+ """Quote and join SQL identifiers."""
166
+ return ",".join([self.quote_identifier(p_identifier) for p_identifier in p_identifiers])
167
+
168
+ def render_default_sql(self, p_field_type, p_default_value):
169
+ """Render a column default expression."""
170
+ if p_field_type[0] == "varchar" or p_field_type[0] == "char":
171
+ return f' DEFAULT "{p_default_value}"'
172
+ return f" DEFAULT {p_default_value}"
173
+
174
+ def render_field_type(self, p_field_type, p_field_params):
175
+ """Render a field type for this backend."""
176
+ field_type = p_field_type[0]
177
+ if field_type == "varchar" or field_type == "char":
178
+ return f"{field_type} ({str(p_field_type[1])})"
179
+ if field_type == "decimal":
180
+ return f"{field_type}({str(p_field_type[1])}, {str(p_field_type[2])})"
181
+ return field_type
182
+
102
183
  def create_db(self):
103
184
  """Create the database according to self.db_structure."""
104
185
  self.cur.execute("SHOW DATABASES")
@@ -108,7 +189,7 @@ class SQLDbWrpr:
108
189
  try:
109
190
  self.cur.execute(f"DROP DATABASE {self.db_name}")
110
191
  self.conn.commit()
111
- except mysql.connector.Error as err:
192
+ except self.db_error as err:
112
193
  self._print_err_msg(err, "Could not drop the database")
113
194
  self.close()
114
195
  sys.exit()
@@ -117,23 +198,23 @@ class SQLDbWrpr:
117
198
  self.conn.commit()
118
199
  self.cur.execute(f"USE {self.db_name}")
119
200
  self.conn.commit()
120
- except mysql.connector.Error as err:
201
+ except self.db_error as err:
121
202
  self._print_err_msg(err, "Could not create the database")
122
203
  self.close()
123
204
  sys.exit()
124
205
  return True
125
206
 
126
207
  def create_tables(self):
127
- """Create db tables from MySQL.table_details dict"""
208
+ """Create database tables, indexes, and constraints from the resolved schema."""
128
209
 
129
210
  def build_db(p_db_sql_str_set):
130
- """Description"""
211
+ """Execute the generated table or index SQL statements."""
131
212
  for sql_set in p_db_sql_str_set:
132
213
  try:
133
214
  self.cur.execute(sql_set[1])
134
215
  if self.silent:
135
216
  print(f"Created table = {sql_set[0]}")
136
- except mysql.connector.Error as err:
217
+ except self.db_error as err:
137
218
  print(f"Failed creating table = {sql_set[0]}: {err}\nForced termination of program")
138
219
  print(f"{sql_set[1]}")
139
220
  sys.exit()
@@ -147,7 +228,7 @@ class SQLDbWrpr:
147
228
  p_idx_set_up_list,
148
229
  p_constraint_set_up_list,
149
230
  ):
150
- """Description"""
231
+ """Combine column, primary-key, index, and constraint SQL."""
151
232
  table_set_up_str = p_table_set_up_str
152
233
  table_set_up_str += p_primary_key_str
153
234
  for idx_str in p_idx_set_up_list:
@@ -161,7 +242,7 @@ class SQLDbWrpr:
161
242
 
162
243
  def build_constraints(p_table_name):
163
244
  # noinspection PySingleQuotedDocstring
164
- """Description"""
245
+ """Build foreign-key constraint SQL for one table."""
165
246
  constraint_list = []
166
247
  fkey_nr_list = []
167
248
  for field_name in self.db_structure[p_table_name]:
@@ -173,9 +254,9 @@ class SQLDbWrpr:
173
254
  "CONSTRAINT fk_{}_{} FOREIGN KEY ({}) REFERENCES {} ({}) ON DELETE {} ON UPDATE {}, ".format(
174
255
  fkey["FKeyTable"],
175
256
  fkey["RefTable"],
176
- ".".join(fkey["FKeyFlds"]),
177
- fkey["RefTable"],
178
- ".".join(fkey["RefFields"]),
257
+ self.quote_identifier_list(fkey["FKeyFlds"]),
258
+ self.quote_identifier(fkey["RefTable"]),
259
+ self.quote_identifier_list(fkey["RefFields"]),
179
260
  self.fkey_ref_act[fkey["OnDelete"]],
180
261
  self.fkey_ref_act[fkey["OnUpdate"]],
181
262
  )
@@ -187,10 +268,10 @@ class SQLDbWrpr:
187
268
  # def build_constraints
188
269
 
189
270
  def build_all_indexes(p_table_name):
190
- """Description"""
271
+ """Build inline or post-create index SQL for one table."""
191
272
 
192
273
  # def build_primary_key_idx(p_table_name):
193
- # '''Description'''
274
+ # '''Build primary-key index SQL.'''
194
275
  # idx_name_list = []
195
276
  # idx_str_list = []
196
277
  # pkey = get_primary_key(p_table_name)
@@ -205,7 +286,7 @@ class SQLDbWrpr:
205
286
  # # end build_primary_key_idx
206
287
 
207
288
  def build_unique_key_idx(p_table_name, p_dx_name_list, p_idx_str_list):
208
- """Description"""
289
+ """Build grouped index definitions from legacy field metadata."""
209
290
  idx_list = {}
210
291
  idx_name_list = p_dx_name_list
211
292
  idx_str_list = p_idx_str_list
@@ -229,14 +310,16 @@ class SQLDbWrpr:
229
310
  idx_name = f"idx_{idx_name[:-1]}"
230
311
  if idx_name not in idx_name_list:
231
312
  idx_name_list.append(idx_name)
232
- if field_det[3] == "U":
233
- idx_str = f"{self.idx_type[field_det[3]]} INDEX {idx_name} ("
313
+ idx_str = self.build_index_sql(
314
+ p_table_name,
315
+ idx_name,
316
+ idx_instance_order,
317
+ field_det[3] == "U",
318
+ )
319
+ if self.inline_indexes:
320
+ idx_str_list.append(idx_str)
234
321
  else:
235
- idx_str = f"INDEX {idx_name} ("
236
- for field_det in idx_instance_order:
237
- idx_str += f"{field_det[0]} {self.sort_order[field_det[2]]}, "
238
- idx_str = idx_str[:-2] + ") VISIBLE, "
239
- idx_str_list.append(idx_str)
322
+ post_create_sql_set.append([idx_name, idx_str])
240
323
  return idx_str_list, idx_name_list
241
324
 
242
325
  # end build_unique_key_idx
@@ -249,47 +332,33 @@ class SQLDbWrpr:
249
332
  # def build_all_indexes
250
333
 
251
334
  def build_primary_key_sql_str(p_table_name):
252
- """Description"""
335
+ """Build the primary-key clause for one table."""
253
336
  primary_key_det = get_primary_key(p_table_name)
254
- sql_str = "PRIMARY KEY ({}), ".format(",".join(primary_key_det["Flds"]))
337
+ sql_str = "PRIMARY KEY ({}), ".format(self.quote_identifier_list(primary_key_det["Flds"]))
255
338
  return sql_str
256
339
 
257
340
  # def build_primary_key_sql_str
258
341
 
259
342
  def build_table_sql_str(p_table_name):
260
- """Description"""
261
- sql_str = f"CREATE TABLE {p_table_name} ("
343
+ """Build the CREATE TABLE prefix and column definitions."""
344
+ sql_str = f"CREATE TABLE {self.quote_identifier(p_table_name)} ("
262
345
  for field_name in self.db_structure[p_table_name]:
263
346
  field_type_st_ref = self.db_structure[p_table_name][field_name]["Type"]
264
347
  field_param_st_ref = self.db_structure[p_table_name][field_name]["Params"]
265
348
  field_comment_st_ref = self.db_structure[p_table_name][field_name]["Comment"]
266
- sql_str += f"{field_name} {field_type_st_ref[0]}"
267
- if field_type_st_ref[0] == "varchar" or field_type_st_ref[0] == "char":
268
- sql_str += f" ({str(field_type_st_ref[1])})"
269
- elif field_type_st_ref[0] == "decimal":
270
- sql_str += f"({str(field_type_st_ref[1])}, {str(field_type_st_ref[2])})"
271
- if field_param_st_ref["AI"] == "Y":
272
- sql_str += " AUTO_INCREMENT"
273
- if field_param_st_ref["UN"] == "Y" and field_param_st_ref["AI"] != "Y":
274
- sql_str += " UNSIGNED"
275
- if field_param_st_ref["NN"] == "Y":
276
- sql_str += " NOT NULL"
277
- if field_param_st_ref["ZF"] == "Y":
278
- sql_str += " ZEROFILL"
279
- if field_param_st_ref["DEF"]:
280
- if field_type_st_ref[0] == "varchar" or field_type_st_ref[0] == "char":
281
- sql_str += ' DEFAULT "{}"'.format(field_param_st_ref["DEF"])
282
- else:
283
- sql_str += " DEFAULT {}".format(field_param_st_ref["DEF"])
284
- if field_comment_st_ref:
285
- sql_str += f' COMMENT "{field_comment_st_ref}"'
349
+ sql_str += self.build_column_sql(
350
+ field_name,
351
+ field_type_st_ref,
352
+ field_param_st_ref,
353
+ field_comment_st_ref,
354
+ )
286
355
  sql_str += ", "
287
356
  return sql_str
288
357
 
289
358
  # end build_table_sql_str
290
359
 
291
360
  def get_foreign_key(p_table_name, p_field_name):
292
- """Description"""
361
+ """Return normalized foreign-key metadata for a field."""
293
362
  fkey = {
294
363
  "Present": False,
295
364
  "FKeyFlds": [],
@@ -327,7 +396,7 @@ class SQLDbWrpr:
327
396
  # end get_foreign_key
328
397
 
329
398
  def get_primary_key(p_table_name):
330
- """Description"""
399
+ """Return normalized primary-key metadata for a table."""
331
400
  pkey = {"Present": False, "Flds": (), "SortPairList": [], "SortPairStr": []}
332
401
  for field_name in self.db_structure[p_table_name]:
333
402
  pkey_field_det = self.db_structure[p_table_name][field_name]
@@ -351,7 +420,7 @@ class SQLDbWrpr:
351
420
  # end get_primary_key
352
421
 
353
422
  def order_table_build_list(p_db_sql_str_set, p_constraint_set_up_list):
354
- """Description"""
423
+ """Order table creation so referenced tables are created first."""
355
424
  db_sql_str_set = p_db_sql_str_set
356
425
  ordered = False
357
426
  while not ordered:
@@ -385,13 +454,13 @@ class SQLDbWrpr:
385
454
  # end order_table_build_list
386
455
 
387
456
  def structure_validation():
388
- """Description"""
457
+ """Validate schema relationships before SQL generation."""
389
458
 
390
459
  def check_pkey_fkey_overlap(p_remove_fkey_pkey__overlap=True):
391
- """Description"""
460
+ """Detect and optionally remove primary-key/foreign-key overlap."""
392
461
 
393
462
  def partial_overlap(p_fkey, p_pkey):
394
- """Description"""
463
+ """Return whether a foreign key partially overlaps a primary key."""
395
464
  is_overlap = False
396
465
  for field_name in p_fkey["FKeyFlds"]:
397
466
  if field_name in p_pkey["Flds"]:
@@ -401,7 +470,7 @@ class SQLDbWrpr:
401
470
  # end partial_overlap
402
471
 
403
472
  def remove_fkey(p_fkey):
404
- """Description"""
473
+ """Remove a foreign-key definition from all participating fields."""
405
474
  for field_name in self.db_structure[p_fkey["FKeyTable"]]:
406
475
  if self.db_structure[p_fkey["FKeyTable"]][field_name]["Params"]["FKey"]:
407
476
  if (
@@ -448,6 +517,7 @@ class SQLDbWrpr:
448
517
  # end structure_validation()
449
518
 
450
519
  success = True
520
+ post_create_sql_set = []
451
521
  structure_validation()
452
522
  table_set_up_list = ""
453
523
  idx_set_up_list = []
@@ -474,9 +544,11 @@ class SQLDbWrpr:
474
544
  pass
475
545
  db_sql_str_set = order_table_build_list(db_sql_str_set, constraint_set_up_list)
476
546
  build_db(db_sql_str_set)
547
+ build_db(post_create_sql_set)
477
548
  return success
478
549
 
479
550
  def create_users(self, p_admin_user, p_new_users):
551
+ """Create MySQL users that do not already exist."""
480
552
  c_user_name = 0
481
553
  self.cur.execute("SELECT User, Host FROM mysql.user")
482
554
  curr_users = self.cur.fetchall()
@@ -488,7 +560,7 @@ class SQLDbWrpr:
488
560
  user[0], self.host_name, user[1]
489
561
  )
490
562
  )
491
- except mysql.connector.Error as err:
563
+ except self.db_error as err:
492
564
  self._print_err_msg(err, "Could not create user")
493
565
  self.close()
494
566
  sys.exit()
@@ -505,7 +577,7 @@ class SQLDbWrpr:
505
577
  if user[c_user_name] in curr_users:
506
578
  try:
507
579
  self.cur.execute(f"DROP USER '{user[c_user_name]}'@'{user[c_host]}'")
508
- except mysql.connector.Error as err:
580
+ except self.db_error as err:
509
581
  self._print_err_msg(err, "Could not delete user")
510
582
  self.close()
511
583
  sys.exit()
@@ -558,12 +630,13 @@ class SQLDbWrpr:
558
630
  file_name_list = []
559
631
  header = p_delimeter.join(self.db_structure[p_table_name])
560
632
  prim_key_sql_str = "SELECT "
561
- all_sql_str = "SELECT " + header.replace(p_delimeter, ",") + " FROM " + p_table_name + " WHERE "
633
+ select_header = self.quote_identifier_list(self.db_structure[p_table_name])
634
+ all_sql_str = "SELECT " + select_header + " FROM " + self.quote_identifier(p_table_name) + " WHERE "
562
635
  for i, field in enumerate(self.db_structure[p_table_name]):
563
636
  if self.db_structure[p_table_name][field]["Params"]["PrimaryKey"][0] == "Y":
564
- prim_key_sql_str += field + ", "
565
- all_sql_str += field + " = %s and "
566
- prim_key_sql_str = prim_key_sql_str[:-2] + " FROM " + p_table_name
637
+ prim_key_sql_str += self.quote_identifier(field) + ", "
638
+ all_sql_str += self.quote_identifier(field) + " = " + self.param_placeholder() + " and "
639
+ prim_key_sql_str = prim_key_sql_str[:-2] + " FROM " + self.quote_identifier(p_table_name)
567
640
  all_sql_str = all_sql_str[:-5]
568
641
  print(f"Collecting {p_table_name} table records")
569
642
  self.cur.execute(prim_key_sql_str)
@@ -626,7 +699,12 @@ class SQLDbWrpr:
626
699
  file_name_list.append(os.path.split(p_csv_path))
627
700
  if not p_sql_query:
628
701
  header = p_delimeter.join(self.db_structure[p_table_name])
629
- sql_str = "SELECT " + header.replace(p_delimeter, ",") + " FROM " + p_table_name
702
+ sql_str = (
703
+ "SELECT "
704
+ + self.quote_identifier_list(self.db_structure[p_table_name])
705
+ + " FROM "
706
+ + self.quote_identifier(p_table_name)
707
+ )
630
708
  else:
631
709
  header = p_delimeter.join(p_sql_query[0])
632
710
  sql_str = p_sql_query[1]
@@ -663,8 +741,8 @@ class SQLDbWrpr:
663
741
 
664
742
  file_name_list = None
665
743
  try:
666
- self.cur.execute("SELECT COUNT(*) FROM " + p_table_name)
667
- except mysql.connector.Error as err:
744
+ self.cur.execute("SELECT COUNT(*) FROM " + self.quote_identifier(p_table_name))
745
+ except self.db_error as err:
668
746
  print(f"Err mesg: {err.msg}")
669
747
  print(err.msg)
670
748
  else:
@@ -677,7 +755,7 @@ class SQLDbWrpr:
677
755
  return file_name_list
678
756
 
679
757
  def get_db_field_types(self):
680
- """Description"""
758
+ """Populate field type lookup lists used during import/export."""
681
759
  for p_table_name in self.db_structure:
682
760
  self.char_fields[p_table_name] = []
683
761
  self.non_char_fields[p_table_name] = []
@@ -691,6 +769,7 @@ class SQLDbWrpr:
691
769
  self.non_char_fields[p_table_name].append(field)
692
770
 
693
771
  def grant_rights(self, p_admin_user, p_user_rights):
772
+ """Grant configured MySQL rights to users."""
694
773
  c_user_name = 0
695
774
  # c_password = 1
696
775
  c_host = 1
@@ -718,7 +797,7 @@ class SQLDbWrpr:
718
797
  )
719
798
  self.cur.execute(sql_str)
720
799
  self.conn.commit()
721
- except mysql.connector.Error as err:
800
+ except self.db_error as err:
722
801
  self._print_err_msg(err)
723
802
  self.close()
724
803
  sys.exit()
@@ -737,41 +816,40 @@ class SQLDbWrpr:
737
816
  p_verbose=False,
738
817
  p_replace=False,
739
818
  ):
740
- """Import a csv file into a database table.
819
+ """Import CSV rows into a database table.
741
820
 
742
821
  Parameters
743
822
  - p_table_name
744
- Table name to import the csv data into
823
+ Table name to import the CSV data into
745
824
  - p_csv_file_name = ''
746
- Csv file name. Empty if structure contained in p_csv_db
825
+ CSV file name. Empty when rows are supplied in p_csv_db
747
826
  - p_key = ''
748
827
  Key used to insert in table
749
828
  - p_header = ''
750
- - Header of csv files
829
+ - Header of CSV files
751
830
  - p_del_head = ''
752
831
  - Delete the header
753
832
  - p_csv_db = ''
754
- - Contains the csv table in a structure and makes p_csv_file_name obsolete.
833
+ - Contains the CSV rows directly and makes p_csv_file_name obsolete.
755
834
  - p_csv_corr_str_file_name = ''
756
- - String that contains any strings that should be replace in the csv
757
- file before parsing
835
+ - File containing string replacements to apply before parsing
758
836
  - p_vol_type = 'Multi'
759
837
  - Multi - Read multiple volume
760
838
  - Single - Read single file
761
839
  - p_verbose = False
762
- - Determine if there are any output to screen
840
+ - Determine whether progress output is written to screen
763
841
  - debug = False
764
- - Switch debug on
842
+ - Switch debug output on
765
843
  - p_replace = False
766
844
  - False - INSERT into database
767
845
  - True - REPLACE into database
768
846
  """
769
847
 
770
848
  def import_volume(p_csv_db, p_header, p_verbose):
771
- """Description"""
849
+ """Prepare and write one in-memory CSV volume to the active table."""
772
850
 
773
851
  def convert_str_to_none(p_non_char_fields_idx, p_csv_db):
774
- """Description"""
852
+ """Convert blank non-character values to None before insert."""
775
853
  rows_to_del = []
776
854
  csv_db = p_csv_db
777
855
  list_len = len(csv_db)
@@ -813,7 +891,7 @@ class SQLDbWrpr:
813
891
  # end convert_str_to_none
814
892
 
815
893
  def find_non_char_field_idx(p_csv_db):
816
- """Find the index of the fields that could potentially contain mepty strings."""
894
+ """Find non-character field indexes that may contain empty strings."""
817
895
  non_char_fields_idx = []
818
896
  for header_field_name in self.non_char_fields[p_table_name]:
819
897
  for row_idx, data_field_name in enumerate(p_csv_db[0]):
@@ -825,7 +903,7 @@ class SQLDbWrpr:
825
903
  # end find_non_char_field_idx
826
904
 
827
905
  def fix_dates(p_csv_db, p_table_name, p_header):
828
- """Ensure date and datetime fields in the database is valid."""
906
+ """Normalize date and datetime values before insert."""
829
907
  c_field_idx = 0
830
908
  c_field_type = 1
831
909
  csv_db = p_csv_db
@@ -879,7 +957,7 @@ class SQLDbWrpr:
879
957
  # end fix_dates
880
958
 
881
959
  def write_to_table(p_csv_db):
882
- """Write the data to a table"""
960
+ """Write prepared rows to the destination table."""
883
961
  i = 1
884
962
  j = 0 # In case batch size is more than all records
885
963
  list_len = len(p_csv_db)
@@ -893,21 +971,11 @@ class SQLDbWrpr:
893
971
  p_verbose=p_verbose,
894
972
  p_bar_len=self.bar_len,
895
973
  )
896
- # sql_str = 'REPLACE'
897
- if p_replace:
898
- sql_str = "REPLACE"
899
- else:
900
- sql_str = "INSERT"
901
- sql_str = "{} INTO {} ({}) VALUES ({})".format(
902
- sql_str,
903
- p_table_name,
904
- ",".join([str(x) for x in header]),
905
- ",".join(["%s" for x in range(len(header))]),
906
- )
974
+ sql_str = self.build_insert_sql(p_table_name, header, p_replace=p_replace)
907
975
  for j in range(self.batch_size, list_len, self.batch_size):
908
976
  try:
909
977
  self.cur.executemany(sql_str, p_csv_db[i : j + 1])
910
- except Error as err:
978
+ except self.db_error as err:
911
979
  self.logger.error(err)
912
980
  self.conn.rollback()
913
981
  self._err_broken_rec(sql_str, p_csv_db[i : j + 1])
@@ -939,7 +1007,7 @@ class SQLDbWrpr:
939
1007
  # end import_volume
940
1008
 
941
1009
  def import_single_volume(p_csv_db, p_header, p_verbose):
942
- """Description"""
1010
+ """Import one supplied in-memory CSV volume."""
943
1011
  success = False
944
1012
  # if not p_csv_db:
945
1013
  # if os.path.isfile(p_csv_file_name):
@@ -965,7 +1033,7 @@ class SQLDbWrpr:
965
1033
  # end import_single_volume
966
1034
 
967
1035
  def import_multi_volume(p_verbose, p_header):
968
- """Description"""
1036
+ """Import numbered CSV files until the next volume is missing."""
969
1037
  vol_cntr = 1
970
1038
  success = False
971
1039
  vol_csv_file_name = p_csv_file_name
@@ -1018,7 +1086,7 @@ class SQLDbWrpr:
1018
1086
  p_verbose=False,
1019
1087
  p_debug=False,
1020
1088
  ):
1021
- """Import a csv file into a database table.
1089
+ """Split CSV rows into one or more destination table imports.
1022
1090
 
1023
1091
  Parameters
1024
1092
  - p_split_struct - { 'Seq01': { 'TableName': Desttable_name1, 'Key': TableKey, 'Replace': False, 'Flds': [[ OrgField1, DestField1, [ Command, Parm1, Parm2, Parm3 ]],
@@ -1036,8 +1104,8 @@ class SQLDbWrpr:
1036
1104
  - Replace (boolean): Either use REPLACE or INSERT SQL statement to add records to the table. INSERT will cause
1037
1105
  a failure when the record to be added is a duplicate.
1038
1106
  - Fields (str): Mandatory key word (in the python dict structure) to list the fields in the table
1039
- - OrgFieldN (str): Field name from the csv file top copy to the database table
1040
- - DestFieldN (str): Destination filed where OrgFieldN will be copied into
1107
+ - OrgFieldN (str): Field name from the CSV file to copy to the database table
1108
+ - DestFieldN (str): Destination field where OrgFieldN will be copied into
1041
1109
  - Command (int): 0 = Copy OrgFieldN to DestFieldN as is
1042
1110
  Parm1 = Truncate OrgFieldN at Parm1 if it is a string and insert into DestFieldN. 0 for no truncation. Non 'str' will not be truncated
1043
1111
  Parm2 = True if you do not want to add the row if the result is empty, else False
@@ -1181,8 +1249,158 @@ class SQLDbWrpr:
1181
1249
  p_replace=p_split_struct[seq]["Replace"],
1182
1250
  )
1183
1251
 
1252
+ @classmethod
1253
+ def from_sqlalchemy_metadata(cls, p_sqlalchemy_metadata):
1254
+ """Convert SQLAlchemy metadata into the legacy SqlDbWrpr schema structure."""
1255
+ if not getattr(p_sqlalchemy_metadata, "tables", None):
1256
+ raise SchemaSourceError("SQLAlchemy metadata does not define any tables")
1257
+ db_structure = {}
1258
+ for table in p_sqlalchemy_metadata.sorted_tables:
1259
+ db_structure[table.name] = cls._table_to_legacy_structure(table)
1260
+ return db_structure
1261
+
1262
+ @staticmethod
1263
+ def resolve_db_structure(p_db_structure=None, p_sqlalchemy_base=None, p_sqlalchemy_metadata=None):
1264
+ """Resolve schema from explicit config, SQLAlchemy metadata, or declarative base."""
1265
+ if p_db_structure:
1266
+ return p_db_structure
1267
+ if p_sqlalchemy_metadata is None and p_sqlalchemy_base is not None:
1268
+ p_sqlalchemy_metadata = getattr(p_sqlalchemy_base, "metadata", None)
1269
+ if p_sqlalchemy_metadata is not None:
1270
+ return SQLDbWrpr.from_sqlalchemy_metadata(p_sqlalchemy_metadata)
1271
+ raise SchemaSourceError("Supply p_db_structure, p_sqlalchemy_metadata, or p_sqlalchemy_base")
1272
+
1273
+ @staticmethod
1274
+ def _action_to_legacy_code(p_action):
1275
+ """Convert SQLAlchemy foreign-key actions to legacy codes."""
1276
+ if p_action is None:
1277
+ return "N"
1278
+ action = p_action.upper().replace(" ", "_")
1279
+ action_map = {
1280
+ "CASCADE": "C",
1281
+ "RESTRICT": "R",
1282
+ "SET_DEFAULT": "D",
1283
+ "SET_NULL": "N",
1284
+ }
1285
+ return action_map[action]
1286
+
1287
+ @staticmethod
1288
+ def _build_default_field_params():
1289
+ """Build the legacy default parameter block for a field."""
1290
+ return {
1291
+ "PrimaryKey": ["", ""],
1292
+ "FKey": [],
1293
+ "Index": [],
1294
+ "NN": "",
1295
+ "B": "",
1296
+ "UN": "",
1297
+ "ZF": "",
1298
+ "AI": "",
1299
+ "G": "",
1300
+ "DEF": "",
1301
+ }
1302
+
1303
+ @staticmethod
1304
+ def _column_type_to_legacy(p_column):
1305
+ """Convert a SQLAlchemy column type to the legacy type list."""
1306
+ column_type = p_column.type
1307
+ type_name = column_type.__class__.__name__.lower()
1308
+ if "biginteger" in type_name:
1309
+ return ["bigint"]
1310
+ if "boolean" in type_name:
1311
+ return ["boolean"]
1312
+ if "char" in type_name or "string" in type_name or "varchar" in type_name:
1313
+ if getattr(column_type, "length", None) is None:
1314
+ return ["varchar"]
1315
+ if type_name == "char":
1316
+ return ["char", column_type.length]
1317
+ return ["varchar", column_type.length]
1318
+ if type_name == "date":
1319
+ return ["date"]
1320
+ if "datetime" in type_name:
1321
+ return ["datetime"]
1322
+ if "integer" in type_name:
1323
+ return ["int"]
1324
+ if "largebinary" in type_name or "blob" in type_name or "binary" in type_name:
1325
+ return ["blob"]
1326
+ if "numeric" in type_name or "decimal" in type_name:
1327
+ precision = getattr(column_type, "precision", None)
1328
+ scale = getattr(column_type, "scale", None)
1329
+ if precision is not None and scale is not None:
1330
+ return ["decimal", precision, scale]
1331
+ return ["decimal"]
1332
+ if type_name == "time":
1333
+ return ["time"]
1334
+ return [type_name]
1335
+
1336
+ @staticmethod
1337
+ def _column_to_legacy_field(p_column):
1338
+ """Convert a SQLAlchemy column to a legacy field definition."""
1339
+ field_type = SQLDbWrpr._column_type_to_legacy(p_column)
1340
+ params = SQLDbWrpr._build_default_field_params()
1341
+ if not p_column.nullable or p_column.primary_key:
1342
+ params["NN"] = "Y"
1343
+ if p_column.autoincrement is True:
1344
+ params["AI"] = "Y"
1345
+ if p_column.default is not None and getattr(p_column.default, "is_scalar", False):
1346
+ params["DEF"] = str(p_column.default.arg)
1347
+ if field_type[0] == "blob":
1348
+ params["B"] = "Y"
1349
+ return {
1350
+ "Type": field_type,
1351
+ "Params": params,
1352
+ "Possible Values": "",
1353
+ "Comment": p_column.comment or "",
1354
+ }
1355
+
1356
+ @staticmethod
1357
+ def _set_foreign_keys(p_table, p_table_structure):
1358
+ """Set legacy foreign-key metadata on converted table fields."""
1359
+ for fkey_nr, constraint in enumerate(p_table.foreign_key_constraints, start=1):
1360
+ elements = list(constraint.elements)
1361
+ for field_order, element in enumerate(elements, start=1):
1362
+ p_table_structure[element.parent.name]["Params"]["FKey"] = [
1363
+ fkey_nr,
1364
+ field_order,
1365
+ element.column.table.name,
1366
+ element.column.name,
1367
+ SQLDbWrpr._action_to_legacy_code(constraint.ondelete),
1368
+ SQLDbWrpr._action_to_legacy_code(constraint.onupdate),
1369
+ ]
1370
+
1371
+ @staticmethod
1372
+ def _set_indexes(p_table, p_table_structure):
1373
+ """Set legacy index metadata on converted table fields."""
1374
+ indexes = sorted(p_table.indexes, key=lambda p_index: p_index.name or "")
1375
+ for idx_nr, index in enumerate(indexes, start=1):
1376
+ for field_order, column in enumerate(index.columns, start=1):
1377
+ p_table_structure[column.name]["Params"]["Index"] = [
1378
+ idx_nr,
1379
+ field_order,
1380
+ "A",
1381
+ "U" if index.unique else "",
1382
+ ]
1383
+
1384
+ @staticmethod
1385
+ def _set_primary_key(p_table, p_table_structure):
1386
+ """Set legacy primary-key metadata on converted table fields."""
1387
+ for column in p_table.primary_key.columns:
1388
+ p_table_structure[column.name]["Params"]["PrimaryKey"] = ["Y", "A"]
1389
+
1390
+ @staticmethod
1391
+ def _table_to_legacy_structure(p_table):
1392
+ """Convert a SQLAlchemy table to the legacy table structure."""
1393
+ table_structure = {}
1394
+ for column in p_table.columns:
1395
+ table_structure[column.name] = SQLDbWrpr._column_to_legacy_field(column)
1396
+ SQLDbWrpr._set_foreign_keys(p_table, table_structure)
1397
+ SQLDbWrpr._set_indexes(p_table, table_structure)
1398
+ SQLDbWrpr._set_primary_key(p_table, table_structure)
1399
+ return table_structure
1400
+
1184
1401
  @staticmethod
1185
1402
  def _print_err_msg(p_err, p_msg=""):
1403
+ """Print a formatted database error message."""
1186
1404
  msg = p_msg
1187
1405
  if p_msg:
1188
1406
  msg = f"{p_msg}\n"
@@ -1197,7 +1415,7 @@ class SQLDbWrpr:
1197
1415
 
1198
1416
 
1199
1417
  class MySQL(SQLDbWrpr):
1200
- """This module creates a wrapper for the MySql database."""
1418
+ """Wrapper for MySQL databases."""
1201
1419
 
1202
1420
  def __init__(
1203
1421
  self,
@@ -1208,6 +1426,8 @@ class MySQL(SQLDbWrpr):
1208
1426
  p_recreate_db=False,
1209
1427
  p_db_name=None,
1210
1428
  p_db_structure=None,
1429
+ p_sqlalchemy_base=None,
1430
+ p_sqlalchemy_metadata=None,
1211
1431
  p_batch_size=10000,
1212
1432
  p_bar_len=50,
1213
1433
  p_msg_width=50,
@@ -1220,7 +1440,7 @@ class MySQL(SQLDbWrpr):
1220
1440
  # p_ssl_cert=None
1221
1441
  **kwargs,
1222
1442
  ):
1223
- """Description"""
1443
+ """Connect to MySQL and optionally recreate or select the target database."""
1224
1444
  super().__init__(
1225
1445
  p_host_name=p_host_name,
1226
1446
  p_user_name=p_user_name,
@@ -1228,6 +1448,8 @@ class MySQL(SQLDbWrpr):
1228
1448
  p_db_name=p_db_name,
1229
1449
  p_recreate_db=p_recreate_db,
1230
1450
  p_db_structure=p_db_structure,
1451
+ p_sqlalchemy_base=p_sqlalchemy_base,
1452
+ p_sqlalchemy_metadata=p_sqlalchemy_metadata,
1231
1453
  p_batch_size=p_batch_size,
1232
1454
  p_bar_len=p_bar_len,
1233
1455
  p_msg_width=p_msg_width,
@@ -1252,7 +1474,7 @@ class MySQL(SQLDbWrpr):
1252
1474
  **kwargs,
1253
1475
  )
1254
1476
  self.cur = self.conn.cursor()
1255
- except mysql.connector.Error as err:
1477
+ except self.db_error as err:
1256
1478
  print(
1257
1479
  bm.error(
1258
1480
  f"Error {err}:'({self.user_name}'@'{self.host_name}')",
@@ -1270,7 +1492,7 @@ class MySQL(SQLDbWrpr):
1270
1492
  auth_plugin="mysql_native_password",
1271
1493
  port=self.db_port,
1272
1494
  )
1273
- except mysql.connector.Error as err:
1495
+ except self.db_error as err:
1274
1496
  self._print_err_msg(
1275
1497
  err,
1276
1498
  "Admin user name and/or password not supplied or incorrect",
@@ -1311,8 +1533,8 @@ class MySQL(SQLDbWrpr):
1311
1533
  pass
1312
1534
 
1313
1535
 
1314
- class MSSQL(SQLDbWrpr):
1315
- """This module creates a wrapper for the MySql database."""
1536
+ class PostgreSQL(SQLDbWrpr):
1537
+ """Wrapper for PostgreSQL databases."""
1316
1538
 
1317
1539
  def __init__(
1318
1540
  self,
@@ -1322,12 +1544,17 @@ class MSSQL(SQLDbWrpr):
1322
1544
  p_recreate_db=False,
1323
1545
  p_db_name=None,
1324
1546
  p_db_structure=None,
1547
+ p_sqlalchemy_base=None,
1548
+ p_sqlalchemy_metadata=None,
1325
1549
  p_batch_size=10000,
1326
1550
  p_bar_len=50,
1327
1551
  p_msg_width=50,
1328
1552
  p_verbose=False,
1553
+ p_db_port="5432",
1554
+ p_maintenance_db="postgres",
1555
+ **kwargs,
1329
1556
  ):
1330
- """Description"""
1557
+ """Create a PostgreSQL wrapper and optionally recreate the target database."""
1331
1558
  super().__init__(
1332
1559
  p_host_name=p_host_name,
1333
1560
  p_user_name=p_user_name,
@@ -1335,36 +1562,136 @@ class MSSQL(SQLDbWrpr):
1335
1562
  p_db_name=p_db_name,
1336
1563
  p_recreate_db=p_recreate_db,
1337
1564
  p_db_structure=p_db_structure,
1565
+ p_sqlalchemy_base=p_sqlalchemy_base,
1566
+ p_sqlalchemy_metadata=p_sqlalchemy_metadata,
1338
1567
  p_batch_size=p_batch_size,
1339
1568
  p_bar_len=p_bar_len,
1340
1569
  p_msg_width=p_msg_width,
1341
1570
  p_verbose=p_verbose,
1571
+ p_db_port=p_db_port,
1572
+ )
1573
+ self.db_error = psycopg.Error
1574
+ self.identifier_quote = '"'
1575
+ self.inline_indexes = False
1576
+ self.maintenance_db = p_maintenance_db
1577
+ self._connection_kwargs = kwargs
1578
+ 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
1581
+ self.cur = self.conn.cursor()
1582
+ if self.re_create_db:
1583
+ if self.create_db():
1584
+ self.create_tables()
1585
+ self.success = True
1586
+
1587
+ def build_column_sql(self, p_field_name, p_field_type, p_field_params, p_field_comment):
1588
+ """Build PostgreSQL SQL for one column in a CREATE TABLE statement."""
1589
+ sql_str = f"{self.quote_identifier(p_field_name)} {self.render_field_type(p_field_type, p_field_params)}"
1590
+ if p_field_params["NN"] == "Y" and p_field_params["AI"] != "Y":
1591
+ sql_str += " NOT NULL"
1592
+ if p_field_params["DEF"]:
1593
+ sql_str += self.render_default_sql(p_field_type, p_field_params["DEF"])
1594
+ return sql_str
1595
+
1596
+ def build_index_sql(self, p_table_name, p_idx_name, p_idx_fields, p_unique=False):
1597
+ """Build a PostgreSQL CREATE INDEX statement."""
1598
+ unique_sql = "UNIQUE " if p_unique else ""
1599
+ index_fields = []
1600
+ for field_det in p_idx_fields:
1601
+ index_fields.append(f"{self.quote_identifier(field_det[0])} {self.sort_order[field_det[2]]}")
1602
+ return "CREATE {}INDEX {} ON {} ({})".format(
1603
+ unique_sql,
1604
+ self.quote_identifier(p_idx_name),
1605
+ self.quote_identifier(p_table_name),
1606
+ ",".join(index_fields),
1607
+ )
1608
+
1609
+ def build_insert_sql(self, p_table_name, p_header, p_replace=False):
1610
+ """Build a PostgreSQL INSERT or upsert statement."""
1611
+ insert_sql = "INSERT INTO {} ({}) VALUES ({})".format(
1612
+ self.quote_identifier(p_table_name),
1613
+ ",".join([self.quote_identifier(str(x)) for x in p_header]),
1614
+ ",".join([self.param_placeholder() for x in range(len(p_header))]),
1342
1615
  )
1616
+ if not p_replace:
1617
+ return insert_sql
1618
+ primary_key_fields = [
1619
+ field_name
1620
+ for field_name in self.db_structure[p_table_name]
1621
+ if self.db_structure[p_table_name][field_name]["Params"]["PrimaryKey"][0] == "Y"
1622
+ ]
1623
+ if not primary_key_fields:
1624
+ return f"{insert_sql} ON CONFLICT DO NOTHING"
1625
+ update_fields = [field_name for field_name in p_header if field_name not in primary_key_fields]
1626
+ if not update_fields:
1627
+ return f"{insert_sql} ON CONFLICT ({self.quote_identifier_list(primary_key_fields)}) DO NOTHING"
1628
+ update_sql = ",".join(
1629
+ [
1630
+ f"{self.quote_identifier(field_name)} = EXCLUDED.{self.quote_identifier(field_name)}"
1631
+ for field_name in update_fields
1632
+ ]
1633
+ )
1634
+ return f"{insert_sql} ON CONFLICT ({self.quote_identifier_list(primary_key_fields)}) DO UPDATE SET {update_sql}"
1635
+
1636
+ def create_db(self):
1637
+ """Create the PostgreSQL database according to self.db_structure."""
1638
+ self.cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (self.db_name,))
1639
+ if self.cur.fetchone():
1640
+ try:
1641
+ self.cur.execute(f"DROP DATABASE {self.quote_identifier(self.db_name)} WITH (FORCE)")
1642
+ except self.db_error as err:
1643
+ self._print_err_msg(err, "Could not drop the database")
1644
+ self.close()
1645
+ sys.exit()
1343
1646
  try:
1344
- self.host_name = "156.38.224.15,1433"
1345
- self.user_name = "chessaco_chessanew"
1346
- self._password = "@Jv&F77%"
1347
- self.db_name = "chessaco_analytics"
1348
- self.driver = "{ODBC Driver 17 for SQL Server}"
1349
- # driver = pyodbc.drivers()
1350
- # con_str_1 = 'DRIVER={};SERVER={};DATABASE={};UID={};PWD={}'.format( self.driver, self.host_name, self.db_name, self.user_name, self._password )
1351
- con_str_2 = (
1352
- "DRIVER={ODBC Driver 17 for SQL Server};SERVER="
1353
- + self.host_name
1354
- + ";DATABASE="
1355
- + self.db_name
1356
- + ";UID="
1357
- + self.user_name
1358
- + ";PWD="
1359
- + self._password
1360
- )
1361
- self.conn = pyodbc.connect(con_str_2)
1362
- pass
1363
- except Error as err:
1364
- self.logger.error(err)
1365
- self.success = self.conn.is_connected()
1366
- if self.conn.is_connected():
1647
+ self.cur.execute(f"CREATE DATABASE {self.quote_identifier(self.db_name)}")
1648
+ self.close()
1649
+ self.conn = self._connect(self.db_name)
1650
+ self.conn.autocommit = True
1367
1651
  self.cur = self.conn.cursor()
1368
- if self.re_create_db:
1369
- self.create_db()
1370
- self.success = self.create_tables()
1652
+ except self.db_error as err:
1653
+ self._print_err_msg(err, "Could not create the database")
1654
+ self.close()
1655
+ sys.exit()
1656
+ return True
1657
+
1658
+ def render_default_sql(self, p_field_type, p_default_value):
1659
+ """Render a PostgreSQL column default expression."""
1660
+ if p_field_type[0] == "varchar" or p_field_type[0] == "char":
1661
+ escaped_value = str(p_default_value).replace("'", "''")
1662
+ return f" DEFAULT '{escaped_value}'"
1663
+ return f" DEFAULT {p_default_value}"
1664
+
1665
+ def render_field_type(self, p_field_type, p_field_params):
1666
+ """Render a legacy field type as a PostgreSQL type."""
1667
+ field_type = p_field_type[0]
1668
+ if p_field_params["AI"] == "Y":
1669
+ if field_type == "bigint":
1670
+ return "BIGSERIAL"
1671
+ return "SERIAL"
1672
+ type_map = {
1673
+ "bigint": "BIGINT",
1674
+ "blob": "BYTEA",
1675
+ "boolean": "BOOLEAN",
1676
+ "date": "DATE",
1677
+ "datetime": "TIMESTAMP",
1678
+ "int": "INTEGER",
1679
+ "tinyint": "SMALLINT",
1680
+ "time": "TIME",
1681
+ }
1682
+ if field_type == "varchar" or field_type == "char":
1683
+ return f"{field_type.upper()}({str(p_field_type[1])})"
1684
+ if field_type == "decimal":
1685
+ return f"DECIMAL({str(p_field_type[1])}, {str(p_field_type[2])})"
1686
+ return type_map.get(field_type, field_type.upper())
1687
+
1688
+ def _connect(self, p_db_name):
1689
+ """Open a PostgreSQL connection."""
1690
+ return psycopg.connect(
1691
+ host=self.host_name,
1692
+ user=self.user_name,
1693
+ password=self._password,
1694
+ dbname=p_db_name,
1695
+ port=self.db_port,
1696
+ **self._connection_kwargs,
1697
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SQLDbWrpr
3
- Version: 5.0.0
3
+ Version: 5.1.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
@@ -8,23 +8,26 @@ Author: Hendrik du Toit
8
8
  Author-email: hendrik@brightedge.co.za
9
9
  Maintainer: Hendrik du Toit
10
10
  Maintainer-email: hendrikdt@citiqprepaid.co.za
11
- Requires-Python: >=3.10
12
- Classifier: Development Status :: 4 - Beta
13
- Classifier: Intended Audience :: Developers
14
- Classifier: Intended Audience :: Information Technology
15
- Classifier: Topic :: Software Development
16
- Classifier: Topic :: Text Processing
17
- Classifier: Topic :: Utilities
11
+ Requires-Python: >=3.10,<3.15
12
+ Classifier: Development Status :: 6 - Mature
13
+ Classifier: Framework :: Pydantic :: 2
14
+ Classifier: Framework :: Pytest
18
15
  Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Operating System :: iOS
19
19
  Classifier: Programming Language :: Python :: 3.10
20
20
  Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
25
  Requires-Dist: csvwrpr (>=1.16.1,<2.0.0)
24
26
  Requires-Dist: fixdate (>=2.1.0,<3.0.0)
25
27
  Requires-Dist: mysql-connector (>=2.2.9,<3.0.0)
28
+ Requires-Dist: psycopg[binary] (>=3.2.0,<4.0.0)
26
29
  Requires-Dist: pydantic-settings (>=2.12.0,<3.0.0)
27
- Requires-Dist: pyodbc (>=5.3.0,<6.0.0)
30
+ Requires-Dist: sqlalchemy (>=2.0.0,<3.0.0)
28
31
  Project-URL: Homepage, https://github.com/BrightEdgeeServices/SqlDbWrpr
29
32
  Project-URL: Issues, https://github.com/BrightEdgeeServices/SqlDbWrpr/issues
30
33
  Project-URL: Repository, https://github.com/BrightEdgeeServices/SqlDbWrpr.git
@@ -40,7 +43,170 @@ Description-Content-Type: text/markdown
40
43
  | PyPI | [![][pypi_release_img]][pypi_release_lnk] [![][pypi_py_versions_img]][pypi_py_versions_lnk] [![][pypi_format_img]][pypi_format_lnk] [![][pypi_downloads_img]][pypi_downloads_lnk] |
41
44
  | Github | [![][gh_issues_img]][gh_issues_lnk] [![][gh_language_img]][gh_language_lnk] [![][gh_last_commit_img]][gh_last_commit_lnk] [![][gh_deployment_img]][gh_deployment_lnk] |
42
45
 
43
- ## Overview
46
+ ## Short description
47
+
48
+ SqlDbWrpr is a Python wrapper that streamlines schema creation plus CSV import/export workflows for SQL backends.
49
+
50
+ ## Module Overview
51
+
52
+ SqlDbWrpr is a Python utility for creating SQL database schemas and moving CSV data into and out of those schemas. It currently provides wrappers for MySQL and PostgreSQL.
53
+
54
+ Schemas can be supplied in two ways:
55
+
56
+ 1. A legacy `db_structure` dictionary.
57
+ 2. SQLAlchemy metadata, either directly through `p_sqlalchemy_metadata` or through `p_sqlalchemy_base.metadata`.
58
+
59
+ When both are supplied, `p_db_structure` takes precedence for backward compatibility. If no supported schema source is supplied, `SchemaSourceError` is raised.
60
+
61
+ ### Key Features
62
+
63
+ - **Schema Management**: Create databases, tables, primary keys, foreign keys, and indexes from a legacy dictionary or SQLAlchemy metadata.
64
+ - **Data Import/Export**:
65
+ - Import CSV data from files or in-memory rows, including single-volume and numbered multi-volume files.
66
+ - Export full tables or custom SQL query results to CSV, including optional multi-volume exports.
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.
69
+ - **Batch Processing**: Configure import batch sizes for larger CSV loads.
70
+
71
+ ### Project Structure
72
+
73
+ - `src/sqldbwrpr/`: Core library implementation, including MySQL and PostgreSQL wrappers.
74
+ - `tests/`: Unit and integration-oriented test coverage for wrapper behaviour.
75
+ - `scripts/`: SQL setup/bootstrap assets used for database initialization.
76
+ - Root automation scripts (`*.ps1`) and CI configuration under `.github/workflows/` support setup and delivery.
77
+
78
+ ## Getting Started
79
+
80
+ ### Installation
81
+
82
+ ```bash
83
+ pip install SqlDbWrpr
84
+ ```
85
+
86
+ ### Quick Start With A Legacy Structure
87
+
88
+ ```python
89
+ from sqldbwrpr.sqldbwrpr import MySQL
90
+
91
+ field_defaults = {
92
+ "PrimaryKey": ["", ""],
93
+ "FKey": [],
94
+ "Index": [],
95
+ "NN": "",
96
+ "B": "",
97
+ "UN": "",
98
+ "ZF": "",
99
+ "AI": "",
100
+ "G": "",
101
+ "DEF": "",
102
+ }
103
+
104
+ db_structure = {
105
+ "Users": {
106
+ "ID": {
107
+ "Type": ["int"],
108
+ "Params": {
109
+ **field_defaults,
110
+ "PrimaryKey": ["Y", "A"],
111
+ "NN": "Y",
112
+ "AI": "Y",
113
+ },
114
+ "Possible Values": "",
115
+ "Comment": "",
116
+ },
117
+ "Username": {
118
+ "Type": ["varchar", 50],
119
+ "Params": {**field_defaults, "NN": "Y"},
120
+ "Possible Values": "",
121
+ "Comment": "",
122
+ },
123
+ }
124
+ }
125
+
126
+ db = MySQL(
127
+ p_host_name="localhost",
128
+ p_user_name="root",
129
+ p_password="yourpassword",
130
+ p_db_name="my_database",
131
+ p_db_structure=db_structure,
132
+ p_recreate_db=True,
133
+ )
134
+
135
+ db.import_csv("Users", p_csv_db=[("Username",), ("alice",)], p_vol_type="Single")
136
+ db.export_to_csv("exported_users.csv", "Users")
137
+ ```
138
+
139
+ ### Quick Start With SQLAlchemy Metadata
140
+
141
+ ```python
142
+ from sqlalchemy import Column
143
+ from sqlalchemy import Integer
144
+ from sqlalchemy import MetaData
145
+ from sqlalchemy import String
146
+ from sqlalchemy import Table
147
+
148
+ from sqldbwrpr.sqldbwrpr import PostgreSQL
149
+
150
+ metadata = MetaData()
151
+ Table(
152
+ "Users",
153
+ metadata,
154
+ Column("ID", Integer, primary_key=True, autoincrement=True),
155
+ Column("Username", String(50), nullable=False),
156
+ )
157
+
158
+ db = PostgreSQL(
159
+ p_host_name="localhost",
160
+ p_user_name="postgres",
161
+ p_password="yourpassword",
162
+ p_db_name="my_database",
163
+ p_sqlalchemy_metadata=metadata,
164
+ p_recreate_db=True,
165
+ )
166
+
167
+ db.import_csv("Users", p_csv_db=[("Username",), ("alice",)], p_vol_type="Single")
168
+ ```
169
+
170
+ ______________________________________________________________________
171
+
172
+ ## Updating ReleaseNotes Instructions
173
+
174
+ 1. Run the `pushpy.ps1` script or manually commit the current changes.
175
+ 2. Generate the release notes
176
+ 3. Use one of the following AI propmpts in Notion to generate the release notes.
177
+
178
+ - [Release - Update - General](https://www.notion.so/Release-Update-General-2c0bc8e6c6f38076b4cee82e3cf243fa?v=2c0bc8e6c6f3806e85db000c395f94ce&source=copy_link)
179
+ - [Release - Update - VenvIt](https://www.notion.so/Release-Update-VenvIt-2c0bc8e6c6f380de84a0f3fbb8b5dda5?v=2c0bc8e6c6f3806e85db000c395f94ce&source=copy_link)
180
+
181
+ or
182
+
183
+ 1. Use the following template and manually update the ReleaseNotes.md file.
184
+
185
+ ```
186
+ # Release ?.?.?
187
+ ## Summary of Changes
188
+ - bla, bla, bla
189
+ ## Next Heading
190
+ - bla, bla, bla
191
+ ---
192
+ ```
193
+
194
+ 2. You can repeat step 1 multiple times.
195
+
196
+ 3. You can repeat step 2 multiple times but update the ReleaseNotes that has not been published.
197
+
198
+ 4. Run the `pushpr.ps1` script once you are ready to create the PR to publish the release. TOy can also manually create
199
+ the tag, touch a file, commit and push the changes.
200
+
201
+ 5. Merge the PR in GitHub.
202
+
203
+ 6. Confirm the following:
204
+
205
+ 7. The release update reflects in GitHub
206
+
207
+ 8. The release update notification was sent
208
+
209
+ ______________________________________________________________________
44
210
 
45
211
  [cicd_codestyle_img]: https://img.shields.io/badge/code%20style-black-000000.svg "Black"
46
212
  [cicd_codestyle_lnk]: https://github.com/psf/black "Black"
@@ -0,0 +1,6 @@
1
+ sqldbwrpr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ sqldbwrpr/sqldbwrpr.py,sha256=m17qvof-ffgzAj475f-qmT1_bwCDLVto0sPDcvnINTo,74541
3
+ sqldbwrpr-5.1.0.dist-info/METADATA,sha256=E-RVCjEMgjVdW-mBTEklHtSrGeElGi5zkCeNqZDBV4o,11042
4
+ sqldbwrpr-5.1.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
5
+ sqldbwrpr-5.1.0.dist-info/licenses/LICENSE.txt,sha256=IA9VQhUiFRCmo0yWTjYyjM37Ldm-rjBno-D0AW12UBs,1149
6
+ sqldbwrpr-5.1.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.2.1
2
+ Generator: poetry-core 2.4.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,6 +0,0 @@
1
- sqldbwrpr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- sqldbwrpr/sqldbwrpr.py,sha256=ufjneZ6W3akmC87_HJ1ZOFOGEmfZD76fBx9dvsQAmIU,59224
3
- sqldbwrpr-5.0.0.dist-info/METADATA,sha256=SuWw8-yHoQG-ijNe5QDhqbMJfdwlBhvUUGDGZqLdM1E,5919
4
- sqldbwrpr-5.0.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
5
- sqldbwrpr-5.0.0.dist-info/licenses/LICENSE.txt,sha256=IA9VQhUiFRCmo0yWTjYyjM37Ldm-rjBno-D0AW12UBs,1149
6
- sqldbwrpr-5.0.0.dist-info/RECORD,,