core-db 3.2.0__tar.gz → 3.2.1__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.
Files changed (27) hide show
  1. {core_db-3.2.0 → core_db-3.2.1}/PKG-INFO +9 -4
  2. {core_db-3.2.0 → core_db-3.2.1}/README.rst +5 -1
  3. {core_db-3.2.0 → core_db-3.2.1}/core_db/__init__.py +1 -5
  4. {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/__init__.py +0 -2
  5. {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/db2.py +31 -41
  6. {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/mongo.py +38 -42
  7. {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/mssql.py +32 -40
  8. {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/mysql.py +14 -26
  9. {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/oracle.py +66 -101
  10. {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/postgres.py +11 -25
  11. {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/snowflake_.py +70 -62
  12. {core_db-3.2.0 → core_db-3.2.1}/core_db/etls/__init__.py +0 -3
  13. {core_db-3.2.0 → core_db-3.2.1}/core_db/etls/database_based.py +17 -24
  14. {core_db-3.2.0 → core_db-3.2.1}/core_db/interfaces/__init__.py +1 -5
  15. {core_db-3.2.0 → core_db-3.2.1}/core_db/interfaces/base.py +9 -13
  16. {core_db-3.2.0 → core_db-3.2.1}/core_db/interfaces/sql_based.py +131 -65
  17. {core_db-3.2.0 → core_db-3.2.1}/core_db.egg-info/PKG-INFO +9 -4
  18. {core_db-3.2.0 → core_db-3.2.1}/core_db.egg-info/requires.txt +2 -2
  19. {core_db-3.2.0 → core_db-3.2.1}/pyproject.toml +9 -3
  20. core_db-3.2.1/setup.py +5 -0
  21. core_db-3.2.0/setup.py +0 -6
  22. {core_db-3.2.0 → core_db-3.2.1}/LICENSE +0 -0
  23. {core_db-3.2.0 → core_db-3.2.1}/core_db/py.typed +0 -0
  24. {core_db-3.2.0 → core_db-3.2.1}/core_db.egg-info/SOURCES.txt +0 -0
  25. {core_db-3.2.0 → core_db-3.2.1}/core_db.egg-info/dependency_links.txt +0 -0
  26. {core_db-3.2.0 → core_db-3.2.1}/core_db.egg-info/top_level.txt +0 -0
  27. {core_db-3.2.0 → core_db-3.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: core-db
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: This project/library contains common elements and clients related to database engines...
5
5
  Author-email: Alejandro Cora González <alek.cora.glez@gmail.com>
6
6
  Maintainer: Alejandro Cora González
@@ -23,6 +23,7 @@ Classifier: Programming Language :: Python :: 3.12
23
23
  Classifier: Programming Language :: Python :: 3.13
24
24
  Classifier: Programming Language :: Python :: 3.14
25
25
  Classifier: Programming Language :: Python :: Implementation :: PyPy
26
+ Classifier: Typing :: Typed
26
27
  Requires-Python: >=3.9
27
28
  Description-Content-Type: text/x-rst
28
29
  License-File: LICENSE
@@ -38,8 +39,8 @@ Requires-Dist: pymongo>=4.6.0; extra == "all"
38
39
  Requires-Dist: ibm-db>=3.2.3; extra == "all"
39
40
  Requires-Dist: snowflake-connector-python>=3.13.1; extra == "all"
40
41
  Provides-Extra: dev
41
- Requires-Dist: core-dev-tools>=2.0.0; extra == "dev"
42
- Requires-Dist: core-tests>=2.1.0; extra == "dev"
42
+ Requires-Dist: core-dev-tools>=2.1.0; extra == "dev"
43
+ Requires-Dist: core-tests>=2.3.0; extra == "dev"
43
44
  Requires-Dist: types-ibm-db>=3.2.3.20241212; extra == "dev"
44
45
  Provides-Extra: mysql
45
46
  Requires-Dist: PyMySQL>=1.1.1; extra == "mysql"
@@ -66,12 +67,16 @@ provides clients to simplify the connections...
66
67
 
67
68
  ===============================================================================
68
69
 
70
+ .. image:: https://static.pepy.tech/personalized-badge/core-db?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads
71
+ :target: https://pepy.tech/projects/core-db
72
+ :alt: PyPI Downloads
73
+
69
74
  .. image:: https://img.shields.io/pypi/pyversions/core-db.svg
70
75
  :target: https://pypi.org/project/core-db/
71
76
  :alt: Python Versions
72
77
 
73
78
  .. image:: https://img.shields.io/badge/license-MIT-blue.svg
74
- :target: https://gitlab.com/bytecode-solutions/core/core-db/-/blob/main/LICENSE
79
+ :target: https://gitlab.com/bytecode-solutions/core/core-db/-/blob/master/LICENSE
75
80
  :alt: License
76
81
 
77
82
  .. image:: https://gitlab.com/bytecode-solutions/core/core-db/badges/release/pipeline.svg
@@ -6,12 +6,16 @@ provides clients to simplify the connections...
6
6
 
7
7
  ===============================================================================
8
8
 
9
+ .. image:: https://static.pepy.tech/personalized-badge/core-db?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads
10
+ :target: https://pepy.tech/projects/core-db
11
+ :alt: PyPI Downloads
12
+
9
13
  .. image:: https://img.shields.io/pypi/pyversions/core-db.svg
10
14
  :target: https://pypi.org/project/core-db/
11
15
  :alt: Python Versions
12
16
 
13
17
  .. image:: https://img.shields.io/badge/license-MIT-blue.svg
14
- :target: https://gitlab.com/bytecode-solutions/core/core-db/-/blob/main/LICENSE
18
+ :target: https://gitlab.com/bytecode-solutions/core/core-db/-/blob/master/LICENSE
15
19
  :alt: License
16
20
 
17
21
  .. image:: https://gitlab.com/bytecode-solutions/core/core-db/badges/release/pipeline.svg
@@ -1,12 +1,8 @@
1
- # -*- coding: utf-8 -*-
2
-
3
1
  """
4
2
  Provides common elements and clients related to database engines.
5
3
  """
6
4
 
7
- from importlib.metadata import PackageNotFoundError
8
- from importlib.metadata import version
9
-
5
+ from importlib.metadata import PackageNotFoundError, version
10
6
 
11
7
  try:
12
8
  __version__ = version("core-db")
@@ -1,5 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
-
3
1
  """
4
2
  Database Engine Clients Module
5
3
  ================================
@@ -1,5 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
-
3
1
  """
4
2
  IBM DB2 Database Client Module
5
3
  ================================
@@ -8,13 +6,10 @@ This module provides the Db2Client class for connecting to and interacting
8
6
  with IBM DB2 databases using the ibm_db library.
9
7
  """
10
8
 
11
- import json
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterator
12
12
  from typing import Any
13
- from typing import Dict
14
- from typing import Iterator
15
- from typing import List
16
- from typing import Optional
17
- from typing import Tuple
18
13
 
19
14
  import ibm_db
20
15
 
@@ -73,12 +68,16 @@ class Db2Client(ISqlDatabaseClient):
73
68
  Returns a wrapper object that mimics cursor behavior.
74
69
  """
75
70
 
76
- class StatementWrapper:
71
+ class StatementWrapper: # pylint: disable=too-few-public-methods
72
+ """Adapts an ibm_db statement handle to expose a cursor-like `rowcount`."""
73
+
77
74
  def __init__(self, statement):
78
75
  self._statement = statement
79
76
 
80
77
  @property
81
78
  def rowcount(self):
79
+ """Number of rows affected by the last statement, or 0 if none was run."""
80
+
82
81
  if self._statement:
83
82
  return ibm_db.num_rows(self._statement)
84
83
  return 0
@@ -114,7 +113,7 @@ class Db2Client(ISqlDatabaseClient):
114
113
  except Exception as error:
115
114
  raise DatabaseClientException(error) from error
116
115
 
117
- def test_connection(self, query: Optional[str] = None) -> Any:
116
+ def test_connection(self, query: str | None = None) -> Any:
118
117
  """
119
118
  Test the database connection by executing a simple query.
120
119
 
@@ -145,8 +144,14 @@ class Db2Client(ISqlDatabaseClient):
145
144
  if params:
146
145
  # Use prepared statement with parameter binding
147
146
  self.statement = ibm_db.prepare(self.cxn, query)
147
+ if isinstance(self.statement, bool):
148
+ error = ibm_db.conn_error(self.cxn)
149
+ raise DatabaseClientException(f"Failed to prepare query: {error}")
150
+
148
151
  if not ibm_db.execute(self.statement, params):
149
- raise DatabaseClientException(f"Failed to execute query: {ibm_db.stmt_error()}")
152
+ raise DatabaseClientException(
153
+ f"Failed to execute query: {ibm_db.stmt_error()}"
154
+ )
150
155
 
151
156
  else:
152
157
  # Use immediate execution for non-parameterized queries
@@ -163,23 +168,23 @@ class Db2Client(ISqlDatabaseClient):
163
168
 
164
169
  ibm_db.commit(self.cxn)
165
170
 
166
- def fetch_record(self) -> Dict[str, Any]:
171
+ def fetch_record(self) -> dict[str, Any]:
167
172
  """
168
173
  Fetch the next row as a dictionary with column names as keys.
169
174
  :return: Dictionary representing a single row, or empty dict if no more rows.
170
175
  """
171
176
 
172
177
  result = ibm_db.fetch_assoc(self.statement)
173
- return result if result else {} # type: ignore[return-value]
178
+ return result if isinstance(result, dict) else {}
174
179
 
175
- def fetch_one(self) -> Tuple:
180
+ def fetch_one(self) -> tuple:
176
181
  """
177
182
  Fetch the next row as a tuple.
178
183
  :return: Tuple representing a single row.
179
184
  """
180
185
  return ibm_db.fetch_tuple(self.statement)
181
186
 
182
- def fetch_records(self) -> Iterator[Dict[str, Any]]:
187
+ def fetch_records(self) -> Iterator[dict[str, Any]]:
183
188
  """
184
189
  Fetch all remaining rows as an iterator of dictionaries.
185
190
  :return: Iterator yielding dictionaries with column names as keys.
@@ -189,7 +194,7 @@ class Db2Client(ISqlDatabaseClient):
189
194
  if isinstance(row_, dict): # Type guard to ensure row_ is dict, not bool
190
195
  yield row_
191
196
 
192
- def fetch_all(self) -> Iterator[Tuple]:
197
+ def fetch_all(self) -> Iterator[tuple]:
193
198
  """
194
199
  Fetch all remaining rows as an iterator of tuples.
195
200
  :return: Iterator yielding tuples representing rows.
@@ -199,13 +204,13 @@ class Db2Client(ISqlDatabaseClient):
199
204
  yield row_
200
205
 
201
206
  @classmethod
202
- def get_merge_dml(
207
+ def get_merge_dml( # pylint: disable=arguments-differ,too-many-locals,duplicate-code
203
208
  cls,
204
209
  table_fqn: str,
205
- pk_ids: List[str],
206
- columns: List[str],
207
- records: List[Dict],
208
- ) -> Tuple[str, Tuple]:
210
+ pk_ids: list[str],
211
+ columns: list[str],
212
+ records: list[dict],
213
+ ) -> tuple[str, tuple]:
209
214
  """
210
215
  Generate parameterized MERGE statement for IBM DB2. Uses parameter
211
216
  binding to prevent SQL injection attacks.
@@ -220,7 +225,7 @@ class Db2Client(ISqlDatabaseClient):
220
225
  """
221
226
 
222
227
  if not records:
223
- return "", tuple()
228
+ return "", ()
224
229
 
225
230
  cls.validate_identifier(columns + pk_ids)
226
231
 
@@ -231,16 +236,9 @@ class Db2Client(ISqlDatabaseClient):
231
236
  # Create source columns list
232
237
  source_columns = ", ".join(columns)
233
238
 
234
- # Building the ON clause for matching
235
- on_conditions = " AND ".join([f"target.{pk} = source.{pk}" for pk in pk_ids])
236
-
237
- # Building UPDATE SET statement
238
- update_columns = [col for col in columns if col not in pk_ids]
239
- set_statement = ", ".join([f"target.{col} = source.{col}" for col in update_columns])
240
-
241
- # Building INSERT statement
242
- insert_columns = ", ".join(columns)
243
- insert_values = ", ".join([f"source.{col}" for col in columns])
239
+ on_conditions, set_statement, insert_columns, insert_values = (
240
+ cls._build_merge_clauses(pk_ids, columns)
241
+ )
244
242
 
245
243
  # SQL construction is safe: columns are validated, values use placeholders...
246
244
  query = f"""
@@ -253,15 +251,7 @@ class Db2Client(ISqlDatabaseClient):
253
251
  INSERT ({insert_columns})
254
252
  VALUES ({insert_values})""" # nosec B608
255
253
 
256
- # Extracting parameters in the correct order for each record
257
- params: List = []
258
- for record in records:
259
- params.extend([
260
- json.dumps(record[col]) if type(record[col]) in [dict, list] else record[col]
261
- for col in columns
262
- ])
263
-
264
- return query, tuple(params)
254
+ return query, cls._flatten_params(records, columns)
265
255
 
266
256
  def close(self):
267
257
  """
@@ -1,5 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
-
3
1
  """
4
2
  MongoDB Database Client Module
5
3
  ================================
@@ -8,22 +6,17 @@ This module provides the MongoClient class for connecting to and interacting
8
6
  with MongoDB databases using the pymongo library.
9
7
  """
10
8
 
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterator, Mapping
11
12
  from typing import Any
12
- from typing import Dict
13
- from typing import Iterator
14
- from typing import List
15
- from typing import Mapping
16
- from typing import Optional
17
13
 
18
14
  from pymongo import MongoClient as PyMongoClient
19
15
  from pymongo.client_session import ClientSession
20
16
  from pymongo.collation import Collation
21
- from pymongo.results import DeleteResult
22
- from pymongo.results import InsertManyResult
23
- from pymongo.results import InsertOneResult
17
+ from pymongo.results import DeleteResult, InsertManyResult, InsertOneResult
24
18
 
25
- from core_db.interfaces.base import DatabaseClientException
26
- from core_db.interfaces.base import IDatabaseClient
19
+ from core_db.interfaces.base import DatabaseClientException, IDatabaseClient
27
20
 
28
21
 
29
22
  class MongoClient(IDatabaseClient):
@@ -83,8 +76,8 @@ class MongoClient(IDatabaseClient):
83
76
 
84
77
  def test_connection(
85
78
  self,
86
- query: Optional[str] = None,
87
- session: Optional[ClientSession] = None,
79
+ query: str | None = None,
80
+ session: ClientSession | None = None,
88
81
  ):
89
82
  """
90
83
  Test the database connection by retrieving server
@@ -102,14 +95,14 @@ class MongoClient(IDatabaseClient):
102
95
  raise DatabaseClientException("No active connection")
103
96
  return self.cxn.server_info(session=session)
104
97
 
105
- def find_one(
98
+ def find_one( # pylint: disable=keyword-arg-before-vararg
106
99
  self,
107
100
  collection_name: str,
108
- filters: Optional[Dict] = None,
109
- projection: Optional[Dict] = None,
101
+ filters: dict | None = None,
102
+ projection: dict | None = None,
110
103
  *args,
111
104
  **kwargs,
112
- ) -> Dict:
105
+ ) -> dict:
113
106
  """
114
107
  Get a single document from the database. All arguments to find() are also
115
108
  valid arguments for find_one(), although any limit argument will be ignored. Returns
@@ -137,13 +130,15 @@ class MongoClient(IDatabaseClient):
137
130
  if not projection:
138
131
  projection = {}
139
132
 
140
- return getattr(self.db, collection_name).find_one(filters, projection, *args, **kwargs)
133
+ return getattr(self.db, collection_name).find_one(
134
+ filters, projection, *args, **kwargs
135
+ )
141
136
 
142
- def find(
137
+ def find( # pylint: disable=keyword-arg-before-vararg
143
138
  self,
144
139
  collection_name: str,
145
- filters: Optional[Dict] = None,
146
- projection: Optional[Dict] = None,
140
+ filters: dict | None = None,
141
+ projection: dict | None = None,
147
142
  *args,
148
143
  **kwargs,
149
144
  ) -> Iterator:
@@ -174,17 +169,18 @@ class MongoClient(IDatabaseClient):
174
169
  if not projection:
175
170
  projection = {}
176
171
 
177
- cursor = getattr(self.db, collection_name).find(filters, projection, *args, **kwargs)
178
- for record in cursor:
179
- yield record
172
+ cursor = getattr(self.db, collection_name).find(
173
+ filters, projection, *args, **kwargs
174
+ )
175
+ yield from cursor
180
176
 
181
177
  def insert_one(
182
178
  self,
183
179
  collection_name: str,
184
- document: Dict,
180
+ document: dict,
185
181
  bypass_document_validation: bool = False,
186
- session: Optional[ClientSession] = None,
187
- comment: Optional[str] = None,
182
+ session: ClientSession | None = None,
183
+ comment: str | None = None,
188
184
  ) -> InsertOneResult:
189
185
  """
190
186
  This is a method by which we can insert a single entry within
@@ -222,10 +218,10 @@ class MongoClient(IDatabaseClient):
222
218
  def insert_many(
223
219
  self,
224
220
  collection_name: str,
225
- documents: List[Dict],
221
+ documents: list[dict],
226
222
  ordered: bool = True,
227
223
  bypass_document_validation: bool = False,
228
- session: Optional[ClientSession] = None,
224
+ session: ClientSession | None = None,
229
225
  ) -> InsertManyResult:
230
226
  """
231
227
  This method is used to insert multiple entries in a collection or the
@@ -264,15 +260,15 @@ class MongoClient(IDatabaseClient):
264
260
  session=session,
265
261
  )
266
262
 
267
- def delete_one(
263
+ def delete_one( # pylint: disable=too-many-arguments,too-many-positional-arguments
268
264
  self,
269
265
  collection_name: str,
270
- filter_query: Dict,
271
- collation: Optional[Collation] = None,
266
+ filter_query: dict,
267
+ collation: Collation | None = None,
272
268
  hint=None,
273
- session: Optional[ClientSession] = None,
274
- let: Optional[Mapping[str, Any]] = None,
275
- comment: Optional[Any] = None,
269
+ session: ClientSession | None = None,
270
+ let: Mapping[str, Any] | None = None,
271
+ comment: Any | None = None,
276
272
  ) -> DeleteResult:
277
273
  """
278
274
  To remove one document from the collection...
@@ -310,15 +306,15 @@ class MongoClient(IDatabaseClient):
310
306
  comment=comment,
311
307
  )
312
308
 
313
- def delete_many(
309
+ def delete_many( # pylint: disable=too-many-arguments,too-many-positional-arguments
314
310
  self,
315
311
  collection_name: str,
316
- filter_query: Dict,
317
- collation: Optional[Collation] = None,
312
+ filter_query: dict,
313
+ collation: Collation | None = None,
318
314
  hint=None,
319
- session: Optional[ClientSession] = None,
320
- let: Optional[Mapping[str, Any]] = None,
321
- comment: Optional[Any] = None,
315
+ session: ClientSession | None = None,
316
+ let: Mapping[str, Any] | None = None,
317
+ comment: Any | None = None,
322
318
  ) -> DeleteResult:
323
319
  """
324
320
  It is used when one needs to delete more than one document. A query
@@ -1,5 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
-
3
1
  """
4
2
  Microsoft SQL Server Database Client Module
5
3
  ============================================
@@ -8,13 +6,13 @@ This module provides the MsSqlClient class for connecting to and interacting
8
6
  with Microsoft SQL Server databases using the pyodbc library.
9
7
  """
10
8
 
11
- import json
12
- from typing import Dict
13
- from typing import List
14
- from typing import Optional
15
- from typing import Tuple
9
+ from __future__ import annotations
16
10
 
17
- import pyodbc # type: ignore
11
+ # pyodbc is a C extension that dynamically links against the system unixODBC
12
+ # library (libodbc.so.2). The CI linters job runs on a bare python:latest
13
+ # image that doesn't have it installed, so pylint's astroid can't import the
14
+ # module to introspect it and reports a false import-error there.
15
+ import pyodbc # type: ignore[import-not-found] # pylint: disable=import-error
18
16
 
19
17
  from core_db.interfaces.base import DatabaseClientException
20
18
  from core_db.interfaces.sql_based import ISqlDatabaseClient
@@ -32,10 +30,12 @@ class MsSqlClient(ISqlDatabaseClient):
32
30
 
33
31
  from core_db.engines.mssql import MsSqlClient
34
32
 
35
- with MsSqlClient(
36
- dsn="DRIVER={ODBC Driver 18 for SQL Server};SERVER=localhost;DATABASE=master;UID=SA;PWD=sOm3str0ngP@33w0rd;Encrypt=no",
37
- autocommit=True, timeout=5) as client:
33
+ dsn = (
34
+ "DRIVER={ODBC Driver 18 for SQL Server};SERVER=localhost;"
35
+ "DATABASE=master;UID=SA;PWD=sOm3str0ngP@33w0rd;Encrypt=no"
36
+ )
38
37
 
38
+ with MsSqlClient(dsn=dsn, autocommit=True, timeout=5) as client:
39
39
  client.execute("SELECT @@VERSION AS 'version';")
40
40
  print(list(client.fetch_records()))
41
41
  ..
@@ -43,6 +43,7 @@ class MsSqlClient(ISqlDatabaseClient):
43
43
 
44
44
  PLACEHOLDER = "?"
45
45
 
46
+ # pylint: disable=line-too-long
46
47
  def __init__(self, **kwargs) -> None:
47
48
  """
48
49
  Expected -> dsn, autocommit, timeout
@@ -51,6 +52,7 @@ class MsSqlClient(ISqlDatabaseClient):
51
52
  - https://learn.microsoft.com/en-us/sql/connect/python/python-driver-for-sql-server?view=sql-server-ver16
52
53
  - https://learn.microsoft.com/en-us/sql/relational-databases/native-client/applications/using-connection-string-keywords-with-sql-server-native-client?view=sql-server-ver15&viewFallbackFrom=sql-server-ver16
53
54
  """
55
+ # pylint: enable=line-too-long
54
56
 
55
57
  super().__init__(**kwargs)
56
58
  self.connect_fcn = pyodbc.connect
@@ -68,13 +70,13 @@ class MsSqlClient(ISqlDatabaseClient):
68
70
 
69
71
  try:
70
72
  self.cxn = self.connect_fcn(
71
- self.cxn_parameters.pop("dsn", ""),
72
- **self.cxn_parameters)
73
+ self.cxn_parameters.pop("dsn", ""), **self.cxn_parameters
74
+ )
73
75
 
74
76
  except Exception as error:
75
77
  raise DatabaseClientException(error) from error
76
78
 
77
- def test_connection(self, query: Optional[str] = None):
79
+ def test_connection(self, query: str | None = None):
78
80
  """
79
81
  Test the database connection by executing a simple query.
80
82
 
@@ -96,19 +98,22 @@ class MsSqlClient(ISqlDatabaseClient):
96
98
  :return: Cursor object after execution.
97
99
  """
98
100
 
101
+ if not self.cursor:
102
+ raise DatabaseClientException("No active cursor!")
103
+
99
104
  params = kwargs.get("params", None)
100
105
  if params:
101
106
  return self.cursor.execute(query, params)
102
107
  return self.cursor.execute(query)
103
108
 
104
109
  @classmethod
105
- def get_merge_dml(
110
+ def get_merge_dml( # pylint: disable=arguments-differ,too-many-locals,duplicate-code
106
111
  cls,
107
112
  table_fqn: str,
108
- pk_ids: List[str],
109
- columns: List[str],
110
- records: List[Dict],
111
- ) -> Tuple[str, Tuple]:
113
+ pk_ids: list[str],
114
+ columns: list[str],
115
+ records: list[dict],
116
+ ) -> tuple[str, tuple]:
112
117
  """
113
118
  Generate parameterized MERGE statement for Microsoft SQL Server. Uses
114
119
  parameter binding to prevent SQL injection attacks.
@@ -123,7 +128,7 @@ class MsSqlClient(ISqlDatabaseClient):
123
128
  """
124
129
 
125
130
  if not records:
126
- return "", tuple()
131
+ return "", ()
127
132
 
128
133
  cls.validate_identifier(columns + pk_ids)
129
134
 
@@ -134,22 +139,17 @@ class MsSqlClient(ISqlDatabaseClient):
134
139
  # Remaining rows
135
140
  union_rows = []
136
141
  for _ in range(1, len(records)):
137
- union_rows.append(f"SELECT {', '.join([cls.PLACEHOLDER for _ in columns])}")
142
+ union_rows.append(
143
+ f"SELECT {', '.join([cls.PLACEHOLDER for _ in columns])}"
144
+ )
138
145
  using_clause = f"({first_row} UNION ALL {' UNION ALL '.join(union_rows)})"
139
146
  else:
140
147
  # Single record
141
148
  using_clause = f"(SELECT {', '.join([f'{cls.PLACEHOLDER} AS {col}' for col in columns])})"
142
149
 
143
- # Building the ON clause for matching
144
- on_conditions = " AND ".join([f"target.{pk} = source.{pk}" for pk in pk_ids])
145
-
146
- # Building UPDATE SET statement
147
- update_columns = [col for col in columns if col not in pk_ids]
148
- set_statement = ", ".join([f"target.{col} = source.{col}" for col in update_columns])
149
-
150
- # Building INSERT statement
151
- insert_columns = ", ".join(columns)
152
- insert_values = ", ".join([f"source.{col}" for col in columns])
150
+ on_conditions, set_statement, insert_columns, insert_values = (
151
+ cls._build_merge_clauses(pk_ids, columns)
152
+ )
153
153
 
154
154
  # SQL construction is safe: columns are validated, values use placeholders...
155
155
  query = f"""
@@ -162,12 +162,4 @@ class MsSqlClient(ISqlDatabaseClient):
162
162
  INSERT ({insert_columns})
163
163
  VALUES ({insert_values});""" # nosec B608
164
164
 
165
- # Extracting parameters in the correct order for each record
166
- params: List = []
167
- for record in records:
168
- params.extend([
169
- json.dumps(record[col]) if type(record[col]) in [dict, list] else record[col]
170
- for col in columns
171
- ])
172
-
173
- return query, tuple(params)
165
+ return query, cls._flatten_params(records, columns)
@@ -1,5 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
-
3
1
  """
4
2
  MySQL Database Client Module
5
3
  ==============================
@@ -8,14 +6,11 @@ This module provides the MySQLClient class for connecting to and interacting
8
6
  with MySQL databases using the pymysql library.
9
7
  """
10
8
 
11
- import json
9
+ from __future__ import annotations
10
+
12
11
  from typing import Any
13
- from typing import Dict
14
- from typing import List
15
- from typing import Optional
16
- from typing import Tuple
17
12
 
18
- import pymysql # type: ignore
13
+ import pymysql # type: ignore[import-untyped]
19
14
 
20
15
  from core_db.interfaces import DatabaseClientException
21
16
  from core_db.interfaces.sql_based import ISqlDatabaseClient
@@ -177,13 +172,13 @@ class MySQLClient(ISqlDatabaseClient):
177
172
  return self.cursor.execute(query, **args)
178
173
 
179
174
  @classmethod
180
- def get_merge_dml(
175
+ def get_merge_dml( # pylint: disable=arguments-differ
181
176
  cls,
182
177
  table_fqn: str,
183
- columns: List[str],
184
- records: List[Dict],
185
- epoch_column: Optional[str] = None,
186
- ) -> Tuple[str, Tuple]:
178
+ columns: list[str],
179
+ records: list[dict],
180
+ epoch_column: str | None = None,
181
+ ) -> tuple[str, tuple]:
187
182
  """
188
183
  Generate parameterized MERGE/UPSERT statement for MySQL using
189
184
  INSERT ... ON DUPLICATE KEY UPDATE. Uses parameter binding to
@@ -198,7 +193,7 @@ class MySQLClient(ISqlDatabaseClient):
198
193
  """
199
194
 
200
195
  if not records:
201
- return "", tuple()
196
+ return "", ()
202
197
 
203
198
  cls.validate_identifier(columns)
204
199
 
@@ -213,7 +208,8 @@ class MySQLClient(ISqlDatabaseClient):
213
208
  if epoch_column:
214
209
  # Only update if the new epoch value is greater
215
210
  on_duplicate = [
216
- f"`{col}` = IF(VALUES(`{epoch_column}`) > `{epoch_column}`, VALUES(`{col}`), `{col}`)"
211
+ f"`{col}` = IF(VALUES(`{epoch_column}`) > `{epoch_column}`, "
212
+ f"VALUES(`{col}`), `{col}`)"
217
213
  for col in columns
218
214
  ]
219
215
 
@@ -223,17 +219,9 @@ class MySQLClient(ISqlDatabaseClient):
223
219
  # SQL construction is safe: columns are validated, values use placeholders...
224
220
  query = f"""
225
221
  INSERT INTO {table_fqn}
226
- ({', '.join([f'`{col}`' for col in columns])})
222
+ ({", ".join([f"`{col}`" for col in columns])})
227
223
  VALUES {values_rows}
228
224
  ON DUPLICATE KEY UPDATE
229
- {', '.join(on_duplicate)}""" # nosec B608
230
-
231
- # Extract and flatten parameters in the correct order
232
- params = []
233
- for record in records:
234
- params.extend([
235
- json.dumps(record[col]) if type(record[col]) in [dict, list] else record[col]
236
- for col in columns
237
- ])
225
+ {", ".join(on_duplicate)}""" # nosec B608
238
226
 
239
- return query, tuple(params)
227
+ return query, cls._flatten_params(records, columns)