altcodepro-polydb-python 2.3.18__py3-none-any.whl → 2.3.21__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.
- {altcodepro_polydb_python-2.3.18.dist-info → altcodepro_polydb_python-2.3.21.dist-info}/METADATA +1 -1
- {altcodepro_polydb_python-2.3.18.dist-info → altcodepro_polydb_python-2.3.21.dist-info}/RECORD +14 -9
- polydb/__init__.py +2 -2
- polydb/adapters/PostgreSQLAdapter.py +316 -203
- polydb/errors.py +6 -0
- polydb/observability/__init__.py +3 -0
- polydb/observability/logging.py +124 -0
- polydb/retry.py +17 -7
- polydb/services/__init__.py +9 -0
- polydb/services/compliance_service.py +141 -0
- polydb/services/security_service.py +133 -0
- {altcodepro_polydb_python-2.3.18.dist-info → altcodepro_polydb_python-2.3.21.dist-info}/WHEEL +0 -0
- {altcodepro_polydb_python-2.3.18.dist-info → altcodepro_polydb_python-2.3.21.dist-info}/licenses/LICENSE +0 -0
- {altcodepro_polydb_python-2.3.18.dist-info → altcodepro_polydb_python-2.3.21.dist-info}/top_level.txt +0 -0
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# src/polydb/adapters/postgres.py
|
|
2
|
-
import datetime
|
|
3
|
-
from decimal import Decimal
|
|
4
2
|
import os
|
|
5
3
|
import threading
|
|
4
|
+
import time
|
|
6
5
|
from typing import Any, Iterator, List, Optional, Tuple, Union
|
|
7
6
|
import hashlib
|
|
8
7
|
from contextlib import contextmanager
|
|
9
8
|
import json
|
|
9
|
+
from decimal import Decimal
|
|
10
10
|
from datetime import datetime, date
|
|
11
11
|
|
|
12
12
|
import psycopg2.extensions
|
|
13
|
+
from psycopg2 import sql as pg_sql
|
|
14
|
+
from psycopg2.extras import Json
|
|
13
15
|
|
|
14
|
-
from ..errors import DatabaseError, ConnectionError
|
|
16
|
+
from ..errors import DatabaseError, ConnectionError, InsufficientBalanceError
|
|
15
17
|
from ..retry import retry
|
|
16
18
|
from ..utils import validate_table_name, validate_column_name
|
|
17
19
|
from ..query import QueryBuilder
|
|
@@ -25,6 +27,7 @@ class PostgreSQLAdapter:
|
|
|
25
27
|
from ..utils import setup_logger
|
|
26
28
|
|
|
27
29
|
self.logger = setup_logger(__name__)
|
|
30
|
+
self._slow_query_ms: float = float(os.getenv("POLYDB_SLOW_QUERY_MS", "1000"))
|
|
28
31
|
self.connection_string = connection_string or os.getenv(
|
|
29
32
|
"POSTGRES_CONNECTION_STRING",
|
|
30
33
|
os.getenv("POSTGRES_URL", ""),
|
|
@@ -40,45 +43,29 @@ class PostgreSQLAdapter:
|
|
|
40
43
|
# ---------------------------------------------------------------------
|
|
41
44
|
|
|
42
45
|
def _is_idle(self, conn) -> bool:
|
|
43
|
-
"""True iff the connection is not inside a (possibly aborted) transaction."""
|
|
44
46
|
try:
|
|
45
47
|
return conn.info.transaction_status == psycopg2.extensions.TRANSACTION_STATUS_IDLE
|
|
46
48
|
except Exception:
|
|
47
49
|
return False
|
|
48
50
|
|
|
49
51
|
def _drain_transaction(self, conn) -> None:
|
|
50
|
-
"""Force the connection back to IDLE so it's safe to change session
|
|
51
|
-
settings (e.g. autocommit). Safe to call when already idle."""
|
|
52
52
|
if self._is_idle(conn):
|
|
53
53
|
return
|
|
54
54
|
try:
|
|
55
55
|
conn.rollback()
|
|
56
56
|
except Exception:
|
|
57
|
-
# Last resort: caller will surface a real error on next use.
|
|
58
57
|
pass
|
|
59
58
|
|
|
60
59
|
def _ping_connection(self, conn) -> bool:
|
|
61
|
-
"""Test if connection is still alive.
|
|
62
|
-
|
|
63
|
-
psycopg2 auto-starts a transaction on the first statement after
|
|
64
|
-
commit/rollback, so we MUST rollback after the SELECT 1 — otherwise
|
|
65
|
-
the next caller inherits an active transaction and any attempt to
|
|
66
|
-
toggle autocommit raises ``set_session cannot be used inside a
|
|
67
|
-
transaction``.
|
|
68
|
-
"""
|
|
69
60
|
try:
|
|
70
61
|
with conn.cursor() as cur:
|
|
71
62
|
cur.execute("SELECT 1")
|
|
72
|
-
# Clean up the auto-started transaction so the connection
|
|
73
|
-
# leaves this method in IDLE state.
|
|
74
63
|
try:
|
|
75
64
|
conn.rollback()
|
|
76
65
|
except Exception:
|
|
77
66
|
pass
|
|
78
67
|
return True
|
|
79
68
|
except Exception:
|
|
80
|
-
# Best-effort cleanup on a failed ping. The connection is
|
|
81
|
-
# almost certainly broken; the caller will close it.
|
|
82
69
|
try:
|
|
83
70
|
conn.rollback()
|
|
84
71
|
except Exception:
|
|
@@ -90,7 +77,6 @@ class PostgreSQLAdapter:
|
|
|
90
77
|
import psycopg2.pool
|
|
91
78
|
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
|
92
79
|
|
|
93
|
-
# Enhance connection string with better Azure settings
|
|
94
80
|
dsn = self.connection_string
|
|
95
81
|
if "postgresql://" in dsn:
|
|
96
82
|
parsed = urlparse(dsn)
|
|
@@ -105,17 +91,37 @@ class PostgreSQLAdapter:
|
|
|
105
91
|
parsed = parsed._replace(query=new_query)
|
|
106
92
|
dsn = urlunparse(parsed)
|
|
107
93
|
|
|
94
|
+
_maxconn = int(os.getenv("POSTGRES_MAX_CONNECTIONS", "100"))
|
|
108
95
|
with self._lock:
|
|
109
96
|
if not self._pool:
|
|
110
97
|
self._pool = psycopg2.pool.ThreadedConnectionPool(
|
|
111
98
|
minconn=int(os.getenv("POSTGRES_MIN_CONNECTIONS", "5")),
|
|
112
|
-
maxconn=
|
|
99
|
+
maxconn=_maxconn,
|
|
113
100
|
dsn=dsn,
|
|
114
101
|
)
|
|
115
|
-
self.logger.info(
|
|
102
|
+
self.logger.info(
|
|
103
|
+
"PostgreSQL pool initialized: min=%s max=%s",
|
|
104
|
+
os.getenv("POSTGRES_MIN_CONNECTIONS", "5"),
|
|
105
|
+
_maxconn,
|
|
106
|
+
)
|
|
116
107
|
except Exception as e:
|
|
117
108
|
raise ConnectionError(f"Failed to initialize PostgreSQL pool: {str(e)}")
|
|
118
109
|
|
|
110
|
+
def _log_pool_utilization(self) -> None:
|
|
111
|
+
if not self._pool:
|
|
112
|
+
return
|
|
113
|
+
try:
|
|
114
|
+
maxconn = int(os.getenv("POSTGRES_MAX_CONNECTIONS", "100"))
|
|
115
|
+
used_count = len(getattr(self._pool, "_used", {}))
|
|
116
|
+
pct = (used_count / maxconn * 100) if maxconn else 0
|
|
117
|
+
if pct > 80:
|
|
118
|
+
self.logger.warning(
|
|
119
|
+
"PostgreSQL pool utilization: %d/%d connections in use (%.0f%%)",
|
|
120
|
+
used_count, maxconn, pct,
|
|
121
|
+
)
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
|
|
119
125
|
def _get_connection(self) -> Any:
|
|
120
126
|
if not self._pool:
|
|
121
127
|
self._initialize_pool()
|
|
@@ -123,16 +129,13 @@ class PostgreSQLAdapter:
|
|
|
123
129
|
try:
|
|
124
130
|
conn = self._pool.getconn() # type: ignore
|
|
125
131
|
|
|
126
|
-
# Critical: Validate connection for Azure transient issues.
|
|
127
|
-
# _ping_connection rolls back its own SELECT 1 so the connection
|
|
128
|
-
# is returned to callers in IDLE state.
|
|
129
132
|
if not self._ping_connection(conn):
|
|
130
133
|
self.logger.warning("Stale connection detected from pool, closing and retrying")
|
|
131
134
|
self._pool.putconn(conn, close=True) # type: ignore
|
|
132
|
-
conn = self._pool.getconn() # type: ignore
|
|
133
|
-
# New connection might still have been used before — make sure it's idle.
|
|
135
|
+
conn = self._pool.getconn() # type: ignore
|
|
134
136
|
self._drain_transaction(conn)
|
|
135
137
|
|
|
138
|
+
self._log_pool_utilization()
|
|
136
139
|
return conn
|
|
137
140
|
|
|
138
141
|
except Exception as e:
|
|
@@ -140,51 +143,66 @@ class PostgreSQLAdapter:
|
|
|
140
143
|
raise ConnectionError(f"Could not obtain database connection: {e}") from e
|
|
141
144
|
|
|
142
145
|
def _return_connection(self, conn: Any):
|
|
143
|
-
"""Return a connection to the pool, defensively draining any
|
|
144
|
-
leftover transaction state. Belt-and-suspenders: every operation
|
|
145
|
-
in this adapter already commits/rolls-back before returning, but
|
|
146
|
-
if any path ever forgets, the pool still gets a clean connection.
|
|
147
|
-
"""
|
|
148
146
|
if self._pool and conn:
|
|
149
147
|
self._drain_transaction(conn)
|
|
150
148
|
self._pool.putconn(conn)
|
|
151
149
|
|
|
150
|
+
# ---------------------------------------------------------------------
|
|
151
|
+
# QUERY TIMING HELPER
|
|
152
|
+
# ---------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def _timed_execute(
|
|
155
|
+
self,
|
|
156
|
+
cursor: Any,
|
|
157
|
+
sql: str,
|
|
158
|
+
params: Any,
|
|
159
|
+
*,
|
|
160
|
+
operation: str = "execute",
|
|
161
|
+
table: str = "",
|
|
162
|
+
) -> float:
|
|
163
|
+
t0 = time.perf_counter()
|
|
164
|
+
cursor.execute(sql, params)
|
|
165
|
+
duration_ms = (time.perf_counter() - t0) * 1000.0
|
|
166
|
+
|
|
167
|
+
self.logger.debug(
|
|
168
|
+
"SQL executed",
|
|
169
|
+
extra={"operation": operation, "table": table, "duration_ms": round(duration_ms, 3)},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if duration_ms > self._slow_query_ms:
|
|
173
|
+
self.logger.warning(
|
|
174
|
+
"Slow query detected: operation=%s table=%s duration_ms=%.1f threshold=%.1f",
|
|
175
|
+
operation, table, duration_ms, self._slow_query_ms,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return duration_ms
|
|
179
|
+
|
|
152
180
|
# ---------------------------------------------------------------------
|
|
153
181
|
# TRANSACTIONS
|
|
154
182
|
# ---------------------------------------------------------------------
|
|
183
|
+
|
|
155
184
|
def reset_pool(self):
|
|
156
|
-
"""Reset the entire pool (call during startup or after major failures)"""
|
|
157
185
|
with self._lock:
|
|
158
186
|
if self._pool:
|
|
159
187
|
try:
|
|
160
188
|
self._pool.closeall()
|
|
161
|
-
except:
|
|
189
|
+
except Exception:
|
|
162
190
|
pass
|
|
163
191
|
self._pool = None
|
|
164
192
|
self._initialize_pool()
|
|
165
193
|
|
|
166
194
|
def begin_transaction(self) -> Any:
|
|
167
|
-
"""Begin a transaction and return the connection handle.
|
|
168
|
-
|
|
169
|
-
Defensively drains any leftover transaction state on the pooled
|
|
170
|
-
connection before toggling autocommit. Without this, a connection
|
|
171
|
-
that's still in TRANSACTION_STATUS_INTRANS (from a previous user
|
|
172
|
-
or from the pool's connection check) causes psycopg2 to raise
|
|
173
|
-
``set_session cannot be used inside a transaction``.
|
|
174
|
-
"""
|
|
175
195
|
conn = self._get_connection()
|
|
176
196
|
self._drain_transaction(conn)
|
|
177
197
|
conn.autocommit = False
|
|
178
198
|
return conn
|
|
179
199
|
|
|
180
200
|
def commit(self, tx: Any):
|
|
181
|
-
"""Commit the transaction using the provided connection."""
|
|
182
201
|
if tx:
|
|
183
202
|
tx.commit()
|
|
184
203
|
self._return_connection(tx)
|
|
185
204
|
|
|
186
205
|
def rollback(self, tx: Any):
|
|
187
|
-
"""Rollback the transaction using the provided connection."""
|
|
188
206
|
if tx:
|
|
189
207
|
tx.rollback()
|
|
190
208
|
self._return_connection(tx)
|
|
@@ -192,11 +210,8 @@ class PostgreSQLAdapter:
|
|
|
192
210
|
# ---------------------------------------------------------------------
|
|
193
211
|
# JSON HELPERS
|
|
194
212
|
# ---------------------------------------------------------------------
|
|
213
|
+
|
|
195
214
|
def _json_safe(self, obj: Any):
|
|
196
|
-
"""
|
|
197
|
-
Ensure JSON serialization never fails.
|
|
198
|
-
Used only for Json() wrapping.
|
|
199
|
-
"""
|
|
200
215
|
if isinstance(obj, datetime):
|
|
201
216
|
return obj.isoformat()
|
|
202
217
|
if isinstance(obj, Decimal):
|
|
@@ -205,71 +220,44 @@ class PostgreSQLAdapter:
|
|
|
205
220
|
return str(obj)
|
|
206
221
|
if isinstance(obj, dict):
|
|
207
222
|
return {k: self._json_safe(v) for k, v in obj.items()}
|
|
208
|
-
if isinstance(obj, list):
|
|
223
|
+
if isinstance(obj, (list, tuple)):
|
|
209
224
|
return [self._json_safe(v) for v in obj]
|
|
210
225
|
return obj
|
|
211
226
|
|
|
212
227
|
def _serialize_value(self, v: Any) -> Any:
|
|
213
|
-
"""
|
|
214
|
-
Make outgoing values safe for psycopg2 across mixed column types.
|
|
215
|
-
|
|
216
|
-
Rules:
|
|
217
|
-
None / empty list / empty dict -> None (becomes NULL on any column)
|
|
218
|
-
list of primitives (str/int/...) -> native list (psycopg2 maps to TEXT[]/INT[])
|
|
219
|
-
list containing dicts -> Json(list) (for JSONB columns)
|
|
220
|
-
dict -> Json(dict)
|
|
221
|
-
datetime/date -> native
|
|
222
|
-
Decimal -> float
|
|
223
|
-
everything else -> as-is
|
|
224
|
-
"""
|
|
225
|
-
# NULL-ify empties so they're valid for TEXT[], JSONB, and plain columns alike.
|
|
226
|
-
from psycopg2.extras import Json
|
|
227
|
-
|
|
228
228
|
if v is None:
|
|
229
229
|
return None
|
|
230
|
-
if isinstance(v, (list, tuple))
|
|
231
|
-
return
|
|
232
|
-
if isinstance(v,
|
|
233
|
-
return
|
|
230
|
+
if isinstance(v, (dict, list, tuple)):
|
|
231
|
+
return Json(self._json_safe(v))
|
|
232
|
+
if isinstance(v, (datetime, date)):
|
|
233
|
+
return v
|
|
234
|
+
if isinstance(v, Decimal):
|
|
235
|
+
return float(v)
|
|
236
|
+
return v
|
|
234
237
|
|
|
235
|
-
|
|
238
|
+
def _serialize_param(self, v: Any) -> Any:
|
|
239
|
+
if v is None:
|
|
240
|
+
return None
|
|
236
241
|
if isinstance(v, dict):
|
|
237
242
|
return Json(self._json_safe(v))
|
|
238
|
-
|
|
239
|
-
# List: route by element type.
|
|
240
243
|
if isinstance(v, (list, tuple)):
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
# If ALL elements are primitives, send as native list for TEXT[]/INT[].
|
|
246
|
-
if all(isinstance(x, (str, int, float, bool, type(None))) for x in v):
|
|
247
|
-
return v
|
|
248
|
-
# Mixed / nested -> safest is JSONB
|
|
249
|
-
return Json(v)
|
|
250
|
-
|
|
251
|
-
# Datetime / date
|
|
244
|
+
seq = list(v)
|
|
245
|
+
if any(isinstance(x, dict) for x in seq):
|
|
246
|
+
return Json(self._json_safe(seq))
|
|
247
|
+
return seq
|
|
252
248
|
if isinstance(v, (datetime, date)):
|
|
253
249
|
return v
|
|
254
|
-
|
|
255
|
-
# Decimal
|
|
256
250
|
if isinstance(v, Decimal):
|
|
257
251
|
return float(v)
|
|
258
|
-
|
|
259
252
|
return v
|
|
260
253
|
|
|
261
254
|
def _serialize_params(self, params: List[Any]) -> List[Any]:
|
|
262
|
-
return [self.
|
|
255
|
+
return [self._serialize_param(p) for p in params]
|
|
263
256
|
|
|
264
257
|
def _deserialize_row(self, row: JsonDict) -> JsonDict:
|
|
265
|
-
"""
|
|
266
|
-
Postgres JSON/JSONB often comes back as dict already depending on driver config.
|
|
267
|
-
If it comes as a string, try json.loads safely.
|
|
268
|
-
"""
|
|
269
258
|
for k, v in list(row.items()):
|
|
270
259
|
if isinstance(v, str):
|
|
271
260
|
s = v.strip()
|
|
272
|
-
# quick cheap check to avoid parsing normal strings
|
|
273
261
|
if (s.startswith("{") and s.endswith("}")) or (
|
|
274
262
|
s.startswith("[") and s.endswith("]")
|
|
275
263
|
):
|
|
@@ -297,24 +285,18 @@ class PostgreSQLAdapter:
|
|
|
297
285
|
|
|
298
286
|
try:
|
|
299
287
|
cursor = conn.cursor()
|
|
300
|
-
|
|
301
288
|
columns = ", ".join(data.keys())
|
|
302
289
|
placeholders = ", ".join(["%s"] * len(data))
|
|
303
290
|
query = f"INSERT INTO {table} ({columns}) VALUES ({placeholders}) RETURNING *"
|
|
304
|
-
|
|
305
291
|
values = [self._serialize_value(v) for v in data.values()]
|
|
306
|
-
|
|
307
|
-
|
|
292
|
+
self._timed_execute(cursor, query, values, operation="insert", table=table)
|
|
308
293
|
result_row = cursor.fetchone()
|
|
309
294
|
columns_list = [desc[0] for desc in cursor.description]
|
|
310
295
|
result = dict(zip(columns_list, result_row))
|
|
311
|
-
|
|
312
296
|
if own_conn:
|
|
313
297
|
conn.commit()
|
|
314
|
-
|
|
315
298
|
cursor.close()
|
|
316
299
|
return self._deserialize_row(result)
|
|
317
|
-
|
|
318
300
|
except Exception as e:
|
|
319
301
|
if own_conn:
|
|
320
302
|
conn.rollback()
|
|
@@ -345,7 +327,6 @@ class PostgreSQLAdapter:
|
|
|
345
327
|
|
|
346
328
|
try:
|
|
347
329
|
cursor = conn.cursor()
|
|
348
|
-
|
|
349
330
|
sql = f"SELECT * FROM {table}"
|
|
350
331
|
params: List[Any] = []
|
|
351
332
|
|
|
@@ -353,8 +334,6 @@ class PostgreSQLAdapter:
|
|
|
353
334
|
where_parts: List[str] = []
|
|
354
335
|
for k, v in query.items():
|
|
355
336
|
validate_column_name(k)
|
|
356
|
-
|
|
357
|
-
# IMPORTANT: None must be "IS NULL" not "= %s"
|
|
358
337
|
if v is None:
|
|
359
338
|
where_parts.append(f"{k} IS NULL")
|
|
360
339
|
elif isinstance(v, (list, tuple)):
|
|
@@ -364,7 +343,6 @@ class PostgreSQLAdapter:
|
|
|
364
343
|
else:
|
|
365
344
|
where_parts.append(f"{k} = %s")
|
|
366
345
|
params.append(v)
|
|
367
|
-
|
|
368
346
|
if where_parts:
|
|
369
347
|
sql += " WHERE " + " AND ".join(where_parts)
|
|
370
348
|
|
|
@@ -375,14 +353,14 @@ class PostgreSQLAdapter:
|
|
|
375
353
|
sql += " OFFSET %s"
|
|
376
354
|
params.append(offset)
|
|
377
355
|
|
|
378
|
-
|
|
356
|
+
self._timed_execute(
|
|
357
|
+
cursor, sql, self._serialize_params(params), operation="select", table=table
|
|
358
|
+
)
|
|
379
359
|
columns = [desc[0] for desc in cursor.description]
|
|
380
360
|
results = [self._deserialize_row(dict(zip(columns, row))) for row in cursor.fetchall()]
|
|
381
361
|
cursor.close()
|
|
382
|
-
|
|
383
362
|
if own_conn:
|
|
384
363
|
conn.commit()
|
|
385
|
-
|
|
386
364
|
return results
|
|
387
365
|
except Exception as e:
|
|
388
366
|
if own_conn:
|
|
@@ -410,11 +388,9 @@ class PostgreSQLAdapter:
|
|
|
410
388
|
) -> Tuple[List[JsonDict], Optional[str]]:
|
|
411
389
|
offset = int(continuation_token) if continuation_token else 0
|
|
412
390
|
results = self.select(table, query, limit=page_size + 1, offset=offset, tx=tx)
|
|
413
|
-
|
|
414
391
|
has_more = len(results) > page_size
|
|
415
392
|
if has_more:
|
|
416
393
|
results = results[:page_size]
|
|
417
|
-
|
|
418
394
|
next_token = str(offset + page_size) if has_more else None
|
|
419
395
|
return results, next_token
|
|
420
396
|
|
|
@@ -442,7 +418,6 @@ class PostgreSQLAdapter:
|
|
|
442
418
|
|
|
443
419
|
try:
|
|
444
420
|
cursor = conn.cursor()
|
|
445
|
-
|
|
446
421
|
set_clause = ", ".join([f"{k} = %s" for k in data.keys()])
|
|
447
422
|
params: List[Any] = [self._serialize_value(v) for v in data.values()]
|
|
448
423
|
|
|
@@ -454,25 +429,21 @@ class PostgreSQLAdapter:
|
|
|
454
429
|
where_parts.append(f"{k} IS NULL")
|
|
455
430
|
else:
|
|
456
431
|
where_parts.append(f"{k} = %s")
|
|
457
|
-
params.append(self.
|
|
432
|
+
params.append(self._serialize_param(v))
|
|
458
433
|
where_clause = " AND ".join(where_parts)
|
|
459
434
|
else:
|
|
460
435
|
where_clause = "id = %s"
|
|
461
436
|
params.append(entity_id)
|
|
462
437
|
|
|
463
438
|
query = f"UPDATE {table} SET {set_clause} WHERE {where_clause} RETURNING *"
|
|
464
|
-
|
|
465
|
-
|
|
439
|
+
self._timed_execute(cursor, query, params, operation="update", table=table)
|
|
466
440
|
result_row = cursor.fetchone()
|
|
467
441
|
if not result_row:
|
|
468
442
|
raise DatabaseError("No rows updated")
|
|
469
|
-
|
|
470
443
|
columns = [desc[0] for desc in cursor.description]
|
|
471
444
|
result = dict(zip(columns, result_row))
|
|
472
|
-
|
|
473
445
|
if own_conn:
|
|
474
446
|
conn.commit()
|
|
475
|
-
|
|
476
447
|
cursor.close()
|
|
477
448
|
return self._deserialize_row(result)
|
|
478
449
|
except Exception as e:
|
|
@@ -501,10 +472,8 @@ class PostgreSQLAdapter:
|
|
|
501
472
|
|
|
502
473
|
try:
|
|
503
474
|
cursor = conn.cursor()
|
|
504
|
-
|
|
505
475
|
columns = ", ".join(data.keys())
|
|
506
476
|
placeholders = ", ".join(["%s"] * len(data))
|
|
507
|
-
|
|
508
477
|
conflict_columns = ["id"] if "id" in data else list(data.keys())[:1]
|
|
509
478
|
update_fields = [k for k in data.keys() if k not in conflict_columns]
|
|
510
479
|
|
|
@@ -521,14 +490,10 @@ class PostgreSQLAdapter:
|
|
|
521
490
|
{on_conflict}
|
|
522
491
|
RETURNING *
|
|
523
492
|
"""
|
|
524
|
-
|
|
525
493
|
values = [self._serialize_value(v) for v in data.values()]
|
|
526
|
-
|
|
527
|
-
|
|
494
|
+
self._timed_execute(cursor, query, values, operation="upsert", table=table)
|
|
528
495
|
result_row = cursor.fetchone()
|
|
529
496
|
if not result_row:
|
|
530
|
-
# DO NOTHING case: fetch existing row (best effort)
|
|
531
|
-
# If conflict is on id, we can read it back.
|
|
532
497
|
if "id" in conflict_columns and "id" in data:
|
|
533
498
|
cursor.execute(f"SELECT * FROM {table} WHERE id = %s", [data["id"]])
|
|
534
499
|
result_row = cursor.fetchone()
|
|
@@ -539,10 +504,8 @@ class PostgreSQLAdapter:
|
|
|
539
504
|
|
|
540
505
|
columns_list = [desc[0] for desc in cursor.description]
|
|
541
506
|
result = dict(zip(columns_list, result_row))
|
|
542
|
-
|
|
543
507
|
if own_conn:
|
|
544
508
|
conn.commit()
|
|
545
|
-
|
|
546
509
|
cursor.close()
|
|
547
510
|
return self._deserialize_row(result)
|
|
548
511
|
except Exception as e:
|
|
@@ -570,7 +533,6 @@ class PostgreSQLAdapter:
|
|
|
570
533
|
|
|
571
534
|
try:
|
|
572
535
|
cursor = conn.cursor()
|
|
573
|
-
|
|
574
536
|
params: List[Any] = []
|
|
575
537
|
if isinstance(entity_id, dict):
|
|
576
538
|
where_parts: List[str] = []
|
|
@@ -580,25 +542,21 @@ class PostgreSQLAdapter:
|
|
|
580
542
|
where_parts.append(f"{k} IS NULL")
|
|
581
543
|
else:
|
|
582
544
|
where_parts.append(f"{k} = %s")
|
|
583
|
-
params.append(self.
|
|
545
|
+
params.append(self._serialize_param(v))
|
|
584
546
|
where_clause = " AND ".join(where_parts)
|
|
585
547
|
else:
|
|
586
548
|
where_clause = "id = %s"
|
|
587
549
|
params.append(entity_id)
|
|
588
550
|
|
|
589
551
|
query = f"DELETE FROM {table} WHERE {where_clause} RETURNING *"
|
|
590
|
-
|
|
552
|
+
self._timed_execute(cursor, query, params, operation="delete", table=table)
|
|
591
553
|
result_row = cursor.fetchone()
|
|
592
|
-
|
|
593
554
|
if not result_row:
|
|
594
555
|
raise DatabaseError("No rows deleted")
|
|
595
|
-
|
|
596
556
|
columns = [desc[0] for desc in cursor.description]
|
|
597
557
|
result = dict(zip(columns, result_row))
|
|
598
|
-
|
|
599
558
|
if own_conn:
|
|
600
559
|
conn.commit()
|
|
601
|
-
|
|
602
560
|
cursor.close()
|
|
603
561
|
return self._deserialize_row(result)
|
|
604
562
|
except Exception as e:
|
|
@@ -617,9 +575,7 @@ class PostgreSQLAdapter:
|
|
|
617
575
|
def query_linq(
|
|
618
576
|
self, table: str, builder: QueryBuilder, tx: Optional[Any] = None
|
|
619
577
|
) -> Union[List[JsonDict], int]:
|
|
620
|
-
|
|
621
578
|
table = validate_table_name(table)
|
|
622
|
-
|
|
623
579
|
conn = tx
|
|
624
580
|
own_conn = False
|
|
625
581
|
if not conn:
|
|
@@ -628,117 +584,67 @@ class PostgreSQLAdapter:
|
|
|
628
584
|
|
|
629
585
|
try:
|
|
630
586
|
cursor = conn.cursor()
|
|
631
|
-
|
|
632
|
-
# ------------------------------------------------
|
|
633
|
-
# SELECT clause
|
|
634
|
-
# ------------------------------------------------
|
|
635
|
-
|
|
636
587
|
if builder.count_only:
|
|
637
588
|
sql = f"SELECT COUNT(*) FROM {table}"
|
|
638
|
-
|
|
639
589
|
elif builder.selected_fields:
|
|
640
590
|
for f in builder.selected_fields:
|
|
641
591
|
validate_column_name(f)
|
|
642
|
-
|
|
643
592
|
fields = ", ".join(builder.selected_fields)
|
|
644
|
-
|
|
645
593
|
if builder.distinct_flag:
|
|
646
594
|
sql = f"SELECT DISTINCT {fields} FROM {table}"
|
|
647
595
|
else:
|
|
648
596
|
sql = f"SELECT {fields} FROM {table}"
|
|
649
|
-
|
|
650
597
|
else:
|
|
651
598
|
sql = f"SELECT * FROM {table}"
|
|
652
599
|
|
|
653
600
|
params: List[Any] = []
|
|
654
|
-
|
|
655
|
-
# ------------------------------------------------
|
|
656
|
-
# WHERE
|
|
657
|
-
# ------------------------------------------------
|
|
658
|
-
|
|
659
601
|
where_clause, where_params = builder.to_sql_where()
|
|
660
|
-
|
|
661
602
|
if where_clause:
|
|
662
603
|
sql += f" WHERE {where_clause}"
|
|
663
604
|
params.extend(where_params)
|
|
664
605
|
|
|
665
|
-
# ------------------------------------------------
|
|
666
|
-
# GROUP BY
|
|
667
|
-
# ------------------------------------------------
|
|
668
|
-
|
|
669
606
|
if builder.group_by_fields:
|
|
670
|
-
|
|
671
607
|
for f in builder.group_by_fields:
|
|
672
608
|
validate_column_name(f)
|
|
673
|
-
|
|
674
609
|
sql += f" GROUP BY {', '.join(builder.group_by_fields)}"
|
|
675
610
|
|
|
676
|
-
# ------------------------------------------------
|
|
677
|
-
# ORDER BY
|
|
678
|
-
# ------------------------------------------------
|
|
679
|
-
|
|
680
611
|
if builder.order_by_fields:
|
|
681
|
-
|
|
682
612
|
order_parts = []
|
|
683
|
-
|
|
684
613
|
for field, desc in builder.order_by_fields:
|
|
685
|
-
|
|
686
614
|
validate_column_name(field)
|
|
687
|
-
|
|
688
615
|
direction = "DESC" if desc else "ASC"
|
|
689
|
-
|
|
690
616
|
order_parts.append(f"{field} {direction}")
|
|
691
|
-
|
|
692
617
|
sql += f" ORDER BY {', '.join(order_parts)}"
|
|
693
618
|
|
|
694
|
-
# ------------------------------------------------
|
|
695
|
-
# LIMIT
|
|
696
|
-
# ------------------------------------------------
|
|
697
|
-
|
|
698
619
|
if builder.take_count is not None:
|
|
699
620
|
sql += " LIMIT %s"
|
|
700
621
|
params.append(builder.take_count)
|
|
701
622
|
|
|
702
|
-
# ------------------------------------------------
|
|
703
|
-
# OFFSET
|
|
704
|
-
# ------------------------------------------------
|
|
705
|
-
|
|
706
623
|
if builder.skip_count:
|
|
707
624
|
sql += " OFFSET %s"
|
|
708
625
|
params.append(builder.skip_count)
|
|
709
626
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
cursor.execute(sql, self._serialize_params(params))
|
|
627
|
+
self._timed_execute(
|
|
628
|
+
cursor, sql, self._serialize_params(params), operation="query_linq", table=table
|
|
629
|
+
)
|
|
715
630
|
|
|
716
631
|
if builder.count_only:
|
|
717
632
|
result = cursor.fetchone()[0]
|
|
718
|
-
|
|
719
633
|
else:
|
|
720
634
|
columns = [desc[0] for desc in cursor.description]
|
|
721
|
-
|
|
722
635
|
result = [
|
|
723
636
|
self._deserialize_row(dict(zip(columns, row))) for row in cursor.fetchall()
|
|
724
637
|
]
|
|
725
638
|
|
|
726
639
|
cursor.close()
|
|
727
|
-
|
|
728
640
|
if own_conn:
|
|
729
641
|
conn.commit()
|
|
730
|
-
|
|
731
642
|
return result
|
|
732
|
-
|
|
733
643
|
except Exception as e:
|
|
734
|
-
|
|
735
644
|
if own_conn:
|
|
736
645
|
conn.rollback()
|
|
737
|
-
|
|
738
646
|
raise DatabaseError(f"LINQ query failed: {str(e)}")
|
|
739
|
-
|
|
740
647
|
finally:
|
|
741
|
-
|
|
742
648
|
if own_conn and conn:
|
|
743
649
|
self._return_connection(conn)
|
|
744
650
|
|
|
@@ -765,10 +671,10 @@ class PostgreSQLAdapter:
|
|
|
765
671
|
cursor = None
|
|
766
672
|
try:
|
|
767
673
|
cursor = conn.cursor()
|
|
768
|
-
|
|
769
|
-
|
|
674
|
+
# Log operation shape only, never parameter values
|
|
675
|
+
self.logger.debug("Executing raw SQL (%d params)", len(params or []))
|
|
770
676
|
exec_params = self._serialize_params(params or [])
|
|
771
|
-
|
|
677
|
+
self._timed_execute(cursor, sql, exec_params, operation="execute", table="")
|
|
772
678
|
|
|
773
679
|
if fetch_one:
|
|
774
680
|
row = cursor.fetchone()
|
|
@@ -791,7 +697,6 @@ class PostgreSQLAdapter:
|
|
|
791
697
|
if own_conn:
|
|
792
698
|
conn.commit()
|
|
793
699
|
return None
|
|
794
|
-
|
|
795
700
|
except Exception as e:
|
|
796
701
|
if own_conn:
|
|
797
702
|
try:
|
|
@@ -799,7 +704,6 @@ class PostgreSQLAdapter:
|
|
|
799
704
|
except Exception:
|
|
800
705
|
pass
|
|
801
706
|
raise DatabaseError(f"Execute failed: {str(e)}")
|
|
802
|
-
|
|
803
707
|
finally:
|
|
804
708
|
if cursor:
|
|
805
709
|
try:
|
|
@@ -809,17 +713,234 @@ class PostgreSQLAdapter:
|
|
|
809
713
|
if own_conn and conn:
|
|
810
714
|
self._return_connection(conn)
|
|
811
715
|
|
|
716
|
+
# ---------------------------------------------------------------------
|
|
717
|
+
# ATOMIC OPERATIONS
|
|
718
|
+
# ---------------------------------------------------------------------
|
|
719
|
+
|
|
720
|
+
def atomic_decrement_if_sufficient(
|
|
721
|
+
self,
|
|
722
|
+
table: str,
|
|
723
|
+
balance_field: str,
|
|
724
|
+
amount: Any,
|
|
725
|
+
where_field: str,
|
|
726
|
+
where_value: Any,
|
|
727
|
+
*,
|
|
728
|
+
tx: Optional[Any] = None,
|
|
729
|
+
) -> JsonDict:
|
|
730
|
+
table = validate_table_name(table)
|
|
731
|
+
validate_column_name(balance_field)
|
|
732
|
+
validate_column_name(where_field)
|
|
733
|
+
|
|
734
|
+
conn = tx
|
|
735
|
+
own_conn = False
|
|
736
|
+
if not conn:
|
|
737
|
+
conn = self._get_connection()
|
|
738
|
+
own_conn = True
|
|
739
|
+
|
|
740
|
+
try:
|
|
741
|
+
cursor = conn.cursor()
|
|
742
|
+
sql = (
|
|
743
|
+
f"UPDATE {table} "
|
|
744
|
+
f"SET {balance_field} = {balance_field} - %s "
|
|
745
|
+
f"WHERE {where_field} = %s AND {balance_field} >= %s "
|
|
746
|
+
f"RETURNING *"
|
|
747
|
+
)
|
|
748
|
+
cursor.execute(sql, [amount, where_value, amount])
|
|
749
|
+
result_row = cursor.fetchone()
|
|
750
|
+
|
|
751
|
+
if not result_row:
|
|
752
|
+
if own_conn:
|
|
753
|
+
conn.rollback()
|
|
754
|
+
raise InsufficientBalanceError(
|
|
755
|
+
f"Insufficient balance in {table}.{balance_field} for {where_field}={where_value!r}"
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
columns = [desc[0] for desc in cursor.description]
|
|
759
|
+
result = dict(zip(columns, result_row))
|
|
760
|
+
if own_conn:
|
|
761
|
+
conn.commit()
|
|
762
|
+
cursor.close()
|
|
763
|
+
return self._deserialize_row(result)
|
|
764
|
+
except InsufficientBalanceError:
|
|
765
|
+
raise
|
|
766
|
+
except Exception as e:
|
|
767
|
+
if own_conn:
|
|
768
|
+
try:
|
|
769
|
+
conn.rollback()
|
|
770
|
+
except Exception:
|
|
771
|
+
pass
|
|
772
|
+
raise DatabaseError(f"atomic_decrement_if_sufficient failed: {str(e)}")
|
|
773
|
+
finally:
|
|
774
|
+
if own_conn and conn:
|
|
775
|
+
self._return_connection(conn)
|
|
776
|
+
|
|
777
|
+
def atomic_set_if(
|
|
778
|
+
self,
|
|
779
|
+
table: str,
|
|
780
|
+
id_field: str,
|
|
781
|
+
id_value: Any,
|
|
782
|
+
field: str,
|
|
783
|
+
value: Any,
|
|
784
|
+
expected: Any,
|
|
785
|
+
*,
|
|
786
|
+
tx: Optional[Any] = None,
|
|
787
|
+
) -> Optional[JsonDict]:
|
|
788
|
+
table = validate_table_name(table)
|
|
789
|
+
validate_column_name(field)
|
|
790
|
+
validate_column_name(id_field)
|
|
791
|
+
|
|
792
|
+
conn = tx
|
|
793
|
+
own_conn = not conn
|
|
794
|
+
if own_conn:
|
|
795
|
+
conn = self._get_connection()
|
|
796
|
+
try:
|
|
797
|
+
cursor = conn.cursor()
|
|
798
|
+
sql = (
|
|
799
|
+
f"UPDATE {table} SET {field} = %s "
|
|
800
|
+
f"WHERE {id_field} = %s AND {field} = %s RETURNING *"
|
|
801
|
+
)
|
|
802
|
+
cursor.execute(sql, [value, id_value, expected])
|
|
803
|
+
row = cursor.fetchone()
|
|
804
|
+
if own_conn:
|
|
805
|
+
conn.commit()
|
|
806
|
+
cursor.close()
|
|
807
|
+
if row is None:
|
|
808
|
+
return None
|
|
809
|
+
cols = [d[0] for d in cursor.description]
|
|
810
|
+
return self._deserialize_row(dict(zip(cols, row)))
|
|
811
|
+
except Exception as e:
|
|
812
|
+
if own_conn:
|
|
813
|
+
try:
|
|
814
|
+
conn.rollback()
|
|
815
|
+
except Exception:
|
|
816
|
+
pass
|
|
817
|
+
raise DatabaseError(f"atomic_set_if failed: {e}") from e
|
|
818
|
+
finally:
|
|
819
|
+
if own_conn and conn:
|
|
820
|
+
self._return_connection(conn)
|
|
821
|
+
|
|
822
|
+
def atomic_max(
|
|
823
|
+
self,
|
|
824
|
+
table: str,
|
|
825
|
+
id_field: str,
|
|
826
|
+
id_value: Any,
|
|
827
|
+
field: str,
|
|
828
|
+
value: Any,
|
|
829
|
+
*,
|
|
830
|
+
tx: Optional[Any] = None,
|
|
831
|
+
) -> JsonDict:
|
|
832
|
+
table = validate_table_name(table)
|
|
833
|
+
validate_column_name(field)
|
|
834
|
+
validate_column_name(id_field)
|
|
835
|
+
|
|
836
|
+
conn = tx
|
|
837
|
+
own_conn = not conn
|
|
838
|
+
if own_conn:
|
|
839
|
+
conn = self._get_connection()
|
|
840
|
+
try:
|
|
841
|
+
cursor = conn.cursor()
|
|
842
|
+
cursor.execute(
|
|
843
|
+
f"UPDATE {table} SET {field} = GREATEST({field}, %s) WHERE {id_field} = %s RETURNING *",
|
|
844
|
+
[value, id_value],
|
|
845
|
+
)
|
|
846
|
+
row = cursor.fetchone()
|
|
847
|
+
if own_conn:
|
|
848
|
+
conn.commit()
|
|
849
|
+
cursor.close()
|
|
850
|
+
if row is None:
|
|
851
|
+
raise DatabaseError(f"atomic_max: row not found {id_field}={id_value!r}")
|
|
852
|
+
cols = [d[0] for d in cursor.description]
|
|
853
|
+
return self._deserialize_row(dict(zip(cols, row)))
|
|
854
|
+
except DatabaseError:
|
|
855
|
+
raise
|
|
856
|
+
except Exception as e:
|
|
857
|
+
if own_conn:
|
|
858
|
+
try:
|
|
859
|
+
conn.rollback()
|
|
860
|
+
except Exception:
|
|
861
|
+
pass
|
|
862
|
+
raise DatabaseError(f"atomic_max failed: {e}") from e
|
|
863
|
+
finally:
|
|
864
|
+
if own_conn and conn:
|
|
865
|
+
self._return_connection(conn)
|
|
866
|
+
|
|
867
|
+
def atomic_min(
|
|
868
|
+
self,
|
|
869
|
+
table: str,
|
|
870
|
+
id_field: str,
|
|
871
|
+
id_value: Any,
|
|
872
|
+
field: str,
|
|
873
|
+
value: Any,
|
|
874
|
+
*,
|
|
875
|
+
tx: Optional[Any] = None,
|
|
876
|
+
) -> JsonDict:
|
|
877
|
+
table = validate_table_name(table)
|
|
878
|
+
validate_column_name(field)
|
|
879
|
+
validate_column_name(id_field)
|
|
880
|
+
|
|
881
|
+
conn = tx
|
|
882
|
+
own_conn = not conn
|
|
883
|
+
if own_conn:
|
|
884
|
+
conn = self._get_connection()
|
|
885
|
+
try:
|
|
886
|
+
cursor = conn.cursor()
|
|
887
|
+
cursor.execute(
|
|
888
|
+
f"UPDATE {table} SET {field} = LEAST({field}, %s) WHERE {id_field} = %s RETURNING *",
|
|
889
|
+
[value, id_value],
|
|
890
|
+
)
|
|
891
|
+
row = cursor.fetchone()
|
|
892
|
+
if own_conn:
|
|
893
|
+
conn.commit()
|
|
894
|
+
cursor.close()
|
|
895
|
+
if row is None:
|
|
896
|
+
raise DatabaseError(f"atomic_min: row not found {id_field}={id_value!r}")
|
|
897
|
+
cols = [d[0] for d in cursor.description]
|
|
898
|
+
return self._deserialize_row(dict(zip(cols, row)))
|
|
899
|
+
except DatabaseError:
|
|
900
|
+
raise
|
|
901
|
+
except Exception as e:
|
|
902
|
+
if own_conn:
|
|
903
|
+
try:
|
|
904
|
+
conn.rollback()
|
|
905
|
+
except Exception:
|
|
906
|
+
pass
|
|
907
|
+
raise DatabaseError(f"atomic_min failed: {e}") from e
|
|
908
|
+
finally:
|
|
909
|
+
if own_conn and conn:
|
|
910
|
+
self._return_connection(conn)
|
|
911
|
+
|
|
912
|
+
# ---------------------------------------------------------------------
|
|
913
|
+
# SAVEPOINTS
|
|
914
|
+
# Security: use pg_sql.Identifier to prevent SQL injection via savepoint names.
|
|
915
|
+
# ---------------------------------------------------------------------
|
|
916
|
+
|
|
917
|
+
def begin_savepoint(self, name: str, tx: Any) -> None:
|
|
918
|
+
try:
|
|
919
|
+
with tx.cursor() as cur:
|
|
920
|
+
cur.execute(pg_sql.SQL("SAVEPOINT {}").format(pg_sql.Identifier(name)))
|
|
921
|
+
except Exception as e:
|
|
922
|
+
raise DatabaseError(f"begin_savepoint({name!r}) failed: {str(e)}")
|
|
923
|
+
|
|
924
|
+
def rollback_to_savepoint(self, name: str, tx: Any) -> None:
|
|
925
|
+
try:
|
|
926
|
+
with tx.cursor() as cur:
|
|
927
|
+
cur.execute(pg_sql.SQL("ROLLBACK TO SAVEPOINT {}").format(pg_sql.Identifier(name)))
|
|
928
|
+
except Exception as e:
|
|
929
|
+
raise DatabaseError(f"rollback_to_savepoint({name!r}) failed: {str(e)}")
|
|
930
|
+
|
|
931
|
+
def release_savepoint(self, name: str, tx: Any) -> None:
|
|
932
|
+
try:
|
|
933
|
+
with tx.cursor() as cur:
|
|
934
|
+
cur.execute(pg_sql.SQL("RELEASE SAVEPOINT {}").format(pg_sql.Identifier(name)))
|
|
935
|
+
except Exception as e:
|
|
936
|
+
raise DatabaseError(f"release_savepoint({name!r}) failed: {str(e)}")
|
|
937
|
+
|
|
812
938
|
# ---------------------------------------------------------------------
|
|
813
939
|
# DISTRIBUTED LOCK
|
|
814
940
|
# ---------------------------------------------------------------------
|
|
815
941
|
|
|
816
942
|
@contextmanager
|
|
817
943
|
def distributed_lock(self, lock_name: str) -> Iterator[None]:
|
|
818
|
-
"""
|
|
819
|
-
PostgreSQL advisory lock (session scoped).
|
|
820
|
-
- Always unlock before returning the pooled connection.
|
|
821
|
-
- Never wrap exceptions raised by the user block.
|
|
822
|
-
"""
|
|
823
944
|
conn = None
|
|
824
945
|
cursor = None
|
|
825
946
|
lock_id = int(hashlib.sha256(lock_name.encode()).hexdigest(), 16) % (2**63)
|
|
@@ -827,8 +948,6 @@ class PostgreSQLAdapter:
|
|
|
827
948
|
try:
|
|
828
949
|
conn = self._get_connection()
|
|
829
950
|
cursor = conn.cursor()
|
|
830
|
-
|
|
831
|
-
# Acquire lock (DB op) — if this fails, it's a DatabaseError
|
|
832
951
|
try:
|
|
833
952
|
cursor.execute("SELECT pg_advisory_lock(%s);", (lock_id,))
|
|
834
953
|
self.logger.debug("Acquired distributed lock: %s", lock_name)
|
|
@@ -836,21 +955,15 @@ class PostgreSQLAdapter:
|
|
|
836
955
|
raise DatabaseError(f"Distributed lock acquire failed: {e}") from e
|
|
837
956
|
|
|
838
957
|
try:
|
|
839
|
-
# User code runs here. If it raises, it MUST propagate unchanged.
|
|
840
958
|
yield
|
|
841
959
|
finally:
|
|
842
|
-
# Release lock (DB op). Always attempted.
|
|
843
960
|
try:
|
|
844
961
|
cursor.execute("SELECT pg_advisory_unlock(%s);", (lock_id,))
|
|
845
962
|
self.logger.debug("Released distributed lock: %s", lock_name)
|
|
846
963
|
except Exception as e:
|
|
847
|
-
# IMPORTANT: don't mask user exceptions.
|
|
848
|
-
# If unlock fails, raise DatabaseError only if user block didn't already fail.
|
|
849
|
-
# Easiest safe behavior: just log and continue.
|
|
850
964
|
self.logger.exception(
|
|
851
965
|
"Distributed lock release failed for %s: %s", lock_name, e
|
|
852
966
|
)
|
|
853
|
-
|
|
854
967
|
finally:
|
|
855
968
|
if cursor:
|
|
856
969
|
try:
|