mysqlclient-client 1.0.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.
- mysqlclient_client/client.py +594 -0
- mysqlclient_client/query_by_key/query.py +63 -0
- mysqlclient_client/query_by_key/query_util.py +144 -0
- mysqlclient_client/query_by_key/settings.py +23 -0
- mysqlclient_client/settings.py +61 -0
- mysqlclient_client-1.0.0.dist-info/METADATA +296 -0
- mysqlclient_client-1.0.0.dist-info/RECORD +10 -0
- mysqlclient_client-1.0.0.dist-info/WHEEL +5 -0
- mysqlclient_client-1.0.0.dist-info/licenses/LICENSE +21 -0
- mysqlclient_client-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""database client"""
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import csv
|
|
5
|
+
import io
|
|
6
|
+
import queue
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import AsyncGenerator, Generator
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
|
|
12
|
+
import MySQLdb
|
|
13
|
+
import MySQLdb.cursors
|
|
14
|
+
|
|
15
|
+
from .query_by_key.query import Query
|
|
16
|
+
from .query_by_key.query_util import (
|
|
17
|
+
get_query_with_value,
|
|
18
|
+
)
|
|
19
|
+
from .query_by_key.settings import Settings as QrySettings
|
|
20
|
+
from .settings import Settings
|
|
21
|
+
|
|
22
|
+
connection = MySQLdb.Connection
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ClientPool:
|
|
26
|
+
"""database connection pool"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, db_settings_pool: Settings):
|
|
29
|
+
self.db_settings_pool = db_settings_pool
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self._pool: queue.Queue[MySQLdb.Connection] = queue.Queue(
|
|
32
|
+
maxsize=db_settings_pool.maxconn
|
|
33
|
+
)
|
|
34
|
+
self._all_conns: list[MySQLdb.Connection] = []
|
|
35
|
+
self._closed = False
|
|
36
|
+
|
|
37
|
+
for _ in range(db_settings_pool.minconn):
|
|
38
|
+
conn = self._create_connection()
|
|
39
|
+
self._pool.put(conn)
|
|
40
|
+
self._all_conns.append(conn)
|
|
41
|
+
|
|
42
|
+
print(datetime.now(UTC), self.__class__.__name__, self.__init__.__name__)
|
|
43
|
+
|
|
44
|
+
def _create_connection(self) -> MySQLdb.Connection:
|
|
45
|
+
conn = MySQLdb.connect(
|
|
46
|
+
host=self.db_settings_pool.host,
|
|
47
|
+
port=self.db_settings_pool.port,
|
|
48
|
+
user=self.db_settings_pool.user,
|
|
49
|
+
passwd=self.db_settings_pool.password,
|
|
50
|
+
db=self.db_settings_pool.database,
|
|
51
|
+
connect_timeout=self.db_settings_pool.connect_timeout,
|
|
52
|
+
charset="utf8mb4",
|
|
53
|
+
autocommit=False,
|
|
54
|
+
cursorclass=MySQLdb.cursors.DictCursor,
|
|
55
|
+
)
|
|
56
|
+
return conn
|
|
57
|
+
|
|
58
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
59
|
+
"""Close the shared connection pool."""
|
|
60
|
+
self.closeall()
|
|
61
|
+
print(datetime.now(UTC), self.__class__.__name__, self.__exit__.__name__)
|
|
62
|
+
|
|
63
|
+
def getconn(self) -> MySQLdb.Connection:
|
|
64
|
+
"""return conn_pool"""
|
|
65
|
+
with self._lock:
|
|
66
|
+
if self._closed:
|
|
67
|
+
raise RuntimeError("Connection pool is closed")
|
|
68
|
+
try:
|
|
69
|
+
conn = self._pool.get_nowait()
|
|
70
|
+
try:
|
|
71
|
+
conn.ping(True)
|
|
72
|
+
except Exception:
|
|
73
|
+
conn = self._create_connection()
|
|
74
|
+
return conn
|
|
75
|
+
except queue.Empty:
|
|
76
|
+
if len(self._all_conns) < self.db_settings_pool.maxconn:
|
|
77
|
+
conn = self._create_connection()
|
|
78
|
+
self._all_conns.append(conn)
|
|
79
|
+
return conn
|
|
80
|
+
try:
|
|
81
|
+
conn = self._pool.get(timeout=self.db_settings_pool.connect_timeout)
|
|
82
|
+
try:
|
|
83
|
+
conn.ping(True)
|
|
84
|
+
except Exception:
|
|
85
|
+
conn = self._create_connection()
|
|
86
|
+
return conn
|
|
87
|
+
except queue.Empty:
|
|
88
|
+
raise TimeoutError("Timeout waiting for connection from pool") from None
|
|
89
|
+
|
|
90
|
+
def putconn(self, conn: MySQLdb.Connection):
|
|
91
|
+
"""putconn"""
|
|
92
|
+
with self._lock:
|
|
93
|
+
if self._closed:
|
|
94
|
+
try:
|
|
95
|
+
conn.close()
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
self._pool.put_nowait(conn)
|
|
101
|
+
except queue.Full:
|
|
102
|
+
try:
|
|
103
|
+
conn.close()
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
if conn in self._all_conns:
|
|
107
|
+
self._all_conns.remove(conn)
|
|
108
|
+
|
|
109
|
+
def closeall(self):
|
|
110
|
+
"""close all connections in pool"""
|
|
111
|
+
with self._lock:
|
|
112
|
+
self._closed = True
|
|
113
|
+
for conn in self._all_conns:
|
|
114
|
+
try:
|
|
115
|
+
conn.close()
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
self._all_conns.clear()
|
|
119
|
+
while not self._pool.empty():
|
|
120
|
+
try:
|
|
121
|
+
self._pool.get_nowait()
|
|
122
|
+
except queue.Empty:
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
db_set_and_pool: dict[str, ClientPool] = {}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cleanup():
|
|
130
|
+
for pool_inst in db_set_and_pool.values():
|
|
131
|
+
try:
|
|
132
|
+
pool_inst.closeall()
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
atexit.register(cleanup)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Client:
|
|
141
|
+
"""database client"""
|
|
142
|
+
|
|
143
|
+
# Class-level shared connection pool
|
|
144
|
+
_conn_pool: ClientPool
|
|
145
|
+
|
|
146
|
+
def __init__(self, db_settings: Settings):
|
|
147
|
+
self.conn: connection
|
|
148
|
+
self.in_with_block = False
|
|
149
|
+
self.db_settings = db_settings
|
|
150
|
+
self.qry = Query(
|
|
151
|
+
qry_settings=QrySettings(
|
|
152
|
+
use_en_ko_column_alias=db_settings.use_en_ko_column_alias,
|
|
153
|
+
use_conditional=db_settings.use_conditional,
|
|
154
|
+
all_query=db_settings.all_query,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
self.query_recent = ""
|
|
158
|
+
|
|
159
|
+
db_set_key = db_settings.key
|
|
160
|
+
if db_set_key not in db_set_and_pool:
|
|
161
|
+
client_pool = ClientPool(db_settings)
|
|
162
|
+
db_set_and_pool[db_set_key] = client_pool
|
|
163
|
+
Client._conn_pool = client_pool
|
|
164
|
+
|
|
165
|
+
def __enter__(self):
|
|
166
|
+
# Called when entering the 'with' block
|
|
167
|
+
self.conn = Client._conn_pool.getconn()
|
|
168
|
+
self.in_with_block = True
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
172
|
+
# Called when exiting the 'with' block
|
|
173
|
+
try:
|
|
174
|
+
if exc_type is None:
|
|
175
|
+
# No exception, commit the transaction
|
|
176
|
+
self.conn.commit()
|
|
177
|
+
else:
|
|
178
|
+
# Exception occurred, roll back the transaction
|
|
179
|
+
self.conn.rollback()
|
|
180
|
+
finally:
|
|
181
|
+
Client._conn_pool.putconn(self.conn)
|
|
182
|
+
self.in_with_block = False
|
|
183
|
+
|
|
184
|
+
def read_row(
|
|
185
|
+
self,
|
|
186
|
+
qry_key: str,
|
|
187
|
+
params: dict,
|
|
188
|
+
*,
|
|
189
|
+
en: bool = False,
|
|
190
|
+
) -> dict | None:
|
|
191
|
+
"""Return single row"""
|
|
192
|
+
|
|
193
|
+
rows = self.read_rows(qry_key, params, en=en)
|
|
194
|
+
if not rows:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
return rows[0]
|
|
198
|
+
|
|
199
|
+
def read_rows(
|
|
200
|
+
self,
|
|
201
|
+
qry_key: str,
|
|
202
|
+
params: dict,
|
|
203
|
+
*,
|
|
204
|
+
en: bool = False,
|
|
205
|
+
) -> list[dict]:
|
|
206
|
+
"""Return all rows"""
|
|
207
|
+
|
|
208
|
+
def read_rows_by_param(
|
|
209
|
+
qry_key: str,
|
|
210
|
+
params: dict,
|
|
211
|
+
*,
|
|
212
|
+
en: bool = False,
|
|
213
|
+
cursor: MySQLdb.cursors.DictCursor,
|
|
214
|
+
) -> list[dict]:
|
|
215
|
+
if not isinstance(params, dict):
|
|
216
|
+
params = vars(params)
|
|
217
|
+
|
|
218
|
+
qry_str = self.qry.get_query_by_key(qry_key, params, "read", en)
|
|
219
|
+
self.query_recent = qry_str
|
|
220
|
+
|
|
221
|
+
start = 0
|
|
222
|
+
if self.db_settings.before_read_execute:
|
|
223
|
+
self.db_settings.before_read_execute(
|
|
224
|
+
qry_key,
|
|
225
|
+
params,
|
|
226
|
+
qry_str,
|
|
227
|
+
get_query_with_value(qry_str, params),
|
|
228
|
+
)
|
|
229
|
+
start = time.time()
|
|
230
|
+
|
|
231
|
+
cursor.execute(qry_str, params)
|
|
232
|
+
rows = cursor.fetchall()
|
|
233
|
+
|
|
234
|
+
if self.db_settings.after_read_execute:
|
|
235
|
+
duration = round((time.time() - start) * 1000)
|
|
236
|
+
self.db_settings.after_read_execute(qry_key, duration)
|
|
237
|
+
|
|
238
|
+
if not rows:
|
|
239
|
+
return []
|
|
240
|
+
|
|
241
|
+
return list(rows)
|
|
242
|
+
|
|
243
|
+
rows: list[dict] = []
|
|
244
|
+
if self.in_with_block:
|
|
245
|
+
cursor = self.conn.cursor(MySQLdb.cursors.DictCursor)
|
|
246
|
+
rows = read_rows_by_param(qry_key, params, en=en, cursor=cursor)
|
|
247
|
+
cursor.close()
|
|
248
|
+
else:
|
|
249
|
+
conn_pool = Client._conn_pool
|
|
250
|
+
conn = conn_pool.getconn()
|
|
251
|
+
try:
|
|
252
|
+
cursor = conn.cursor(MySQLdb.cursors.DictCursor)
|
|
253
|
+
rows = read_rows_by_param(qry_key, params, en=en, cursor=cursor)
|
|
254
|
+
cursor.close()
|
|
255
|
+
finally:
|
|
256
|
+
conn_pool.putconn(conn)
|
|
257
|
+
|
|
258
|
+
return rows
|
|
259
|
+
|
|
260
|
+
async def read_csv_partial_async(
|
|
261
|
+
self,
|
|
262
|
+
qry_key: str,
|
|
263
|
+
params: dict,
|
|
264
|
+
*,
|
|
265
|
+
row_count_partial: int = 100,
|
|
266
|
+
en: bool = False,
|
|
267
|
+
) -> AsyncGenerator[bytes]:
|
|
268
|
+
"""Return rows partially in batches with async
|
|
269
|
+
|
|
270
|
+
Arguments:
|
|
271
|
+
qry_key: key of the Dictionary registered in the clients/queries folder
|
|
272
|
+
params: key, value pairs to pass as parameters to the SQL query.
|
|
273
|
+
row_count_partial: Number of rows to return at a time
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
CSV format converted to UTF-8-BOM
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
async def read_csv_partial_async_by_param(
|
|
280
|
+
qry_key: str,
|
|
281
|
+
params: dict,
|
|
282
|
+
*,
|
|
283
|
+
row_count_partial: int = 100,
|
|
284
|
+
en: bool = False,
|
|
285
|
+
cursor: MySQLdb.cursors.DictCursor,
|
|
286
|
+
) -> AsyncGenerator[bytes]:
|
|
287
|
+
if not isinstance(params, dict):
|
|
288
|
+
params = vars(params)
|
|
289
|
+
|
|
290
|
+
qry_str = self.qry.get_query_by_key(qry_key, params, "csv", en)
|
|
291
|
+
|
|
292
|
+
is_second = False
|
|
293
|
+
|
|
294
|
+
# without UTF-8 BOM, hangul will be broken.
|
|
295
|
+
utf8_bom = b"\xef\xbb\xbf"
|
|
296
|
+
yield utf8_bom
|
|
297
|
+
|
|
298
|
+
start = 0
|
|
299
|
+
if self.db_settings.before_read_execute:
|
|
300
|
+
self.db_settings.before_read_execute(
|
|
301
|
+
qry_key,
|
|
302
|
+
params,
|
|
303
|
+
qry_str,
|
|
304
|
+
get_query_with_value(qry_str, params),
|
|
305
|
+
)
|
|
306
|
+
start = time.time()
|
|
307
|
+
|
|
308
|
+
cursor.execute(qry_str, params)
|
|
309
|
+
while True:
|
|
310
|
+
rows = (
|
|
311
|
+
cursor.fetchmany(row_count_partial)
|
|
312
|
+
if hasattr(cursor, "fetchmany")
|
|
313
|
+
else cursor.fetchall()
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if not is_second and self.db_settings.after_read_execute:
|
|
317
|
+
duration = round((time.time() - start) * 1000)
|
|
318
|
+
self.db_settings.after_read_execute(qry_key, duration)
|
|
319
|
+
|
|
320
|
+
if not rows:
|
|
321
|
+
break
|
|
322
|
+
|
|
323
|
+
csv_out = io.StringIO()
|
|
324
|
+
csv_w = csv.writer(csv_out)
|
|
325
|
+
if not is_second and cursor.description:
|
|
326
|
+
column_names = [desc[0] for desc in cursor.description]
|
|
327
|
+
csv_w.writerow(column_names)
|
|
328
|
+
csv_w.writerows(
|
|
329
|
+
[list(r.values()) if isinstance(r, dict) else r for r in rows]
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
yield csv_out.getvalue().encode("utf-8")
|
|
333
|
+
|
|
334
|
+
is_second = True
|
|
335
|
+
|
|
336
|
+
if self.in_with_block:
|
|
337
|
+
cursor = self.conn.cursor(MySQLdb.cursors.DictCursor)
|
|
338
|
+
try:
|
|
339
|
+
async for value in read_csv_partial_async_by_param(
|
|
340
|
+
qry_key,
|
|
341
|
+
params,
|
|
342
|
+
row_count_partial=row_count_partial,
|
|
343
|
+
en=en,
|
|
344
|
+
cursor=cursor,
|
|
345
|
+
):
|
|
346
|
+
yield value
|
|
347
|
+
finally:
|
|
348
|
+
cursor.close()
|
|
349
|
+
else:
|
|
350
|
+
conn_pool = Client._conn_pool
|
|
351
|
+
conn = conn_pool.getconn()
|
|
352
|
+
try:
|
|
353
|
+
cursor = conn.cursor(MySQLdb.cursors.DictCursor)
|
|
354
|
+
try:
|
|
355
|
+
async for value in read_csv_partial_async_by_param(
|
|
356
|
+
qry_key,
|
|
357
|
+
params,
|
|
358
|
+
row_count_partial=row_count_partial,
|
|
359
|
+
en=en,
|
|
360
|
+
cursor=cursor,
|
|
361
|
+
):
|
|
362
|
+
yield value
|
|
363
|
+
finally:
|
|
364
|
+
cursor.close()
|
|
365
|
+
finally:
|
|
366
|
+
conn_pool.putconn(conn)
|
|
367
|
+
|
|
368
|
+
def read_csv_partial(
|
|
369
|
+
self,
|
|
370
|
+
qry_key: str,
|
|
371
|
+
params: dict,
|
|
372
|
+
*,
|
|
373
|
+
row_count_partial: int = 100,
|
|
374
|
+
en: bool = False,
|
|
375
|
+
) -> Generator[bytes]:
|
|
376
|
+
"""Return rows partially in batches
|
|
377
|
+
|
|
378
|
+
Arguments:
|
|
379
|
+
qry_key: key of the Dictionary registered in the clients/queries folder
|
|
380
|
+
params: key, value pairs to pass as parameters to the SQL query.
|
|
381
|
+
row_count_partial: Number of rows to return at a time
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
CSV format converted to UTF-8-BOM
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
def read_csv_partial_by_param(
|
|
388
|
+
qry_key: str,
|
|
389
|
+
params: dict,
|
|
390
|
+
*,
|
|
391
|
+
row_count_partial: int = 100,
|
|
392
|
+
en: bool = False,
|
|
393
|
+
cursor: MySQLdb.cursors.DictCursor,
|
|
394
|
+
) -> Generator[bytes]:
|
|
395
|
+
if not isinstance(params, dict):
|
|
396
|
+
params = vars(params)
|
|
397
|
+
|
|
398
|
+
qry_str = self.qry.get_query_by_key(qry_key, params, "csv", en)
|
|
399
|
+
|
|
400
|
+
is_second = False
|
|
401
|
+
|
|
402
|
+
# without UTF-8 BOM, hangul will be broken.
|
|
403
|
+
utf8_bom = b"\xef\xbb\xbf"
|
|
404
|
+
yield utf8_bom
|
|
405
|
+
|
|
406
|
+
start = 0
|
|
407
|
+
if self.db_settings.before_read_execute:
|
|
408
|
+
self.db_settings.before_read_execute(
|
|
409
|
+
qry_key,
|
|
410
|
+
params,
|
|
411
|
+
qry_str,
|
|
412
|
+
get_query_with_value(qry_str, params),
|
|
413
|
+
)
|
|
414
|
+
start = time.time()
|
|
415
|
+
|
|
416
|
+
cursor.execute(qry_str, params)
|
|
417
|
+
while True:
|
|
418
|
+
rows = (
|
|
419
|
+
cursor.fetchmany(row_count_partial)
|
|
420
|
+
if hasattr(cursor, "fetchmany")
|
|
421
|
+
else cursor.fetchall()
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
if not is_second and self.db_settings.after_read_execute:
|
|
425
|
+
duration = round((time.time() - start) * 1000)
|
|
426
|
+
self.db_settings.after_read_execute(qry_key, duration)
|
|
427
|
+
if not rows:
|
|
428
|
+
break
|
|
429
|
+
|
|
430
|
+
csv_out = io.StringIO()
|
|
431
|
+
csv_w = csv.writer(csv_out)
|
|
432
|
+
if not is_second and cursor.description:
|
|
433
|
+
column_names = [desc[0] for desc in cursor.description]
|
|
434
|
+
csv_w.writerow(column_names)
|
|
435
|
+
csv_w.writerows(
|
|
436
|
+
[list(r.values()) if isinstance(r, dict) else r for r in rows]
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
yield csv_out.getvalue().encode("utf-8")
|
|
440
|
+
|
|
441
|
+
is_second = True
|
|
442
|
+
|
|
443
|
+
if self.in_with_block:
|
|
444
|
+
cursor = self.conn.cursor(MySQLdb.cursors.DictCursor)
|
|
445
|
+
try:
|
|
446
|
+
yield from read_csv_partial_by_param(
|
|
447
|
+
qry_key,
|
|
448
|
+
params,
|
|
449
|
+
row_count_partial=row_count_partial,
|
|
450
|
+
en=en,
|
|
451
|
+
cursor=cursor,
|
|
452
|
+
)
|
|
453
|
+
finally:
|
|
454
|
+
cursor.close()
|
|
455
|
+
else:
|
|
456
|
+
conn_pool = Client._conn_pool
|
|
457
|
+
conn = conn_pool.getconn()
|
|
458
|
+
try:
|
|
459
|
+
cursor = conn.cursor(MySQLdb.cursors.DictCursor)
|
|
460
|
+
try:
|
|
461
|
+
yield from read_csv_partial_by_param(
|
|
462
|
+
qry_key,
|
|
463
|
+
params,
|
|
464
|
+
row_count_partial=row_count_partial,
|
|
465
|
+
en=en,
|
|
466
|
+
cursor=cursor,
|
|
467
|
+
)
|
|
468
|
+
finally:
|
|
469
|
+
cursor.close()
|
|
470
|
+
finally:
|
|
471
|
+
conn_pool.putconn(conn)
|
|
472
|
+
|
|
473
|
+
def update(
|
|
474
|
+
self,
|
|
475
|
+
qry_key: str,
|
|
476
|
+
params: dict,
|
|
477
|
+
params_out: dict | None = None,
|
|
478
|
+
) -> int:
|
|
479
|
+
"""call updates"""
|
|
480
|
+
|
|
481
|
+
params_out_used = params_out if params_out is not None else {}
|
|
482
|
+
row_counts = self.updates([(qry_key, params, params_out_used)])
|
|
483
|
+
return row_counts[0]
|
|
484
|
+
|
|
485
|
+
def updates(
|
|
486
|
+
self,
|
|
487
|
+
qry_key_params_list: list[tuple[str, dict, dict]] | list[tuple[str, dict]],
|
|
488
|
+
) -> list[int]:
|
|
489
|
+
"""Executes a list of SQL statements within a single transaction.
|
|
490
|
+
If all SQL commands succeed, returns a list of the number of rows affected
|
|
491
|
+
by each qry_key.
|
|
492
|
+
If any command fails, an error is raised.
|
|
493
|
+
|
|
494
|
+
Arguments:
|
|
495
|
+
qry_key_params_list: A list of tuples, each containing following two values:
|
|
496
|
+
qry_key: key of the dictionary registered in the clients/queries folder
|
|
497
|
+
params: key, value pairs to pass as parameters to the SQL query.
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
The number of rows affected by the last SQL query.
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
def normalize_qry_key_params_list(
|
|
504
|
+
qry_key_params_list: list[tuple[str, dict, dict]] | list[tuple[str, dict]],
|
|
505
|
+
) -> list[tuple[str, dict, dict]]:
|
|
506
|
+
qry_key_params_list_new: list[tuple[str, dict, dict]] = []
|
|
507
|
+
for item in qry_key_params_list:
|
|
508
|
+
if len(item) == 2:
|
|
509
|
+
qry_key, params = item
|
|
510
|
+
params_out = {}
|
|
511
|
+
else:
|
|
512
|
+
qry_key, params, params_out = item
|
|
513
|
+
|
|
514
|
+
if not isinstance(params, (dict, list, tuple)):
|
|
515
|
+
params = vars(params)
|
|
516
|
+
|
|
517
|
+
qry_key_params_list_new.append((qry_key, params, params_out))
|
|
518
|
+
|
|
519
|
+
return qry_key_params_list_new
|
|
520
|
+
|
|
521
|
+
def updates_by_param(
|
|
522
|
+
qry_key_params_list: list[tuple[str, dict, dict]] | list[tuple[str, dict]],
|
|
523
|
+
cursor: MySQLdb.cursors.DictCursor,
|
|
524
|
+
) -> list[int]:
|
|
525
|
+
row_counts: list[int] = []
|
|
526
|
+
qry_strs: list[str] = []
|
|
527
|
+
|
|
528
|
+
qry_key_params_list_new = normalize_qry_key_params_list(qry_key_params_list)
|
|
529
|
+
|
|
530
|
+
for item in qry_key_params_list_new:
|
|
531
|
+
qry_key, params, params_out = item
|
|
532
|
+
|
|
533
|
+
qry_str = self.qry.get_query_by_key(qry_key, params, "update")
|
|
534
|
+
|
|
535
|
+
start = 0
|
|
536
|
+
if self.db_settings.before_update_execute:
|
|
537
|
+
self.db_settings.before_update_execute(
|
|
538
|
+
qry_key,
|
|
539
|
+
params,
|
|
540
|
+
params_out,
|
|
541
|
+
qry_str,
|
|
542
|
+
get_query_with_value(qry_str, params),
|
|
543
|
+
)
|
|
544
|
+
start = time.time()
|
|
545
|
+
|
|
546
|
+
if isinstance(params, (list, tuple)):
|
|
547
|
+
cursor.executemany(qry_str, params)
|
|
548
|
+
else:
|
|
549
|
+
cursor.execute(qry_str, params)
|
|
550
|
+
|
|
551
|
+
row_count = cursor.rowcount
|
|
552
|
+
|
|
553
|
+
if params_out and cursor.description:
|
|
554
|
+
row = cursor.fetchone()
|
|
555
|
+
if row:
|
|
556
|
+
for k, v in row.items():
|
|
557
|
+
if k in params_out:
|
|
558
|
+
params_out[k] = v
|
|
559
|
+
|
|
560
|
+
if self.db_settings.after_update_execute:
|
|
561
|
+
duration = round((time.time() - start) * 1000)
|
|
562
|
+
self.db_settings.after_update_execute(
|
|
563
|
+
qry_key, row_count, params_out, duration
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
row_counts.append(row_count)
|
|
567
|
+
qry_strs.append(qry_str)
|
|
568
|
+
|
|
569
|
+
return row_counts
|
|
570
|
+
|
|
571
|
+
row_counts: list[int] = []
|
|
572
|
+
if self.in_with_block:
|
|
573
|
+
cursor = self.conn.cursor(MySQLdb.cursors.DictCursor)
|
|
574
|
+
try:
|
|
575
|
+
row_counts = updates_by_param(qry_key_params_list, cursor)
|
|
576
|
+
finally:
|
|
577
|
+
cursor.close()
|
|
578
|
+
else:
|
|
579
|
+
conn_pool = Client._conn_pool
|
|
580
|
+
conn = conn_pool.getconn()
|
|
581
|
+
try:
|
|
582
|
+
cursor = conn.cursor(MySQLdb.cursors.DictCursor)
|
|
583
|
+
try:
|
|
584
|
+
row_counts = updates_by_param(qry_key_params_list, cursor)
|
|
585
|
+
conn.commit()
|
|
586
|
+
except Exception:
|
|
587
|
+
conn.rollback()
|
|
588
|
+
raise
|
|
589
|
+
finally:
|
|
590
|
+
cursor.close()
|
|
591
|
+
finally:
|
|
592
|
+
conn_pool.putconn(conn)
|
|
593
|
+
|
|
594
|
+
return row_counts
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""client_util"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from .query_util import get_conditional, replace_en_ko_column_alias
|
|
8
|
+
from .settings import Settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Query:
|
|
12
|
+
"""query"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, qry_settings: Settings):
|
|
15
|
+
self.qry_settings = qry_settings
|
|
16
|
+
|
|
17
|
+
def get_query_by_key(
|
|
18
|
+
self,
|
|
19
|
+
qry_key: str,
|
|
20
|
+
params: dict,
|
|
21
|
+
func_type: Literal["update", "read", "csv"],
|
|
22
|
+
en: bool = False,
|
|
23
|
+
) -> str:
|
|
24
|
+
"""get query string by qry_key"""
|
|
25
|
+
|
|
26
|
+
def serial_date(obj):
|
|
27
|
+
"""JSON serializer for objects not serializable by default json code"""
|
|
28
|
+
|
|
29
|
+
if isinstance(obj, (datetime, date)):
|
|
30
|
+
return obj.isoformat()
|
|
31
|
+
return str(obj)
|
|
32
|
+
|
|
33
|
+
query = self.qry_settings.all_query.get(qry_key)
|
|
34
|
+
if not query:
|
|
35
|
+
raise KeyError(f"{qry_key} not exists")
|
|
36
|
+
|
|
37
|
+
cond_params = (
|
|
38
|
+
params[0]
|
|
39
|
+
if isinstance(params, (list, tuple))
|
|
40
|
+
and params
|
|
41
|
+
and isinstance(params[0], dict)
|
|
42
|
+
else (params if isinstance(params, dict) else {})
|
|
43
|
+
)
|
|
44
|
+
info_params = (
|
|
45
|
+
f"[executemany: {len(params)} rows]"
|
|
46
|
+
if isinstance(params, (list, tuple)) and len(params) > 1
|
|
47
|
+
else params
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
info = {
|
|
51
|
+
"qry_key": qry_key,
|
|
52
|
+
"params": info_params,
|
|
53
|
+
"func_type": func_type,
|
|
54
|
+
"en": en,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if self.qry_settings.use_en_ko_column_alias and isinstance(en, bool):
|
|
58
|
+
query = replace_en_ko_column_alias(query, en)
|
|
59
|
+
if self.qry_settings.use_conditional and "#if" in query:
|
|
60
|
+
query = get_conditional(query, cond_params)
|
|
61
|
+
|
|
62
|
+
info_str = json.dumps(info, ensure_ascii=False, default=serial_date)
|
|
63
|
+
return f"/* {info_str.replace('%', '{percent}')} */{query}"
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""client_util"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_conditional(qry_str: str, params: dict) -> str:
|
|
8
|
+
"""
|
|
9
|
+
return true or false part by condition.
|
|
10
|
+
`#if target == 'korea' ... #elif target == 'vietnam' ... #else ... #endif`
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def eval_safe(to_eval: str, params: dict) -> bool:
|
|
14
|
+
"""
|
|
15
|
+
# assert eval_safe('%(target)s != ""', {"target": ""}) is False
|
|
16
|
+
# assert eval_safe('"A" in %(targets)s', {"targets": ["A", "B"]}) is True
|
|
17
|
+
# assert eval_safe('"A" not in %(targets)s', {"targets": ["A", "B"]}) is False
|
|
18
|
+
# assert eval_safe("%(t)s in [i for i in range(10)]", {"t": 1}) is True
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# remove '%(' and ')s' from %(target)s
|
|
22
|
+
to_eval = re.sub(r"%\((.*?)\)s", r"\1", to_eval)
|
|
23
|
+
|
|
24
|
+
# allow below:
|
|
25
|
+
# - string inside quotes
|
|
26
|
+
# - digit
|
|
27
|
+
to_check = re.sub(r"""(".*?"|'.*?'|\b\d+\b)""", "", to_eval)
|
|
28
|
+
|
|
29
|
+
param_set = {key for key in params}
|
|
30
|
+
op_set = {
|
|
31
|
+
"==",
|
|
32
|
+
"!=",
|
|
33
|
+
">=",
|
|
34
|
+
"<=",
|
|
35
|
+
">",
|
|
36
|
+
"<",
|
|
37
|
+
"in",
|
|
38
|
+
"not",
|
|
39
|
+
"and",
|
|
40
|
+
"or",
|
|
41
|
+
"[",
|
|
42
|
+
"]",
|
|
43
|
+
"(",
|
|
44
|
+
")",
|
|
45
|
+
",",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
eval_set = set(to_check.split())
|
|
49
|
+
diff = eval_set - (param_set | op_set)
|
|
50
|
+
if diff:
|
|
51
|
+
raise ValueError(f"'{diff}' not in {param_set | op_set}")
|
|
52
|
+
|
|
53
|
+
is_include = bool(eval(to_eval, {}, params.copy()))
|
|
54
|
+
return is_include
|
|
55
|
+
|
|
56
|
+
lines = qry_str.split("\n")
|
|
57
|
+
rets = []
|
|
58
|
+
is_include = True
|
|
59
|
+
is_checked = False
|
|
60
|
+
for line in lines:
|
|
61
|
+
line_strip = line.strip()
|
|
62
|
+
if line_strip.startswith(("#if", "#elif")):
|
|
63
|
+
if not is_checked:
|
|
64
|
+
_, condition = line_strip.split(maxsplit=1)
|
|
65
|
+
is_include = eval_safe(condition, params.copy())
|
|
66
|
+
if is_include:
|
|
67
|
+
is_checked = True
|
|
68
|
+
else:
|
|
69
|
+
is_include = False
|
|
70
|
+
elif line_strip.startswith("#else"):
|
|
71
|
+
is_include = not is_checked
|
|
72
|
+
elif line_strip.startswith("#endif"):
|
|
73
|
+
is_include = True
|
|
74
|
+
is_checked = False
|
|
75
|
+
elif is_include:
|
|
76
|
+
rets.append(line)
|
|
77
|
+
|
|
78
|
+
return "\n".join(rets)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def rep_kv(query: str, tab_count: int, **kwargs) -> str:
|
|
82
|
+
"""
|
|
83
|
+
replace {key} with value when `rev_ky("WHERE user_name = {key}", key="u.user_name")`
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
ret = query
|
|
87
|
+
ret = re.sub(r"^", " " * 4 * tab_count, ret, flags=re.MULTILINE)
|
|
88
|
+
for k, v in kwargs.items():
|
|
89
|
+
ret = ret.replace("{" + k + "}", str(v))
|
|
90
|
+
|
|
91
|
+
return ret
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_query_with_value(qry_str: str, params: dict) -> str:
|
|
95
|
+
"""replace raw query to value filled query"""
|
|
96
|
+
|
|
97
|
+
def escape_literal(value) -> str:
|
|
98
|
+
ret = ""
|
|
99
|
+
if isinstance(value, str):
|
|
100
|
+
ret = "'" + value.replace("'", "''") + "'"
|
|
101
|
+
elif isinstance(value, datetime):
|
|
102
|
+
ret = f"'{value.strftime('%Y-%m-%d %H:%M:%S.%f')}'"
|
|
103
|
+
elif isinstance(value, (list, tuple)):
|
|
104
|
+
ret = str(value)
|
|
105
|
+
elif value is None:
|
|
106
|
+
ret = "NULL"
|
|
107
|
+
else:
|
|
108
|
+
ret = str(value)
|
|
109
|
+
return ret
|
|
110
|
+
|
|
111
|
+
if isinstance(params, (list, tuple)):
|
|
112
|
+
if not params:
|
|
113
|
+
return qry_str
|
|
114
|
+
params = params[0] if isinstance(params[0], dict) else {}
|
|
115
|
+
|
|
116
|
+
query_replaced = qry_str
|
|
117
|
+
for key, value in params.items():
|
|
118
|
+
find = f"%({key})s"
|
|
119
|
+
if find in query_replaced:
|
|
120
|
+
replace = escape_literal(value)
|
|
121
|
+
query_replaced = query_replaced.replace(find, replace)
|
|
122
|
+
# %% -> % : mysqlclient / db-api
|
|
123
|
+
# {{}} -> {} : python
|
|
124
|
+
query_replaced = (
|
|
125
|
+
query_replaced.replace("%%", "%").replace("{{", "{").replace("}}", "}")
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return query_replaced
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def replace_en_ko_column_alias(qry_str: str, en: bool) -> str:
|
|
132
|
+
""" "
|
|
133
|
+
return en part or ko part separated by '|' using en variable
|
|
134
|
+
ex:
|
|
135
|
+
tbl.obj_nm "File Name|파일명"
|
|
136
|
+
->
|
|
137
|
+
tbl.obj_nm "File Name"
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
pattern = r'(?P<ws>\s)"(?P<en>[^"]+)\|(?P<ko>[^"]+)"'
|
|
141
|
+
en_ko = "en" if en else "ko"
|
|
142
|
+
repl = rf'\g<ws>"\g<{en_ko}>"'
|
|
143
|
+
qry_str_new = re.sub(pattern, repl, qry_str, flags=re.MULTILINE | re.IGNORECASE)
|
|
144
|
+
return qry_str_new
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""settings"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, kw_only=True)
|
|
7
|
+
class Settings:
|
|
8
|
+
"""query by key settings"""
|
|
9
|
+
|
|
10
|
+
use_en_ko_column_alias: bool
|
|
11
|
+
"""SELECT file_name "File Name|파일명" """
|
|
12
|
+
|
|
13
|
+
use_conditional: bool
|
|
14
|
+
"""
|
|
15
|
+
#if target == 'korea'
|
|
16
|
+
FROM tbl_korea
|
|
17
|
+
#else
|
|
18
|
+
FROM tbl_vietnam
|
|
19
|
+
#endif
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
all_query: dict[str, str]
|
|
23
|
+
"""all query information"""
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""settings"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, kw_only=True)
|
|
8
|
+
class Settings:
|
|
9
|
+
"""db client settings"""
|
|
10
|
+
|
|
11
|
+
host: str
|
|
12
|
+
port: int
|
|
13
|
+
database: str
|
|
14
|
+
user: str
|
|
15
|
+
password: str
|
|
16
|
+
|
|
17
|
+
minconn: int
|
|
18
|
+
maxconn: int
|
|
19
|
+
connect_timeout: int
|
|
20
|
+
|
|
21
|
+
use_en_ko_column_alias: bool
|
|
22
|
+
"""SELECT file_name "File Name|파일명" """
|
|
23
|
+
use_conditional: bool
|
|
24
|
+
"""
|
|
25
|
+
#if target == 'korea'
|
|
26
|
+
FROM tbl_korea
|
|
27
|
+
#else
|
|
28
|
+
FROM tbl_vietnam
|
|
29
|
+
#endif
|
|
30
|
+
"""
|
|
31
|
+
all_query: dict[str, str]
|
|
32
|
+
"""all query information"""
|
|
33
|
+
|
|
34
|
+
before_read_execute: Callable[[str, dict, str, str], None]
|
|
35
|
+
"""
|
|
36
|
+
qry_key: str, params: dict, qry_str: str, qry_with_value: str
|
|
37
|
+
"""
|
|
38
|
+
after_read_execute: Callable[[str, int], None]
|
|
39
|
+
"""
|
|
40
|
+
qry_key: str, duration: int
|
|
41
|
+
"""
|
|
42
|
+
before_update_execute: Callable[
|
|
43
|
+
[str, dict, dict, str, str],
|
|
44
|
+
None,
|
|
45
|
+
]
|
|
46
|
+
"""
|
|
47
|
+
qry_key: str, params: dict, params_out: dict, qry_str: str, qry_with_value: str
|
|
48
|
+
"""
|
|
49
|
+
after_update_execute: Callable[[str, int, dict, int], None]
|
|
50
|
+
"""
|
|
51
|
+
qry_key: str, row_count: int, params_out: dict, duration: int
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def key(self):
|
|
56
|
+
"""key for another dictionary"""
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
f"{self.host},{self.port},{self.database},{self.user},{self.password}"
|
|
60
|
+
f"{self.minconn},{self.maxconn},{self.connect_timeout}"
|
|
61
|
+
)
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mysqlclient-client
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MySQL helper function to run MySQL query with #if support
|
|
5
|
+
Author-email: Gu Park <doctorgu@kakao.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 doctorgu
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: homepage, https://github.com/doctorgu/mysqlclient-client
|
|
29
|
+
Project-URL: repository, https://github.com/doctorgu/mysqlclient-client
|
|
30
|
+
Project-URL: documentation, https://github.com/doctorgu/mysqlclient-client
|
|
31
|
+
Keywords: mysqlclient client,mysqlclient helper,mysql helper
|
|
32
|
+
Requires-Python: >=3.11
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
License-File: LICENSE
|
|
35
|
+
Requires-Dist: mysqlclient
|
|
36
|
+
Requires-Dist: pyhumps
|
|
37
|
+
Requires-Dist: typing-extensions
|
|
38
|
+
Requires-Dist: python-dotenv
|
|
39
|
+
Requires-Dist: pydantic-settings
|
|
40
|
+
Requires-Dist: Flask
|
|
41
|
+
Requires-Dist: PyYAML
|
|
42
|
+
Requires-Dist: ruff
|
|
43
|
+
Provides-Extra: test
|
|
44
|
+
Requires-Dist: pytest; extra == "test"
|
|
45
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
46
|
+
Requires-Dist: pytest-env; extra == "test"
|
|
47
|
+
Requires-Dist: pytest-mock; extra == "test"
|
|
48
|
+
Requires-Dist: pytest-asyncio; extra == "test"
|
|
49
|
+
Requires-Dist: build; extra == "test"
|
|
50
|
+
Dynamic: license-file
|
|
51
|
+
|
|
52
|
+
# MysqlclientClient — Modern MySQL Helper for Python
|
|
53
|
+
|
|
54
|
+
A lightweight, opinionated wrapper around **mysqlclient** (`MySQLdb`) with built-in support for:
|
|
55
|
+
|
|
56
|
+
- Connection pooling (`minconn` / `maxconn`)
|
|
57
|
+
- Query dictionary management
|
|
58
|
+
- Conditional SQL (`#if` / `#elif` / `#endif`)
|
|
59
|
+
- Bilingual column aliases (`en|ko`)
|
|
60
|
+
- Simple transaction handling via context manager
|
|
61
|
+
- Safe parameter binding (`%(param)s` syntax)
|
|
62
|
+
- Streaming CSV export support
|
|
63
|
+
|
|
64
|
+
> Successor-friendly alternative to raw mysqlclient with better developer experience.
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install mysqlclient-client
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
> Note: `mysqlclient-client` is a custom helper class. See full source in repository.
|
|
73
|
+
|
|
74
|
+
## Quick Start
|
|
75
|
+
|
|
76
|
+
### 1. Define Queries
|
|
77
|
+
|
|
78
|
+
YAML format in `queries` folder (or dictionary):
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
- name: read_user_id_all
|
|
82
|
+
value: |
|
|
83
|
+
SELECT user_id
|
|
84
|
+
FROM t_user
|
|
85
|
+
|
|
86
|
+
- name: upsert_user
|
|
87
|
+
value: |
|
|
88
|
+
INSERT INTO t_user
|
|
89
|
+
(
|
|
90
|
+
user_id, user_name, user_rank
|
|
91
|
+
)
|
|
92
|
+
VALUES
|
|
93
|
+
(
|
|
94
|
+
%(user_id)s, %(user_name)s, %(user_rank)s
|
|
95
|
+
)
|
|
96
|
+
ON DUPLICATE KEY UPDATE
|
|
97
|
+
user_name = %(user_name)s,
|
|
98
|
+
user_rank = %(user_rank)s,
|
|
99
|
+
update_time = CURRENT_TIMESTAMP
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### 2. Configure Database Connection
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from mysqlclient_client.settings import Settings
|
|
106
|
+
|
|
107
|
+
db_settings = Settings(
|
|
108
|
+
host="127.0.0.1",
|
|
109
|
+
port=3306,
|
|
110
|
+
database="test",
|
|
111
|
+
user="root",
|
|
112
|
+
password="password",
|
|
113
|
+
minconn=3,
|
|
114
|
+
maxconn=10,
|
|
115
|
+
connect_timeout=5,
|
|
116
|
+
use_en_ko_column_alias=True,
|
|
117
|
+
use_conditional=True,
|
|
118
|
+
all_query=qry_dic,
|
|
119
|
+
before_read_execute=lambda qry_key, params, qry_str, qry_with_value: print(
|
|
120
|
+
f'READ_ROWS_START, QRY_KEY: "{qry_key}", QRY_WITH_VALUE: {qry_with_value}'
|
|
121
|
+
),
|
|
122
|
+
after_read_execute=lambda qry_key, duration: print(
|
|
123
|
+
f'READ_ROWS_END, QRY_KEY: "{qry_key}", DURATION: {duration}'
|
|
124
|
+
),
|
|
125
|
+
before_update_execute=lambda qry_key, params, params_out, qry_str, qry_with_value: (
|
|
126
|
+
print(f'UPDATES_START, QRY_KEY: "{qry_key}", QRY_WITH_VALUE: {qry_with_value}')
|
|
127
|
+
),
|
|
128
|
+
after_update_execute=lambda qry_key, row_count, params_out, duration: print(
|
|
129
|
+
f'UPDATES_END, QRY_KEY: "{qry_key}", DURATION: {duration}'
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### 3. Basic Usage
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from mysqlclient_client.client import Client
|
|
138
|
+
|
|
139
|
+
db = Client(db_settings=db_settings)
|
|
140
|
+
|
|
141
|
+
# Read single row
|
|
142
|
+
row = db.read_row("read_user_id_all", {})
|
|
143
|
+
print(row) # {'user_id': 'gildong.hong'}
|
|
144
|
+
|
|
145
|
+
# Read all rows
|
|
146
|
+
rows = db.read_rows("read_user_id_all", {})
|
|
147
|
+
print(rows[:2])
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Create / Update / Delete Operations
|
|
151
|
+
|
|
152
|
+
### `update()` — Single CUD Statement
|
|
153
|
+
|
|
154
|
+
Returns affected row count:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
affected = db.update(
|
|
158
|
+
"upsert_user", {"user_id": "gildong.hong", "user_name": "홍길동", "user_rank": 1}
|
|
159
|
+
)
|
|
160
|
+
print("Affected rows:", affected) # 1
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Capture Output Parameters
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
params_out = {"user_name": "", "user_rank": 0}
|
|
167
|
+
db.update(
|
|
168
|
+
"upsert_user",
|
|
169
|
+
{"user_id": "gildong.hong", "user_name": "홍길동", "user_rank": 1},
|
|
170
|
+
params_out=params_out,
|
|
171
|
+
)
|
|
172
|
+
print("Returned name:", params_out["user_name"], params_out["user_rank"])
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### `updates()` — Batch Execution
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
batch = [
|
|
179
|
+
("upsert_user", {"user_id": "sunja.kim", "user_name": "김순자", "user_rank": 2}),
|
|
180
|
+
("upsert_user", {"user_id": "malja.kim", "user_name": "김말자", "user_rank": 3}),
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
results = db.updates(batch)
|
|
184
|
+
print("Batch results:", results) # [1, 1]
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Transaction Support with `with`
|
|
188
|
+
|
|
189
|
+
Automatically commits on success, rolls back on exception:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
with Client(db_settings=db_settings) as db:
|
|
193
|
+
new_id = "youngja.lee"
|
|
194
|
+
db.update("upsert_user", {"user_id": new_id, "user_name": "이영자", "user_rank": 4})
|
|
195
|
+
db.update("delete_user", {"user_id": new_id})
|
|
196
|
+
print("Committed successfully")
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Partially return CSV
|
|
200
|
+
|
|
201
|
+
Read rows partially and return immediately to client to show progress:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
# Flask
|
|
205
|
+
@app.route("/read-csv-partial")
|
|
206
|
+
def read_csv_partial():
|
|
207
|
+
"""read csv partial"""
|
|
208
|
+
|
|
209
|
+
db_client = Client(db_settings=db_settings)
|
|
210
|
+
filename = f"{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}.csv"
|
|
211
|
+
|
|
212
|
+
return Response(
|
|
213
|
+
db_client.read_csv_partial("read_csv_partial", {}),
|
|
214
|
+
mimetype="text/csv",
|
|
215
|
+
headers={
|
|
216
|
+
"Access-Control-Expose-Headers": "Content-Disposition",
|
|
217
|
+
"Content-Disposition": f'attachment; filename="{filename}"',
|
|
218
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
219
|
+
"Pragma": "no-cache",
|
|
220
|
+
"Expires": "0",
|
|
221
|
+
"X-Accel-Buffering": "no",
|
|
222
|
+
"Transfer-Encoding": "chunked",
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Bilingual Column Aliases (English ↔ Korean)
|
|
228
|
+
|
|
229
|
+
Enabled when `use_en_ko_column_alias=True` and `en` not omitted:
|
|
230
|
+
|
|
231
|
+
```yaml
|
|
232
|
+
- name: read_user_alias
|
|
233
|
+
value: |
|
|
234
|
+
SELECT user_id "Id|아이디", user_name "Name|이름", user_rank "Rank|순위"
|
|
235
|
+
FROM t_user
|
|
236
|
+
WHERE user_id = %(user_id)s
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### English mode (`en=True`)
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
rows = db.read_rows("read_user_alias", {"user_id": "gildong.hong"}, en=True)
|
|
243
|
+
print(rows[0])
|
|
244
|
+
# {'Id': 'gildong.hong', 'Name': '홍길동', 'Rank': 1}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Korean mode (`en=False`)
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
rows = db.read_rows("read_user_alias", {"user_id": "gildong.hong"}, en=False)
|
|
251
|
+
print(rows[0])
|
|
252
|
+
# {'아이디': 'gildong.hong', '이름': '홍길동', '순위': 1}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Conditional SQL (`#if`, `#elif`, `#endif`)
|
|
256
|
+
|
|
257
|
+
Enabled when `use_conditional=True`:
|
|
258
|
+
|
|
259
|
+
```yaml
|
|
260
|
+
- name: read_user_search
|
|
261
|
+
value: |
|
|
262
|
+
SELECT user_id, user_name, user_rank, insert_time, update_time
|
|
263
|
+
FROM t_user
|
|
264
|
+
WHERE 1 = 1
|
|
265
|
+
#if user_id
|
|
266
|
+
AND user_id = %(user_id)s
|
|
267
|
+
#elif user_name
|
|
268
|
+
AND user_name LIKE %(user_name)s
|
|
269
|
+
#elif user_rank
|
|
270
|
+
AND user_rank <= %(user_rank)s
|
|
271
|
+
#endif
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Safety & Security
|
|
275
|
+
|
|
276
|
+
The `#if` preprocessor only allows parameter names, string literals, numbers, and basic boolean operators. Any attempt to inject raw SQL will raise a parsing error before execution.
|
|
277
|
+
|
|
278
|
+
## Features Summary
|
|
279
|
+
|
|
280
|
+
| Feature | Notes |
|
|
281
|
+
| ----------------------------- | ------------------------------------------------------------------------------- |
|
|
282
|
+
| Connection pooling | Thread-safe pool via `minconn` / `maxconn` |
|
|
283
|
+
| Named queries | Stored in YAML / dictionary |
|
|
284
|
+
| Single-row / multi-row fetch | `read_row()` / `read_rows()` |
|
|
285
|
+
| Single / batch CUD operations | `update()` / `updates()` |
|
|
286
|
+
| Output parameters | Via `params_out` dict |
|
|
287
|
+
| Transactions via `with` | Auto rollback on exception |
|
|
288
|
+
| Partially return CSV | `read_csv_partial` / `read_csv_partial_async` |
|
|
289
|
+
| Bilingual column aliases | `"Name\|이름"` syntax |
|
|
290
|
+
| Conditional SQL | `#if` / `#elif` / `#endif` |
|
|
291
|
+
| Logging support | Before and after execute hooks via `before...` and `after...` callables |
|
|
292
|
+
| SQL injection protection | Strict parsing in conditionals |
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
mysqlclient_client/client.py,sha256=JPRPJKcy6Avx30uGm6z7Tebc9UTuEWyZx-Z2hEjjH5E,19318
|
|
2
|
+
mysqlclient_client/settings.py,sha256=DXxrIMkaPZTfXh0Im7oDC929rdUVnULFJZAU2WECYM4,1433
|
|
3
|
+
mysqlclient_client/query_by_key/query.py,sha256=7RVBZKdxVkCE-17met40mcvTuklKbLTQFaMlBZ5m2Qs,1868
|
|
4
|
+
mysqlclient_client/query_by_key/query_util.py,sha256=4vUlhdejomFLI7hk2ThbTPAS3ME4q6iGawSs7Q0PK8I,4227
|
|
5
|
+
mysqlclient_client/query_by_key/settings.py,sha256=v3HEvUeTqYzYfdLb8Z9vvR2Jof5t5FavrJbXztzuQL8,423
|
|
6
|
+
mysqlclient_client-1.0.0.dist-info/licenses/LICENSE,sha256=gFDlDjmqY3ZYsdSoSZrY2jivE561CTz5IV-1wo4gjyU,1065
|
|
7
|
+
mysqlclient_client-1.0.0.dist-info/METADATA,sha256=Zh0JEd_2YM5KIXkAbtPvUKRixDcbF-LAZiJ7mTg53XQ,9740
|
|
8
|
+
mysqlclient_client-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
mysqlclient_client-1.0.0.dist-info/top_level.txt,sha256=CnkGv3Ejoc5QVxNA_1KnHnL0d3VZPgxlURVm61B8g7E,19
|
|
10
|
+
mysqlclient_client-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 doctorgu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mysqlclient_client
|