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.
- {core_db-3.2.0 → core_db-3.2.1}/PKG-INFO +9 -4
- {core_db-3.2.0 → core_db-3.2.1}/README.rst +5 -1
- {core_db-3.2.0 → core_db-3.2.1}/core_db/__init__.py +1 -5
- {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/__init__.py +0 -2
- {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/db2.py +31 -41
- {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/mongo.py +38 -42
- {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/mssql.py +32 -40
- {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/mysql.py +14 -26
- {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/oracle.py +66 -101
- {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/postgres.py +11 -25
- {core_db-3.2.0 → core_db-3.2.1}/core_db/engines/snowflake_.py +70 -62
- {core_db-3.2.0 → core_db-3.2.1}/core_db/etls/__init__.py +0 -3
- {core_db-3.2.0 → core_db-3.2.1}/core_db/etls/database_based.py +17 -24
- {core_db-3.2.0 → core_db-3.2.1}/core_db/interfaces/__init__.py +1 -5
- {core_db-3.2.0 → core_db-3.2.1}/core_db/interfaces/base.py +9 -13
- {core_db-3.2.0 → core_db-3.2.1}/core_db/interfaces/sql_based.py +131 -65
- {core_db-3.2.0 → core_db-3.2.1}/core_db.egg-info/PKG-INFO +9 -4
- {core_db-3.2.0 → core_db-3.2.1}/core_db.egg-info/requires.txt +2 -2
- {core_db-3.2.0 → core_db-3.2.1}/pyproject.toml +9 -3
- core_db-3.2.1/setup.py +5 -0
- core_db-3.2.0/setup.py +0 -6
- {core_db-3.2.0 → core_db-3.2.1}/LICENSE +0 -0
- {core_db-3.2.0 → core_db-3.2.1}/core_db/py.typed +0 -0
- {core_db-3.2.0 → core_db-3.2.1}/core_db.egg-info/SOURCES.txt +0 -0
- {core_db-3.2.0 → core_db-3.2.1}/core_db.egg-info/dependency_links.txt +0 -0
- {core_db-3.2.0 → core_db-3.2.1}/core_db.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
42
|
-
Requires-Dist: core-tests>=2.
|
|
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/
|
|
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/
|
|
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
|
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
|
|
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:
|
|
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(
|
|
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) ->
|
|
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 {}
|
|
178
|
+
return result if isinstance(result, dict) else {}
|
|
174
179
|
|
|
175
|
-
def fetch_one(self) ->
|
|
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[
|
|
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[
|
|
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:
|
|
206
|
-
columns:
|
|
207
|
-
records:
|
|
208
|
-
) ->
|
|
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 "",
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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:
|
|
87
|
-
session:
|
|
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:
|
|
109
|
-
projection:
|
|
101
|
+
filters: dict | None = None,
|
|
102
|
+
projection: dict | None = None,
|
|
110
103
|
*args,
|
|
111
104
|
**kwargs,
|
|
112
|
-
) ->
|
|
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(
|
|
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:
|
|
146
|
-
projection:
|
|
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(
|
|
178
|
-
|
|
179
|
-
|
|
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:
|
|
180
|
+
document: dict,
|
|
185
181
|
bypass_document_validation: bool = False,
|
|
186
|
-
session:
|
|
187
|
-
comment:
|
|
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:
|
|
221
|
+
documents: list[dict],
|
|
226
222
|
ordered: bool = True,
|
|
227
223
|
bypass_document_validation: bool = False,
|
|
228
|
-
session:
|
|
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:
|
|
271
|
-
collation:
|
|
266
|
+
filter_query: dict,
|
|
267
|
+
collation: Collation | None = None,
|
|
272
268
|
hint=None,
|
|
273
|
-
session:
|
|
274
|
-
let:
|
|
275
|
-
comment:
|
|
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:
|
|
317
|
-
collation:
|
|
312
|
+
filter_query: dict,
|
|
313
|
+
collation: Collation | None = None,
|
|
318
314
|
hint=None,
|
|
319
|
-
session:
|
|
320
|
-
let:
|
|
321
|
-
comment:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
109
|
-
columns:
|
|
110
|
-
records:
|
|
111
|
-
) ->
|
|
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 "",
|
|
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(
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
184
|
-
records:
|
|
185
|
-
epoch_column:
|
|
186
|
-
) ->
|
|
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 "",
|
|
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}`,
|
|
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
|
-
({
|
|
222
|
+
({", ".join([f"`{col}`" for col in columns])})
|
|
227
223
|
VALUES {values_rows}
|
|
228
224
|
ON DUPLICATE KEY UPDATE
|
|
229
|
-
{
|
|
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,
|
|
227
|
+
return query, cls._flatten_params(records, columns)
|