altcodepro-polydb-python 2.2.2__py3-none-any.whl → 2.2.4__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.2.4.dist-info/METADATA +489 -0
- altcodepro_polydb_python-2.2.4.dist-info/RECORD +57 -0
- {altcodepro_polydb_python-2.2.2.dist-info → altcodepro_polydb_python-2.2.4.dist-info}/WHEEL +1 -1
- polydb/__init__.py +2 -2
- polydb/adapters/AzureBlobStorageAdapter.py +146 -41
- polydb/adapters/AzureFileStorageAdapter.py +148 -43
- polydb/adapters/AzureQueueAdapter.py +96 -34
- polydb/adapters/AzureTableStorageAdapter.py +462 -119
- polydb/adapters/BlockchainBlobAdapter.py +111 -0
- polydb/adapters/BlockchainKVAdapter.py +152 -0
- polydb/adapters/BlockchainQueueAdapter.py +116 -0
- polydb/adapters/DynamoDBAdapter.py +463 -176
- polydb/adapters/FirestoreAdapter.py +320 -148
- polydb/adapters/GCPPubSubAdapter.py +217 -0
- polydb/adapters/GCPStorageAdapter.py +184 -39
- polydb/adapters/MongoDBAdapter.py +159 -39
- polydb/adapters/PostgreSQLAdapter.py +285 -83
- polydb/adapters/S3Adapter.py +172 -35
- polydb/adapters/S3CompatibleAdapter.py +62 -8
- polydb/adapters/SQSAdapter.py +121 -44
- polydb/adapters/VercelBlobAdapter.py +196 -0
- polydb/adapters/VercelKVAdapter.py +275 -283
- polydb/adapters/VercelQueueAdapter.py +61 -0
- polydb/audit/AuditStorage.py +1 -1
- polydb/base/NoSQLKVAdapter.py +113 -101
- polydb/base/ObjectStorageAdapter.py +42 -6
- polydb/base/QueueAdapter.py +2 -2
- polydb/base/SharedFilesAdapter.py +2 -2
- polydb/cloudDatabaseFactory.py +200 -0
- polydb/databaseFactory.py +434 -101
- polydb/models.py +63 -1
- polydb/query.py +111 -42
- altcodepro_polydb_python-2.2.2.dist-info/METADATA +0 -379
- altcodepro_polydb_python-2.2.2.dist-info/RECORD +0 -52
- polydb/adapters/PubSubAdapter.py +0 -85
- polydb/factory.py +0 -107
- {altcodepro_polydb_python-2.2.2.dist-info → altcodepro_polydb_python-2.2.4.dist-info}/licenses/LICENSE +0 -0
- {altcodepro_polydb_python-2.2.2.dist-info → altcodepro_polydb_python-2.2.4.dist-info}/top_level.txt +0 -0
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
# src/polydb/adapters/postgres.py
|
|
2
|
+
import datetime
|
|
3
|
+
from decimal import Decimal
|
|
2
4
|
import os
|
|
3
5
|
import threading
|
|
4
|
-
from typing import Any, List, Optional, Tuple, Union
|
|
6
|
+
from typing import Any, Iterator, List, Optional, Tuple, Union
|
|
5
7
|
import hashlib
|
|
6
8
|
from contextlib import contextmanager
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime, date
|
|
11
|
+
from psycopg2.extras import Json
|
|
7
12
|
|
|
8
13
|
from ..errors import DatabaseError, ConnectionError
|
|
9
14
|
from ..retry import retry
|
|
@@ -13,13 +18,13 @@ from ..types import JsonDict, Lookup
|
|
|
13
18
|
|
|
14
19
|
|
|
15
20
|
class PostgreSQLAdapter:
|
|
16
|
-
"""PostgreSQL with full LINQ support, connection pooling"""
|
|
21
|
+
"""PostgreSQL with full LINQ support, connection pooling, JSON/JSONB support"""
|
|
17
22
|
|
|
18
|
-
def __init__(self):
|
|
23
|
+
def __init__(self, connection_string: Optional[str] = None):
|
|
19
24
|
from ..utils import setup_logger
|
|
20
25
|
|
|
21
26
|
self.logger = setup_logger(__name__)
|
|
22
|
-
self.connection_string = os.getenv(
|
|
27
|
+
self.connection_string = connection_string or os.getenv(
|
|
23
28
|
"POSTGRES_CONNECTION_STRING",
|
|
24
29
|
os.getenv("POSTGRES_URL", ""),
|
|
25
30
|
)
|
|
@@ -53,10 +58,14 @@ class PostgreSQLAdapter:
|
|
|
53
58
|
if self._pool and conn:
|
|
54
59
|
self._pool.putconn(conn)
|
|
55
60
|
|
|
61
|
+
# ---------------------------------------------------------------------
|
|
62
|
+
# TRANSACTIONS
|
|
63
|
+
# ---------------------------------------------------------------------
|
|
64
|
+
|
|
56
65
|
def begin_transaction(self) -> Any:
|
|
57
66
|
"""Begin a transaction and return the connection handle."""
|
|
58
67
|
conn = self._get_connection()
|
|
59
|
-
conn.autocommit = False
|
|
68
|
+
conn.autocommit = False
|
|
60
69
|
return conn
|
|
61
70
|
|
|
62
71
|
def commit(self, tx: Any):
|
|
@@ -71,6 +80,87 @@ class PostgreSQLAdapter:
|
|
|
71
80
|
tx.rollback()
|
|
72
81
|
self._return_connection(tx)
|
|
73
82
|
|
|
83
|
+
# ---------------------------------------------------------------------
|
|
84
|
+
# JSON HELPERS
|
|
85
|
+
# ---------------------------------------------------------------------
|
|
86
|
+
def _json_safe(self, obj: Any):
|
|
87
|
+
"""
|
|
88
|
+
Ensure JSON serialization never fails.
|
|
89
|
+
Used only for Json() wrapping.
|
|
90
|
+
"""
|
|
91
|
+
if isinstance(obj, datetime):
|
|
92
|
+
return obj.isoformat()
|
|
93
|
+
if isinstance(obj, Decimal):
|
|
94
|
+
return float(obj)
|
|
95
|
+
if isinstance(obj, date):
|
|
96
|
+
return str(obj)
|
|
97
|
+
if isinstance(obj, dict):
|
|
98
|
+
return {k: self._json_safe(v) for k, v in obj.items()}
|
|
99
|
+
if isinstance(obj, list):
|
|
100
|
+
return [self._json_safe(v) for v in obj]
|
|
101
|
+
return obj
|
|
102
|
+
|
|
103
|
+
def _serialize_value(self, v: Any) -> Any:
|
|
104
|
+
"""
|
|
105
|
+
Make all outgoing values safe for psycopg2.
|
|
106
|
+
|
|
107
|
+
Rules:
|
|
108
|
+
- dict -> Json()
|
|
109
|
+
- list -> leave as list (so TEXT[] works)
|
|
110
|
+
- datetime/date -> pass as native (psycopg2 handles it)
|
|
111
|
+
- Decimal -> convert to float
|
|
112
|
+
- everything else -> pass as-is
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
if v is None:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
# Dict -> JSON/JSONB
|
|
119
|
+
if isinstance(v, dict):
|
|
120
|
+
return Json(self._json_safe(v))
|
|
121
|
+
|
|
122
|
+
# List:
|
|
123
|
+
# DO NOT wrap in Json() automatically.
|
|
124
|
+
# If column is JSONB, Postgres will still accept Json(list).
|
|
125
|
+
# But for TEXT[] columns we must send Python list.
|
|
126
|
+
if isinstance(v, list):
|
|
127
|
+
return v
|
|
128
|
+
|
|
129
|
+
# Datetime / date
|
|
130
|
+
if isinstance(v, (datetime, date)):
|
|
131
|
+
return v # psycopg2 handles natively
|
|
132
|
+
|
|
133
|
+
# Decimal
|
|
134
|
+
if isinstance(v, Decimal):
|
|
135
|
+
return float(v)
|
|
136
|
+
|
|
137
|
+
return v
|
|
138
|
+
|
|
139
|
+
def _serialize_params(self, params: List[Any]) -> List[Any]:
|
|
140
|
+
return [self._serialize_value(p) for p in params]
|
|
141
|
+
|
|
142
|
+
def _deserialize_row(self, row: JsonDict) -> JsonDict:
|
|
143
|
+
"""
|
|
144
|
+
Postgres JSON/JSONB often comes back as dict already depending on driver config.
|
|
145
|
+
If it comes as a string, try json.loads safely.
|
|
146
|
+
"""
|
|
147
|
+
for k, v in list(row.items()):
|
|
148
|
+
if isinstance(v, str):
|
|
149
|
+
s = v.strip()
|
|
150
|
+
# quick cheap check to avoid parsing normal strings
|
|
151
|
+
if (s.startswith("{") and s.endswith("}")) or (
|
|
152
|
+
s.startswith("[") and s.endswith("]")
|
|
153
|
+
):
|
|
154
|
+
try:
|
|
155
|
+
row[k] = json.loads(s)
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
return row
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------
|
|
161
|
+
# INSERT
|
|
162
|
+
# ---------------------------------------------------------------------
|
|
163
|
+
|
|
74
164
|
@retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
|
|
75
165
|
def insert(self, table: str, data: JsonDict, tx: Optional[Any] = None) -> JsonDict:
|
|
76
166
|
table = validate_table_name(table)
|
|
@@ -90,7 +180,9 @@ class PostgreSQLAdapter:
|
|
|
90
180
|
placeholders = ", ".join(["%s"] * len(data))
|
|
91
181
|
query = f"INSERT INTO {table} ({columns}) VALUES ({placeholders}) RETURNING *"
|
|
92
182
|
|
|
93
|
-
|
|
183
|
+
values = [self._serialize_value(v) for v in data.values()]
|
|
184
|
+
cursor.execute(query, values)
|
|
185
|
+
|
|
94
186
|
result_row = cursor.fetchone()
|
|
95
187
|
columns_list = [desc[0] for desc in cursor.description]
|
|
96
188
|
result = dict(zip(columns_list, result_row))
|
|
@@ -99,7 +191,8 @@ class PostgreSQLAdapter:
|
|
|
99
191
|
conn.commit()
|
|
100
192
|
|
|
101
193
|
cursor.close()
|
|
102
|
-
return result
|
|
194
|
+
return self._deserialize_row(result)
|
|
195
|
+
|
|
103
196
|
except Exception as e:
|
|
104
197
|
if own_conn:
|
|
105
198
|
conn.rollback()
|
|
@@ -108,6 +201,10 @@ class PostgreSQLAdapter:
|
|
|
108
201
|
if own_conn and conn:
|
|
109
202
|
self._return_connection(conn)
|
|
110
203
|
|
|
204
|
+
# ---------------------------------------------------------------------
|
|
205
|
+
# SELECT
|
|
206
|
+
# ---------------------------------------------------------------------
|
|
207
|
+
|
|
111
208
|
@retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
|
|
112
209
|
def select(
|
|
113
210
|
self,
|
|
@@ -128,16 +225,20 @@ class PostgreSQLAdapter:
|
|
|
128
225
|
cursor = conn.cursor()
|
|
129
226
|
|
|
130
227
|
sql = f"SELECT * FROM {table}"
|
|
131
|
-
params = []
|
|
228
|
+
params: List[Any] = []
|
|
132
229
|
|
|
133
230
|
if query:
|
|
134
|
-
where_parts = []
|
|
231
|
+
where_parts: List[str] = []
|
|
135
232
|
for k, v in query.items():
|
|
136
233
|
validate_column_name(k)
|
|
137
|
-
|
|
234
|
+
|
|
235
|
+
# IMPORTANT: None must be "IS NULL" not "= %s"
|
|
236
|
+
if v is None:
|
|
237
|
+
where_parts.append(f"{k} IS NULL")
|
|
238
|
+
elif isinstance(v, (list, tuple)):
|
|
138
239
|
placeholders = ",".join(["%s"] * len(v))
|
|
139
240
|
where_parts.append(f"{k} IN ({placeholders})")
|
|
140
|
-
params.extend(v)
|
|
241
|
+
params.extend(list(v))
|
|
141
242
|
else:
|
|
142
243
|
where_parts.append(f"{k} = %s")
|
|
143
244
|
params.append(v)
|
|
@@ -146,15 +247,15 @@ class PostgreSQLAdapter:
|
|
|
146
247
|
sql += " WHERE " + " AND ".join(where_parts)
|
|
147
248
|
|
|
148
249
|
if limit:
|
|
149
|
-
sql +=
|
|
250
|
+
sql += " LIMIT %s"
|
|
150
251
|
params.append(limit)
|
|
151
252
|
if offset:
|
|
152
|
-
sql +=
|
|
253
|
+
sql += " OFFSET %s"
|
|
153
254
|
params.append(offset)
|
|
154
255
|
|
|
155
|
-
cursor.execute(sql, params)
|
|
256
|
+
cursor.execute(sql, self._serialize_params(params))
|
|
156
257
|
columns = [desc[0] for desc in cursor.description]
|
|
157
|
-
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
|
258
|
+
results = [self._deserialize_row(dict(zip(columns, row))) for row in cursor.fetchall()]
|
|
158
259
|
cursor.close()
|
|
159
260
|
|
|
160
261
|
return results
|
|
@@ -164,6 +265,10 @@ class PostgreSQLAdapter:
|
|
|
164
265
|
if own_conn and conn:
|
|
165
266
|
self._return_connection(conn)
|
|
166
267
|
|
|
268
|
+
# ---------------------------------------------------------------------
|
|
269
|
+
# SELECT PAGE
|
|
270
|
+
# ---------------------------------------------------------------------
|
|
271
|
+
|
|
167
272
|
@retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
|
|
168
273
|
def select_page(
|
|
169
274
|
self,
|
|
@@ -183,6 +288,10 @@ class PostgreSQLAdapter:
|
|
|
183
288
|
next_token = str(offset + page_size) if has_more else None
|
|
184
289
|
return results, next_token
|
|
185
290
|
|
|
291
|
+
# ---------------------------------------------------------------------
|
|
292
|
+
# UPDATE
|
|
293
|
+
# ---------------------------------------------------------------------
|
|
294
|
+
|
|
186
295
|
@retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
|
|
187
296
|
def update(
|
|
188
297
|
self,
|
|
@@ -205,14 +314,17 @@ class PostgreSQLAdapter:
|
|
|
205
314
|
cursor = conn.cursor()
|
|
206
315
|
|
|
207
316
|
set_clause = ", ".join([f"{k} = %s" for k in data.keys()])
|
|
208
|
-
params =
|
|
317
|
+
params: List[Any] = [self._serialize_value(v) for v in data.values()]
|
|
209
318
|
|
|
210
319
|
if isinstance(entity_id, dict):
|
|
211
|
-
where_parts = []
|
|
320
|
+
where_parts: List[str] = []
|
|
212
321
|
for k, v in entity_id.items():
|
|
213
322
|
validate_column_name(k)
|
|
214
|
-
|
|
215
|
-
|
|
323
|
+
if v is None:
|
|
324
|
+
where_parts.append(f"{k} IS NULL")
|
|
325
|
+
else:
|
|
326
|
+
where_parts.append(f"{k} = %s")
|
|
327
|
+
params.append(self._serialize_value(v))
|
|
216
328
|
where_clause = " AND ".join(where_parts)
|
|
217
329
|
else:
|
|
218
330
|
where_clause = "id = %s"
|
|
@@ -232,7 +344,7 @@ class PostgreSQLAdapter:
|
|
|
232
344
|
conn.commit()
|
|
233
345
|
|
|
234
346
|
cursor.close()
|
|
235
|
-
return result
|
|
347
|
+
return self._deserialize_row(result)
|
|
236
348
|
except Exception as e:
|
|
237
349
|
if own_conn:
|
|
238
350
|
conn.rollback()
|
|
@@ -241,6 +353,10 @@ class PostgreSQLAdapter:
|
|
|
241
353
|
if own_conn and conn:
|
|
242
354
|
self._return_connection(conn)
|
|
243
355
|
|
|
356
|
+
# ---------------------------------------------------------------------
|
|
357
|
+
# UPSERT
|
|
358
|
+
# ---------------------------------------------------------------------
|
|
359
|
+
|
|
244
360
|
@retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
|
|
245
361
|
def upsert(self, table: str, data: JsonDict, tx: Optional[Any] = None) -> JsonDict:
|
|
246
362
|
table = validate_table_name(table)
|
|
@@ -260,20 +376,37 @@ class PostgreSQLAdapter:
|
|
|
260
376
|
placeholders = ", ".join(["%s"] * len(data))
|
|
261
377
|
|
|
262
378
|
conflict_columns = ["id"] if "id" in data else list(data.keys())[:1]
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
379
|
+
update_fields = [k for k in data.keys() if k not in conflict_columns]
|
|
380
|
+
|
|
381
|
+
if update_fields:
|
|
382
|
+
update_clause = ", ".join([f"{k} = EXCLUDED.{k}" for k in update_fields])
|
|
383
|
+
on_conflict = f"DO UPDATE SET {update_clause}"
|
|
384
|
+
else:
|
|
385
|
+
on_conflict = "DO NOTHING"
|
|
266
386
|
|
|
267
387
|
query = f"""
|
|
268
|
-
INSERT INTO {table} ({columns})
|
|
388
|
+
INSERT INTO {table} ({columns})
|
|
269
389
|
VALUES ({placeholders})
|
|
270
|
-
ON CONFLICT ({', '.join(conflict_columns)})
|
|
271
|
-
|
|
390
|
+
ON CONFLICT ({', '.join(conflict_columns)})
|
|
391
|
+
{on_conflict}
|
|
272
392
|
RETURNING *
|
|
273
393
|
"""
|
|
274
394
|
|
|
275
|
-
|
|
395
|
+
values = [self._serialize_value(v) for v in data.values()]
|
|
396
|
+
cursor.execute(query, values)
|
|
397
|
+
|
|
276
398
|
result_row = cursor.fetchone()
|
|
399
|
+
if not result_row:
|
|
400
|
+
# DO NOTHING case: fetch existing row (best effort)
|
|
401
|
+
# If conflict is on id, we can read it back.
|
|
402
|
+
if "id" in conflict_columns and "id" in data:
|
|
403
|
+
cursor.execute(f"SELECT * FROM {table} WHERE id = %s", [data["id"]])
|
|
404
|
+
result_row = cursor.fetchone()
|
|
405
|
+
if not result_row:
|
|
406
|
+
raise DatabaseError("Upsert did nothing and existing row not found")
|
|
407
|
+
else:
|
|
408
|
+
raise DatabaseError("Upsert did nothing and cannot determine existing row")
|
|
409
|
+
|
|
277
410
|
columns_list = [desc[0] for desc in cursor.description]
|
|
278
411
|
result = dict(zip(columns_list, result_row))
|
|
279
412
|
|
|
@@ -281,7 +414,7 @@ class PostgreSQLAdapter:
|
|
|
281
414
|
conn.commit()
|
|
282
415
|
|
|
283
416
|
cursor.close()
|
|
284
|
-
return result
|
|
417
|
+
return self._deserialize_row(result)
|
|
285
418
|
except Exception as e:
|
|
286
419
|
if own_conn:
|
|
287
420
|
conn.rollback()
|
|
@@ -290,6 +423,10 @@ class PostgreSQLAdapter:
|
|
|
290
423
|
if own_conn and conn:
|
|
291
424
|
self._return_connection(conn)
|
|
292
425
|
|
|
426
|
+
# ---------------------------------------------------------------------
|
|
427
|
+
# DELETE
|
|
428
|
+
# ---------------------------------------------------------------------
|
|
429
|
+
|
|
293
430
|
@retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
|
|
294
431
|
def delete(
|
|
295
432
|
self, table: str, entity_id: Union[Any, Lookup], tx: Optional[Any] = None
|
|
@@ -304,13 +441,16 @@ class PostgreSQLAdapter:
|
|
|
304
441
|
try:
|
|
305
442
|
cursor = conn.cursor()
|
|
306
443
|
|
|
307
|
-
params = []
|
|
444
|
+
params: List[Any] = []
|
|
308
445
|
if isinstance(entity_id, dict):
|
|
309
|
-
where_parts = []
|
|
446
|
+
where_parts: List[str] = []
|
|
310
447
|
for k, v in entity_id.items():
|
|
311
448
|
validate_column_name(k)
|
|
312
|
-
|
|
313
|
-
|
|
449
|
+
if v is None:
|
|
450
|
+
where_parts.append(f"{k} IS NULL")
|
|
451
|
+
else:
|
|
452
|
+
where_parts.append(f"{k} = %s")
|
|
453
|
+
params.append(self._serialize_value(v))
|
|
314
454
|
where_clause = " AND ".join(where_parts)
|
|
315
455
|
else:
|
|
316
456
|
where_clause = "id = %s"
|
|
@@ -330,7 +470,7 @@ class PostgreSQLAdapter:
|
|
|
330
470
|
conn.commit()
|
|
331
471
|
|
|
332
472
|
cursor.close()
|
|
333
|
-
return result
|
|
473
|
+
return self._deserialize_row(result)
|
|
334
474
|
except Exception as e:
|
|
335
475
|
if own_conn:
|
|
336
476
|
conn.rollback()
|
|
@@ -339,11 +479,17 @@ class PostgreSQLAdapter:
|
|
|
339
479
|
if own_conn and conn:
|
|
340
480
|
self._return_connection(conn)
|
|
341
481
|
|
|
482
|
+
# ---------------------------------------------------------------------
|
|
483
|
+
# LINQ QUERY
|
|
484
|
+
# ---------------------------------------------------------------------
|
|
485
|
+
|
|
342
486
|
@retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
|
|
343
487
|
def query_linq(
|
|
344
488
|
self, table: str, builder: QueryBuilder, tx: Optional[Any] = None
|
|
345
489
|
) -> Union[List[JsonDict], int]:
|
|
490
|
+
|
|
346
491
|
table = validate_table_name(table)
|
|
492
|
+
|
|
347
493
|
conn = tx
|
|
348
494
|
own_conn = False
|
|
349
495
|
if not conn:
|
|
@@ -353,59 +499,99 @@ class PostgreSQLAdapter:
|
|
|
353
499
|
try:
|
|
354
500
|
cursor = conn.cursor()
|
|
355
501
|
|
|
502
|
+
# ------------------------------------------------
|
|
503
|
+
# SELECT clause
|
|
504
|
+
# ------------------------------------------------
|
|
505
|
+
|
|
356
506
|
if builder.count_only:
|
|
357
507
|
sql = f"SELECT COUNT(*) FROM {table}"
|
|
358
|
-
|
|
359
|
-
|
|
508
|
+
|
|
509
|
+
elif builder.selected_fields:
|
|
510
|
+
for f in builder.selected_fields:
|
|
360
511
|
validate_column_name(f)
|
|
361
|
-
|
|
362
|
-
|
|
512
|
+
|
|
513
|
+
fields = ", ".join(builder.selected_fields)
|
|
514
|
+
|
|
515
|
+
if builder.distinct_flag:
|
|
363
516
|
sql = f"SELECT DISTINCT {fields} FROM {table}"
|
|
364
517
|
else:
|
|
365
518
|
sql = f"SELECT {fields} FROM {table}"
|
|
519
|
+
|
|
366
520
|
else:
|
|
367
521
|
sql = f"SELECT * FROM {table}"
|
|
368
522
|
|
|
369
|
-
params = []
|
|
523
|
+
params: List[Any] = []
|
|
370
524
|
|
|
525
|
+
# ------------------------------------------------
|
|
371
526
|
# WHERE
|
|
527
|
+
# ------------------------------------------------
|
|
528
|
+
|
|
372
529
|
where_clause, where_params = builder.to_sql_where()
|
|
530
|
+
|
|
373
531
|
if where_clause:
|
|
374
532
|
sql += f" WHERE {where_clause}"
|
|
375
533
|
params.extend(where_params)
|
|
376
534
|
|
|
535
|
+
# ------------------------------------------------
|
|
377
536
|
# GROUP BY
|
|
537
|
+
# ------------------------------------------------
|
|
538
|
+
|
|
378
539
|
if builder.group_by_fields:
|
|
540
|
+
|
|
379
541
|
for f in builder.group_by_fields:
|
|
380
542
|
validate_column_name(f)
|
|
543
|
+
|
|
381
544
|
sql += f" GROUP BY {', '.join(builder.group_by_fields)}"
|
|
382
545
|
|
|
546
|
+
# ------------------------------------------------
|
|
383
547
|
# ORDER BY
|
|
548
|
+
# ------------------------------------------------
|
|
549
|
+
|
|
384
550
|
if builder.order_by_fields:
|
|
551
|
+
|
|
385
552
|
order_parts = []
|
|
553
|
+
|
|
386
554
|
for field, desc in builder.order_by_fields:
|
|
555
|
+
|
|
387
556
|
validate_column_name(field)
|
|
557
|
+
|
|
388
558
|
direction = "DESC" if desc else "ASC"
|
|
559
|
+
|
|
389
560
|
order_parts.append(f"{field} {direction}")
|
|
561
|
+
|
|
390
562
|
sql += f" ORDER BY {', '.join(order_parts)}"
|
|
391
563
|
|
|
392
|
-
#
|
|
393
|
-
|
|
394
|
-
|
|
564
|
+
# ------------------------------------------------
|
|
565
|
+
# LIMIT
|
|
566
|
+
# ------------------------------------------------
|
|
567
|
+
|
|
568
|
+
if builder.take_count is not None:
|
|
569
|
+
sql += " LIMIT %s"
|
|
395
570
|
params.append(builder.take_count)
|
|
396
571
|
|
|
572
|
+
# ------------------------------------------------
|
|
573
|
+
# OFFSET
|
|
574
|
+
# ------------------------------------------------
|
|
575
|
+
|
|
397
576
|
if builder.skip_count:
|
|
398
|
-
sql +=
|
|
577
|
+
sql += " OFFSET %s"
|
|
399
578
|
params.append(builder.skip_count)
|
|
400
579
|
|
|
401
|
-
|
|
580
|
+
# ------------------------------------------------
|
|
581
|
+
# EXECUTE
|
|
582
|
+
# ------------------------------------------------
|
|
583
|
+
|
|
584
|
+
cursor.execute(sql, self._serialize_params(params))
|
|
402
585
|
|
|
403
586
|
if builder.count_only:
|
|
404
587
|
result = cursor.fetchone()[0]
|
|
588
|
+
|
|
405
589
|
else:
|
|
406
590
|
columns = [desc[0] for desc in cursor.description]
|
|
407
|
-
|
|
408
|
-
result =
|
|
591
|
+
|
|
592
|
+
result = [
|
|
593
|
+
self._deserialize_row(dict(zip(columns, row))) for row in cursor.fetchall()
|
|
594
|
+
]
|
|
409
595
|
|
|
410
596
|
cursor.close()
|
|
411
597
|
|
|
@@ -413,20 +599,22 @@ class PostgreSQLAdapter:
|
|
|
413
599
|
conn.commit()
|
|
414
600
|
|
|
415
601
|
return result
|
|
602
|
+
|
|
416
603
|
except Exception as e:
|
|
604
|
+
|
|
417
605
|
if own_conn:
|
|
418
606
|
conn.rollback()
|
|
607
|
+
|
|
419
608
|
raise DatabaseError(f"LINQ query failed: {str(e)}")
|
|
609
|
+
|
|
420
610
|
finally:
|
|
611
|
+
|
|
421
612
|
if own_conn and conn:
|
|
422
613
|
self._return_connection(conn)
|
|
423
614
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
self._pool.closeall()
|
|
428
|
-
except:
|
|
429
|
-
pass
|
|
615
|
+
# ---------------------------------------------------------------------
|
|
616
|
+
# EXECUTE RAW SQL
|
|
617
|
+
# ---------------------------------------------------------------------
|
|
430
618
|
|
|
431
619
|
@retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
|
|
432
620
|
def execute(
|
|
@@ -447,17 +635,17 @@ class PostgreSQLAdapter:
|
|
|
447
635
|
cursor = None
|
|
448
636
|
try:
|
|
449
637
|
cursor = conn.cursor()
|
|
450
|
-
|
|
451
638
|
self.logger.debug("Executing raw SQL: %s", sql)
|
|
452
|
-
cursor.execute(sql, params or [])
|
|
453
639
|
|
|
454
|
-
|
|
640
|
+
exec_params = self._serialize_params(params or [])
|
|
641
|
+
cursor.execute(sql, exec_params)
|
|
642
|
+
|
|
455
643
|
if fetch_one:
|
|
456
644
|
row = cursor.fetchone()
|
|
457
645
|
result = None
|
|
458
646
|
if row:
|
|
459
647
|
columns = [desc[0] for desc in cursor.description]
|
|
460
|
-
result = dict(zip(columns, row))
|
|
648
|
+
result = self._deserialize_row(dict(zip(columns, row)))
|
|
461
649
|
if own_conn:
|
|
462
650
|
conn.commit()
|
|
463
651
|
return result
|
|
@@ -465,12 +653,11 @@ class PostgreSQLAdapter:
|
|
|
465
653
|
if fetch:
|
|
466
654
|
rows = cursor.fetchall()
|
|
467
655
|
columns = [desc[0] for desc in cursor.description]
|
|
468
|
-
results = [dict(zip(columns, r)) for r in rows]
|
|
656
|
+
results = [self._deserialize_row(dict(zip(columns, r))) for r in rows]
|
|
469
657
|
if own_conn:
|
|
470
658
|
conn.commit()
|
|
471
659
|
return results
|
|
472
660
|
|
|
473
|
-
# Non-fetch execution (DDL/DML)
|
|
474
661
|
if own_conn:
|
|
475
662
|
conn.commit()
|
|
476
663
|
return None
|
|
@@ -492,42 +679,57 @@ class PostgreSQLAdapter:
|
|
|
492
679
|
if own_conn and conn:
|
|
493
680
|
self._return_connection(conn)
|
|
494
681
|
|
|
682
|
+
# ---------------------------------------------------------------------
|
|
683
|
+
# DISTRIBUTED LOCK
|
|
684
|
+
# ---------------------------------------------------------------------
|
|
685
|
+
|
|
495
686
|
@contextmanager
|
|
496
|
-
def distributed_lock(self, lock_name: str):
|
|
687
|
+
def distributed_lock(self, lock_name: str) -> Iterator[None]:
|
|
497
688
|
"""
|
|
498
|
-
PostgreSQL advisory lock.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
Safe across:
|
|
502
|
-
- Multiple pods
|
|
503
|
-
- Multiple containers
|
|
504
|
-
- Multiple instances
|
|
689
|
+
PostgreSQL advisory lock (session scoped).
|
|
690
|
+
- Always unlock before returning the pooled connection.
|
|
691
|
+
- Never wrap exceptions raised by the user block.
|
|
505
692
|
"""
|
|
506
|
-
|
|
507
693
|
conn = None
|
|
694
|
+
cursor = None
|
|
695
|
+
lock_id = int(hashlib.sha256(lock_name.encode()).hexdigest(), 16) % (2**63)
|
|
696
|
+
|
|
508
697
|
try:
|
|
509
698
|
conn = self._get_connection()
|
|
510
699
|
cursor = conn.cursor()
|
|
511
700
|
|
|
512
|
-
#
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
yield
|
|
519
|
-
|
|
520
|
-
cursor.execute("SELECT pg_advisory_unlock(%s);", (lock_id,))
|
|
521
|
-
self.logger.debug(f"Released distributed lock: {lock_name}")
|
|
522
|
-
|
|
523
|
-
cursor.close()
|
|
524
|
-
conn.commit()
|
|
701
|
+
# Acquire lock (DB op) — if this fails, it's a DatabaseError
|
|
702
|
+
try:
|
|
703
|
+
cursor.execute("SELECT pg_advisory_lock(%s);", (lock_id,))
|
|
704
|
+
self.logger.debug("Acquired distributed lock: %s", lock_name)
|
|
705
|
+
except Exception as e:
|
|
706
|
+
raise DatabaseError(f"Distributed lock acquire failed: {e}") from e
|
|
525
707
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
708
|
+
try:
|
|
709
|
+
# User code runs here. If it raises, it MUST propagate unchanged.
|
|
710
|
+
yield
|
|
711
|
+
finally:
|
|
712
|
+
# Release lock (DB op). Always attempted.
|
|
713
|
+
try:
|
|
714
|
+
cursor.execute("SELECT pg_advisory_unlock(%s);", (lock_id,))
|
|
715
|
+
self.logger.debug("Released distributed lock: %s", lock_name)
|
|
716
|
+
except Exception as e:
|
|
717
|
+
# IMPORTANT: don't mask user exceptions.
|
|
718
|
+
# If unlock fails, raise DatabaseError only if user block didn't already fail.
|
|
719
|
+
# Easiest safe behavior: just log and continue.
|
|
720
|
+
self.logger.exception(
|
|
721
|
+
"Distributed lock release failed for %s: %s", lock_name, e
|
|
722
|
+
)
|
|
530
723
|
|
|
531
724
|
finally:
|
|
725
|
+
if cursor:
|
|
726
|
+
try:
|
|
727
|
+
cursor.close()
|
|
728
|
+
except Exception:
|
|
729
|
+
pass
|
|
532
730
|
if conn:
|
|
731
|
+
try:
|
|
732
|
+
conn.commit()
|
|
733
|
+
except Exception:
|
|
734
|
+
pass
|
|
533
735
|
self._return_connection(conn)
|