altcodepro-polydb-python 2.3.16__py3-none-any.whl → 2.3.18__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: altcodepro-polydb-python
3
- Version: 2.3.16
3
+ Version: 2.3.18
4
4
  Summary: Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety
5
5
  Author: AltCodePro
6
6
  Project-URL: Homepage, https://github.com/altcodepro/polydb-python
@@ -1,12 +1,12 @@
1
- altcodepro_polydb_python-2.3.16.dist-info/licenses/LICENSE,sha256=9X8GLocsBwy-5aR5JGOt2SAMDDPs9Qv-YnqmHBHOXrw,1067
1
+ altcodepro_polydb_python-2.3.18.dist-info/licenses/LICENSE,sha256=9X8GLocsBwy-5aR5JGOt2SAMDDPs9Qv-YnqmHBHOXrw,1067
2
2
  polydb/PolyDB.py,sha256=MG7-nV59zDvrUQwXNEgV4eetHCeW9TYY9DQqtxu_r7k,23543
3
3
  polydb/__init__.py,sha256=UhUzfSvmMgKbV2tSME1ooIyfshIBi7_WyU4xl1tWWiA,1454
4
4
  polydb/advanced_query.py,sha256=cxMB-EB-qT3bWXJlhmjnMCUtrzogORWyoEfS50Dy7go,4280
5
5
  polydb/batch.py,sha256=_DjWZa1ZXYSk6MLKqFe0eT7SYVRZtYNqZb9bI8Y2sao,4566
6
6
  polydb/cache.py,sha256=JBXF1XEK-fY80ar8SDE893Z1Z116YtXAEG0PaJ0Nkcw,7658
7
7
  polydb/cloudDatabaseFactory.py,sha256=Gp6L__YtgrkGahD8B7ItzXMHCoj2ZUGDjXLS9w0TujY,17780
8
- polydb/databaseFactory.py,sha256=xbcQTJ4kSsayV7rmWfQ3akvbvbCzOnpxjO-KNXykNeE,40862
9
- polydb/decorators.py,sha256=Rzk8Bj8wHi8YFtc06HEYT5r_Vqqn7TGaCtR5qvHdY-E,420
8
+ polydb/databaseFactory.py,sha256=u20sy8bA09A0jTO2vLxGznssdcaxChQf21FBpooL2oA,40265
9
+ polydb/decorators.py,sha256=L_WP2uXP_k8Ac49SUm-mthbM4jWI-XYfHXEyKzumOww,43062
10
10
  polydb/errors.py,sha256=rcFeBH0cenjJ86v0cmDc2Yjj4R019pLCBcTeSC4qps4,1428
11
11
  polydb/json_safe.py,sha256=R5PrqAGirqjYKPyy-8KH-lSXjLH0FPr2TSGozy4eheU,149
12
12
  polydb/models.py,sha256=9uu_BaJ95194n-vnd0Rx9KLc6aPS-mxn10P4W5grUcI,8155
@@ -15,7 +15,7 @@ polydb/multitenancy.py,sha256=9kyY98RpKg8xDy9ejB_MyV_YzF7eZd4uxashw5S8vlg,6408
15
15
  polydb/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  polydb/query.py,sha256=3oWgRXIYLItmd_R-7u78hCdUgUaoiFmjVg38vi2-rVA,6600
17
17
  polydb/registry.py,sha256=RD_elvFXcmhTdCyZDm2f3ej0elxqhArnSJ2aO9k5VCU,2352
18
- polydb/retry.py,sha256=hEyL3s-frHI0ceS9dqqjh6yGOyww6tyhe1Bw1Ht7LFs,2681
18
+ polydb/retry.py,sha256=AWq-Ia5aLL2MnOmMB0dfNT5bSJLQySLveLfwZUTMQfs,3403
19
19
  polydb/schema.py,sha256=VrOayX6V6AD2Qh3-lm4ZVPTpI24e4V52IYheZf2rNQ4,5812
20
20
  polydb/security.py,sha256=9ju-hc6Y1sxobCoV_mZ3ZWroUD73LodyTLVMhY_HeKU,16360
21
21
  polydb/types.py,sha256=XB_85Un8_aWt4dSfpjIGotHbK3KBY2WurQGXr9EOxWY,2992
@@ -55,7 +55,7 @@ polydb/base/ObjectStorageAdapter.py,sha256=VeJ3qXET6H0xd3lJpE8-WSsKs8EyK9S0-9VNR
55
55
  polydb/base/QueueAdapter.py,sha256=jFgyG-SUK4nhRNxm2NbzUbwnA9b_5iAC-ikLSUpXRwk,799
56
56
  polydb/base/SharedFilesAdapter.py,sha256=kXbJmtn_cwEyAZ-1AvFrmesCLSwu43ycTV3S4BmwrO4,853
57
57
  polydb/base/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
58
- altcodepro_polydb_python-2.3.16.dist-info/METADATA,sha256=qquhQbgVZ5kVitqWY7ULMcvCfWi_EMoAg1uHKBVDtfA,12359
59
- altcodepro_polydb_python-2.3.16.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
60
- altcodepro_polydb_python-2.3.16.dist-info/top_level.txt,sha256=WgLFWJoYjUhwvyPxJFl6jYLrVFuBJDX3OABf4ocwk_E,7
61
- altcodepro_polydb_python-2.3.16.dist-info/RECORD,,
58
+ altcodepro_polydb_python-2.3.18.dist-info/METADATA,sha256=Jc_9hMUK2yeGjUclvNlQ5cj-8dADdz1P6tDFvl6spz8,12359
59
+ altcodepro_polydb_python-2.3.18.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
60
+ altcodepro_polydb_python-2.3.18.dist-info/top_level.txt,sha256=WgLFWJoYjUhwvyPxJFl6jYLrVFuBJDX3OABf4ocwk_E,7
61
+ altcodepro_polydb_python-2.3.18.dist-info/RECORD,,
polydb/databaseFactory.py CHANGED
@@ -34,6 +34,7 @@ from .audit.manager import AuditManager
34
34
  from .audit.context import AuditContext
35
35
  from .query import Operator, QueryBuilder
36
36
  from .cloudDatabaseFactory import CloudDatabaseFactory
37
+ import re as _re
37
38
 
38
39
  logger = logging.getLogger(__name__)
39
40
 
@@ -43,6 +44,33 @@ _DEFAULT_RETRY = retry(
43
44
  reraise=True,
44
45
  )
45
46
 
47
+ _UNIQUE_VIOLATION_MARKERS = (
48
+ "23505", # Postgres SQLSTATE
49
+ "duplicate key value violates", # Postgres message
50
+ "unique constraint", # Postgres, generic
51
+ "UniqueViolation", # psycopg / SQLAlchemy class name
52
+ "Duplicate entry", # MySQL
53
+ "UNIQUE constraint failed", # SQLite
54
+ )
55
+ _UNIQUE_KEY_RE = _re.compile(r"Key \(([^)]+)\)=")
56
+
57
+
58
+ def _is_unique_violation(exc: BaseException) -> bool:
59
+ s = str(exc)
60
+ return any(m in s for m in _UNIQUE_VIOLATION_MARKERS)
61
+
62
+
63
+ def _parse_unique_violation_columns(exc: BaseException) -> list[str]:
64
+ """
65
+ Pull the conflicting column names out of a Postgres unique-violation error.
66
+ Postgres formats them as: Key (col1, col2)=(val1, val2) already exists.
67
+ Returns [] if the message doesn't carry that detail.
68
+ """
69
+ m = _UNIQUE_KEY_RE.search(str(exc))
70
+ if not m:
71
+ return []
72
+ return [c.strip() for c in m.group(1).split(",") if c.strip()]
73
+
46
74
 
47
75
  # ═══════════════════════════════════════════════════════════════════════════════
48
76
  # ENGINE CONFIG
@@ -322,46 +350,8 @@ class DatabaseFactory:
322
350
  result.setdefault("deleted_at", None)
323
351
  return result
324
352
 
325
- def _audit_safe(
326
- self,
327
- *,
328
- action: str,
329
- model: Union[type, str],
330
- entity_id: Optional[Any],
331
- meta: ModelMeta,
332
- success: bool,
333
- before: Optional[JsonDict],
334
- after: Optional[JsonDict],
335
- error: Optional[str],
336
- engine_name: Optional[str] = None,
337
- ) -> None:
338
- if not self._audit:
339
- return
340
- try:
341
- changed = None
342
- if before and after:
343
- changed = [
344
- k for k in set(before) | set(after) if before.get(k) != after.get(k)
345
- ] or None
346
- self._audit.record(
347
- action=action,
348
- model=_model_name(model),
349
- entity_id=str(entity_id) if entity_id else None,
350
- storage_type=meta.storage,
351
- provider=engine_name or self._provider_name,
352
- success=success,
353
- before=before,
354
- after=after,
355
- changed_fields=changed,
356
- error=error,
357
- )
358
- except Exception as exc:
359
- logger.error("Audit recording failed: %s", exc)
360
-
361
353
  def _run(self, fn: Callable[[], Any]) -> Any:
362
- if not self._enable_retries:
363
- return fn()
364
- return _DEFAULT_RETRY(fn)()
354
+ return fn()
365
355
 
366
356
  def _is_sql(self, meta: ModelMeta, override: Optional[EngineOverride] = None) -> bool:
367
357
  if override and override.force_sql:
@@ -398,7 +388,29 @@ class DatabaseFactory:
398
388
  def _op() -> JsonDict:
399
389
  nonlocal after_plain, success, entity_id
400
390
  if self._is_sql(meta, engine_override):
401
- result = adapters.sql.insert(meta.table, data)
391
+ try:
392
+ result = adapters.sql.insert(meta.table, data)
393
+ except Exception as exc:
394
+ if not _is_unique_violation(exc):
395
+ raise
396
+ # Half-ran scenario / re-activation / replay. The record already
397
+ # exists with these unique-key columns. Preserve idempotent
398
+ # "create or update" semantics by routing to UPDATE keyed on the
399
+ # exact columns that conflicted (parsed from the Postgres error).
400
+ conflict_cols = _parse_unique_violation_columns(exc)
401
+ if not conflict_cols or not all(c in data for c in conflict_cols):
402
+ # Can't determine the conflict — re-raise so the caller sees it.
403
+ raise
404
+ where = {c: data[c] for c in conflict_cols}
405
+ logger.warning(
406
+ "insert %s hit unique violation on %s — falling through to update",
407
+ meta.table,
408
+ conflict_cols,
409
+ )
410
+ # Drop the conflict columns from the UPDATE SET clause — they're
411
+ # already the matching key.
412
+ update_data = {k: v for k, v in data.items() if k not in conflict_cols}
413
+ result = adapters.sql.update(meta.table, where, update_data)
402
414
  else:
403
415
  result = adapters.nosql.put(
404
416
  (
@@ -427,21 +439,8 @@ class DatabaseFactory:
427
439
  m.rows_affected = 1
428
440
  return result
429
441
  return self._run(_op)
430
- except Exception as exc:
431
- error = str(exc)
442
+ except Exception:
432
443
  raise
433
- finally:
434
- self._audit_safe(
435
- action="create",
436
- model=model,
437
- entity_id=entity_id,
438
- meta=meta,
439
- success=success,
440
- before=None,
441
- after=after_plain,
442
- error=error,
443
- engine_name=adapters.engine_name,
444
- )
445
444
 
446
445
  # ──────────────────────────────────────────────────────────────────────
447
446
  # READ
@@ -605,21 +604,8 @@ class DatabaseFactory:
605
604
  m.rows_affected = 1
606
605
  return result
607
606
  return self._run(_op)
608
- except Exception as exc:
609
- error = str(exc)
607
+ except Exception:
610
608
  raise
611
- finally:
612
- self._audit_safe(
613
- action="update",
614
- model=model,
615
- entity_id=entity_id if not isinstance(entity_id, dict) else None,
616
- meta=meta,
617
- success=success,
618
- before=before,
619
- after=after_plain,
620
- error=error,
621
- engine_name=adapters.engine_name,
622
- )
623
609
 
624
610
  # ──────────────────────────────────────────────────────────────────────
625
611
  # UPSERT
@@ -675,21 +661,8 @@ class DatabaseFactory:
675
661
  m.rows_affected = 1
676
662
  return result
677
663
  return self._run(_op)
678
- except Exception as exc:
679
- error = str(exc)
664
+ except Exception:
680
665
  raise
681
- finally:
682
- self._audit_safe(
683
- action="upsert",
684
- model=model,
685
- entity_id=None,
686
- meta=meta,
687
- success=success,
688
- before=None,
689
- after=after_plain,
690
- error=error,
691
- engine_name=adapters.engine_name,
692
- )
693
666
 
694
667
  # ──────────────────────────────────────────────────────────────────────
695
668
  # DELETE
@@ -755,21 +728,8 @@ class DatabaseFactory:
755
728
  m.rows_affected = 1
756
729
  return result
757
730
  return self._run(_op)
758
- except Exception as exc:
759
- error = str(exc)
731
+ except Exception:
760
732
  raise
761
- finally:
762
- self._audit_safe(
763
- action="delete",
764
- model=model,
765
- entity_id=entity_id if not isinstance(entity_id, dict) else None,
766
- meta=meta,
767
- success=success,
768
- before=before,
769
- after=None,
770
- error=error,
771
- engine_name=adapters.engine_name,
772
- )
773
733
 
774
734
  # ──────────────────────────────────────────────────────────────────────
775
735
  # QUERY (LINQ-style)
polydb/decorators.py CHANGED
@@ -1,21 +1,1040 @@
1
- # src/polydb/decorators.py
1
+ """
2
+ DatabaseFactory — Pure Storage Layer
3
+ =====================================
4
+
5
+ Multi-engine CRUD, blob, queue, cache, file operations.
6
+
7
+ NO business logic. NO tenant enforcement. NO model registry validation.
8
+ NO RLS. Those belong in UDL.
9
+
10
+ PolyDB is the dumb storage layer. UDL is the smart layer.
11
+ """
12
+
2
13
  from __future__ import annotations
3
14
 
4
- from typing import Type, TypeVar
15
+ import logging
16
+ import os
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime
19
+ from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
20
+
21
+ from tenacity import retry, stop_after_attempt, wait_exponential
22
+
23
+ from .adapters.PostgreSQLAdapter import PostgreSQLAdapter
24
+
25
+ from .base.NoSQLKVAdapter import NoSQLKVAdapter
26
+
27
+ from .batch import BatchOperations
28
+ from .cache import CacheWarmer, RedisCacheEngine
29
+ from .monitoring import HealthCheck, MetricsCollector, PerformanceMonitor
30
+ from .security import DataMasking, FieldEncryption
31
+ from .errors import AdapterConfigurationError
32
+ from .types import JsonDict, Lookup, ModelMeta
33
+ from .audit.manager import AuditManager
34
+ from .audit.context import AuditContext
35
+ from .query import Operator, QueryBuilder
36
+ from .cloudDatabaseFactory import CloudDatabaseFactory
37
+ import re as _re
38
+ logger = logging.getLogger(__name__)
39
+
40
+ _DEFAULT_RETRY = retry(
41
+ wait=wait_exponential(multiplier=0.5, min=0.5, max=6),
42
+ stop=stop_after_attempt(3),
43
+ reraise=True,
44
+ )
45
+
46
+ _UNIQUE_VIOLATION_MARKERS = (
47
+ "23505", # Postgres SQLSTATE
48
+ "duplicate key value violates", # Postgres message
49
+ "unique constraint", # Postgres, generic
50
+ "UniqueViolation", # psycopg / SQLAlchemy class name
51
+ "Duplicate entry", # MySQL
52
+ "UNIQUE constraint failed", # SQLite
53
+ )
54
+ _UNIQUE_KEY_RE = _re.compile(r"Key \(([^)]+)\)=")
55
+
56
+
57
+ def _is_unique_violation(exc: BaseException) -> bool:
58
+ s = str(exc)
59
+ return any(m in s for m in _UNIQUE_VIOLATION_MARKERS)
60
+
61
+
62
+ def _parse_unique_violation_columns(exc: BaseException) -> list[str]:
63
+ """
64
+ Pull the conflicting column names out of a Postgres unique-violation error.
65
+ Postgres formats them as: Key (col1, col2)=(val1, val2) already exists.
66
+ Returns [] if the message doesn't carry that detail.
67
+ """
68
+ m = _UNIQUE_KEY_RE.search(str(exc))
69
+ if not m:
70
+ return []
71
+ return [c.strip() for c in m.group(1).split(",") if c.strip()]
72
+ # ═══════════════════════════════════════════════════════════════════════════════
73
+ # ENGINE CONFIG
74
+ # ═══════════════════════════════════════════════════════════════════════════════
75
+
76
+
77
+ @dataclass
78
+ class EngineConfig:
79
+ """Single SQL or NoSQL engine that DatabaseFactory can route to."""
80
+
81
+ name: str
82
+ cloud_factory: CloudDatabaseFactory
83
+ sql_models: Optional[Set[str]] = None
84
+ nosql_models: Optional[Set[str]] = None
85
+ is_default_sql: bool = False
86
+ is_default_nosql: bool = False
87
+
88
+ _sql: Any = field(default=None, init=False, repr=False)
89
+ _nosql: Any = field(default=None, init=False, repr=False)
90
+
91
+ def sql(self) -> Any:
92
+ if self._sql is None:
93
+ self._sql = self.cloud_factory.get_sql()
94
+ return self._sql
95
+
96
+ def nosql(self) -> Any:
97
+ if self._nosql is None:
98
+ self._nosql = self.cloud_factory.get_nosql_kv()
99
+ return self._nosql
100
+
101
+
102
+ @dataclass
103
+ class EngineOverride:
104
+ """Per-call override to bypass routing and target a specific engine."""
105
+
106
+ engine_name: str
107
+ force_sql: bool = False
108
+ force_nosql: bool = False
109
+
110
+
111
+ @dataclass
112
+ class _ResolvedAdapters:
113
+ sql: PostgreSQLAdapter
114
+ nosql: NoSQLKVAdapter
115
+ engine_name: str
116
+
117
+
118
+ # ═══════════════════════════════════════════════════════════════════════════════
119
+ # MODEL META RESOLUTION (lightweight — no registry enforcement)
120
+ # ═══════════════════════════════════════════════════════════════════════════════
121
+
122
+
123
+ def _extract_meta(model: Union[type, str]) -> ModelMeta:
124
+ """
125
+ Extract storage metadata from model class.
126
+
127
+ If model is a string, return a default NoSQL meta (UDL resolves the
128
+ class before calling PolyDB, so string fallback is safe).
129
+ """
130
+ if isinstance(model, type):
131
+ raw = getattr(model, "__polydb__", None)
132
+ if raw:
133
+ return ModelMeta(
134
+ storage=raw.get("storage", "nosql"),
135
+ table=raw.get("table"),
136
+ collection=raw.get("collection"),
137
+ pk_field=raw.get("pk_field", raw.get("partition_key")),
138
+ rk_field=raw.get("rk_field", raw.get("sort_key")),
139
+ provider=raw.get("provider"),
140
+ cache=raw.get("cache", False),
141
+ cache_ttl=raw.get("cache_ttl"),
142
+ )
143
+ # Default for dynamic/string models
144
+ return ModelMeta(storage="nosql", table=None, collection=None)
145
+
5
146
 
6
- from .registry import ModelRegistry
147
+ def _model_name(model: Union[type, str]) -> str:
148
+ return model.__name__ if isinstance(model, type) else str(model)
7
149
 
8
- T = TypeVar("T", bound=type)
9
150
 
151
+ # ═══════════════════════════════════════════════════════════════════════════════
152
+ # DATABASE FACTORY
153
+ # ═══════════════════════════════════════════════════════════════════════════════
10
154
 
11
- def polydb_model(cls: T) -> T:
155
+
156
+ class DatabaseFactory:
12
157
  """
13
- Decorator to auto-register a model at import time.
158
+ Pure storage layer. Multi-engine CRUD with:
159
+ - Multi-engine routing (sql_models / nosql_models per engine)
160
+ - Per-call engine override
161
+ - Cache, audit, encryption, monitoring
162
+ - Blob, queue, file, cache storage
14
163
 
15
- Usage:
16
- @polydb_model
17
- class UserEntity:
18
- __polydb__ = {"storage": "nosql"}
164
+ NO tenant enforcement. NO model registry validation. NO RLS.
165
+ UDL handles all of that.
19
166
  """
20
- ModelRegistry.register(cls)
21
- return cls
167
+
168
+ def __init__(
169
+ self,
170
+ *,
171
+ # Single-engine (backwards-compatible)
172
+ provider: Optional[Any] = None,
173
+ cloud_factory: Optional[CloudDatabaseFactory] = None,
174
+ # Multi-engine
175
+ engines: Optional[List[EngineConfig]] = None,
176
+ # Feature flags
177
+ redis_cache_url: Optional[str] = None,
178
+ enable_retries: bool = True,
179
+ enable_audit: bool = True,
180
+ enable_audit_reads: bool = False,
181
+ enable_cache: bool = True,
182
+ soft_delete: bool = False,
183
+ use_redis_cache: bool = False,
184
+ enable_monitoring: bool = False,
185
+ enable_encryption: bool = False,
186
+ ) -> None:
187
+ self._enable_retries = enable_retries
188
+ self._enable_audit = enable_audit
189
+ self._enable_audit_reads = enable_audit_reads
190
+ self._enable_cache = enable_cache
191
+ self._soft_delete = soft_delete
192
+
193
+ # Monitoring
194
+ self.metrics = MetricsCollector() if enable_monitoring else None
195
+ self.health = HealthCheck(self) if enable_monitoring else None
196
+
197
+ # Redis cache
198
+ self._cache: Optional[RedisCacheEngine] = None
199
+ self.cache_warmer: Optional[CacheWarmer] = None
200
+ if enable_cache and use_redis_cache:
201
+ redis_url = redis_cache_url or os.getenv("REDIS_CACHE_URL") or os.getenv("REDIS_URL")
202
+ if redis_url:
203
+ self._cache = RedisCacheEngine(redis_url=redis_url)
204
+ self.cache_warmer = CacheWarmer(self, self._cache)
205
+ else:
206
+ logger.warning("use_redis_cache=True but REDIS_CACHE_URL not set")
207
+
208
+ # Encryption
209
+ self.encryption = FieldEncryption() if enable_encryption else None
210
+ self.masking = DataMasking()
211
+
212
+ self.batch = BatchOperations(self)
213
+ self._audit = AuditManager() if enable_audit else None
214
+
215
+ # Engine registry
216
+ self._engines: List[EngineConfig] = []
217
+
218
+ if engines:
219
+ self._engines = engines
220
+ default_sql = [e for e in engines if e.is_default_sql]
221
+ default_nosql = [e for e in engines if e.is_default_nosql]
222
+ if len(default_sql) > 1:
223
+ raise AdapterConfigurationError("More than one engine marked is_default_sql=True")
224
+ if len(default_nosql) > 1:
225
+ raise AdapterConfigurationError("More than one engine marked is_default_nosql=True")
226
+ else:
227
+ _cf = cloud_factory or CloudDatabaseFactory(provider=provider)
228
+ self._engines = [
229
+ EngineConfig(
230
+ name="primary", cloud_factory=_cf, is_default_sql=True, is_default_nosql=True
231
+ )
232
+ ]
233
+
234
+ self._engine_by_name: Dict[str, EngineConfig] = {e.name: e for e in self._engines}
235
+ self._provider_name = self._engines[0].cloud_factory.provider.value
236
+
237
+ # ──────────────────────────────────────────────────────────────────────
238
+ # ENGINE ROUTING
239
+ # ──────────────────────────────────────────────────────────────────────
240
+
241
+ def _resolve_adapters(
242
+ self, model_name: str, storage: str, override: Optional[EngineOverride] = None
243
+ ) -> _ResolvedAdapters:
244
+ # 1. Per-call override
245
+ if override:
246
+ engine = self._engine_by_name.get(override.engine_name)
247
+ if engine is None:
248
+ raise AdapterConfigurationError(
249
+ f"Unknown engine '{override.engine_name}'. Available: {list(self._engine_by_name)}"
250
+ )
251
+ return _ResolvedAdapters(
252
+ sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
253
+ )
254
+
255
+ # 2. Explicit allow-list
256
+ for engine in self._engines:
257
+ if storage == "sql" and engine.sql_models and model_name in engine.sql_models:
258
+ return _ResolvedAdapters(
259
+ sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
260
+ )
261
+ if storage == "nosql" and engine.nosql_models and model_name in engine.nosql_models:
262
+ return _ResolvedAdapters(
263
+ sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
264
+ )
265
+
266
+ # 3. Default fallback
267
+ for engine in self._engines:
268
+ if storage == "sql" and engine.is_default_sql:
269
+ return _ResolvedAdapters(
270
+ sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
271
+ )
272
+ if storage == "nosql" and engine.is_default_nosql:
273
+ return _ResolvedAdapters(
274
+ sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
275
+ )
276
+
277
+ raise AdapterConfigurationError(f"No engine for model='{model_name}' storage='{storage}'")
278
+
279
+ def _adapters_for(
280
+ self, model: Union[type, str], meta: ModelMeta, override: Optional[EngineOverride] = None
281
+ ) -> _ResolvedAdapters:
282
+ name = _model_name(model)
283
+ if override and override.force_sql:
284
+ storage = "sql"
285
+ elif override and override.force_nosql:
286
+ storage = "nosql"
287
+ else:
288
+ storage = "sql" if (meta.storage == "sql" and meta.table) else "nosql"
289
+ return self._resolve_adapters(name, storage, override)
290
+
291
+ # ──────────────────────────────────────────────────────────────────────
292
+ # ENGINE MANAGEMENT
293
+ # ──────────────────────────────────────────────────────────────────────
294
+
295
+ def register_engine(self, engine: EngineConfig) -> None:
296
+ if engine.name in self._engine_by_name:
297
+ self._engines = [e for e in self._engines if e.name != engine.name]
298
+ self._engines.append(engine)
299
+ self._engine_by_name[engine.name] = engine
300
+
301
+ def unregister_engine(self, name: str) -> None:
302
+ if name not in self._engine_by_name:
303
+ raise AdapterConfigurationError(f"Engine '{name}' not registered.")
304
+ self._engines = [e for e in self._engines if e.name != name]
305
+ del self._engine_by_name[name]
306
+
307
+ def get_engine(self, name: str) -> EngineConfig:
308
+ if name not in self._engine_by_name:
309
+ raise AdapterConfigurationError(f"Engine '{name}' not found.")
310
+ return self._engine_by_name[name]
311
+
312
+ @property
313
+ def _sql(self) -> PostgreSQLAdapter:
314
+ for e in self._engines:
315
+ if e.is_default_sql:
316
+ return e.sql()
317
+ return self._engines[0].sql()
318
+
319
+ @property
320
+ def _nosql(self) -> NoSQLKVAdapter:
321
+ for e in self._engines:
322
+ if e.is_default_nosql:
323
+ return e.nosql()
324
+ return self._engines[0].nosql()
325
+
326
+ # ──────────────────────────────────────────────────────────────────────
327
+ # HELPERS
328
+ # ──────────────────────────────────────────────────────────────────────
329
+
330
+ def _inject_audit_fields(self, data: JsonDict, is_create: bool = False) -> JsonDict:
331
+ data = dict(data)
332
+ actor_id = AuditContext.actor_id.get()
333
+ now = datetime.utcnow().isoformat()
334
+ if is_create:
335
+ data.setdefault("created_at", now)
336
+ if actor_id:
337
+ data.setdefault("created_by", actor_id)
338
+ data.setdefault("updated_at", now)
339
+ if actor_id:
340
+ data.setdefault("updated_by", actor_id)
341
+ return data
342
+
343
+ def _apply_soft_delete_filter(self, query: Optional[Lookup]) -> Lookup:
344
+ if not self._soft_delete:
345
+ return query or {}
346
+ result = dict(query or {})
347
+ result.setdefault("deleted_at", None)
348
+ return result
349
+
350
+ def _audit_safe(
351
+ self,
352
+ *,
353
+ action: str,
354
+ model: Union[type, str],
355
+ entity_id: Optional[Any],
356
+ meta: ModelMeta,
357
+ success: bool,
358
+ before: Optional[JsonDict],
359
+ after: Optional[JsonDict],
360
+ error: Optional[str],
361
+ engine_name: Optional[str] = None,
362
+ ) -> None:
363
+ if not self._audit:
364
+ return
365
+ try:
366
+ changed = None
367
+ if before and after:
368
+ changed = [
369
+ k for k in set(before) | set(after) if before.get(k) != after.get(k)
370
+ ] or None
371
+ self._audit.record(
372
+ action=action,
373
+ model=_model_name(model),
374
+ entity_id=str(entity_id) if entity_id else None,
375
+ storage_type=meta.storage,
376
+ provider=engine_name or self._provider_name,
377
+ success=success,
378
+ before=before,
379
+ after=after,
380
+ changed_fields=changed,
381
+ error=error,
382
+ )
383
+ except Exception as exc:
384
+ logger.error("Audit recording failed: %s", exc)
385
+
386
+ def _run(self, fn: Callable[[], Any]) -> Any:
387
+ return fn()
388
+
389
+ def _is_sql(self, meta: ModelMeta, override: Optional[EngineOverride] = None) -> bool:
390
+ if override and override.force_sql:
391
+ return True
392
+ if override and override.force_nosql:
393
+ return False
394
+ return meta.storage == "sql" and bool(meta.table)
395
+
396
+ # ──────────────────────────────────────────────────────────────────────
397
+ # CREATE
398
+ # ──────────────────────────────────────────────────────────────────────
399
+
400
+ def create(
401
+ self,
402
+ model: Union[type, str],
403
+ data: JsonDict,
404
+ *,
405
+ engine_override: Optional[EngineOverride] = None,
406
+ ) -> JsonDict:
407
+ meta = _extract_meta(model)
408
+ name = _model_name(model)
409
+ data = self._inject_audit_fields(data, is_create=True)
410
+
411
+ encrypted_fields = getattr(meta, "encrypted_fields", [])
412
+ if self.encryption and encrypted_fields:
413
+ data = self.encryption.encrypt_fields(data, encrypted_fields)
414
+
415
+ adapters = self._adapters_for(model, meta, engine_override)
416
+ after_plain = None
417
+ success = False
418
+ error: Optional[str] = None
419
+ entity_id: Optional[Any] = None
420
+
421
+ def _op() -> JsonDict:
422
+ nonlocal after_plain, success, entity_id
423
+ if self._is_sql(meta, engine_override):
424
+ try:
425
+ result = adapters.sql.insert(meta.table, data)
426
+ except Exception as exc:
427
+ if not _is_unique_violation(exc):
428
+ raise
429
+ conflict_cols = _parse_unique_violation_columns(exc)
430
+ logger.warning(
431
+ "insert %s hit unique violation on %s — record already exists, skipping",
432
+ meta.table, conflict_cols,
433
+ )
434
+ # Record already exists — fetch and return it so the caller gets a valid result
435
+ try:
436
+ if conflict_cols and all(c in data for c in conflict_cols):
437
+ where = {c: data[c] for c in conflict_cols}
438
+ existing = adapters.sql.select(meta.table, where, limit=1)
439
+ result = existing[0] if existing else data
440
+ else:
441
+ result = data
442
+ except Exception:
443
+ result = data
444
+ entity_id = result.get("id")
445
+ after_plain = result
446
+ success = True
447
+ if self._enable_cache and self._cache:
448
+ self._cache.invalidate(name)
449
+ return after_plain
450
+ else:
451
+ result = adapters.nosql.put(
452
+ model if isinstance(model, type)
453
+ else type(name, (), {"__polydb__": meta.__dict__}),
454
+ data,
455
+ )
456
+ entity_id = result.get("id")
457
+ after_plain = result
458
+ if self.encryption and encrypted_fields:
459
+ after_plain = self.encryption.decrypt_fields(result, encrypted_fields)
460
+ success = True
461
+ if self._enable_cache and self._cache:
462
+ self._cache.invalidate(name)
463
+ return after_plain
464
+ try:
465
+ monitor = (
466
+ PerformanceMonitor(self.metrics, "create", name, None) if self.metrics else None
467
+ )
468
+ if monitor:
469
+ with monitor as m:
470
+ result = self._run(_op)
471
+ m.rows_affected = 1
472
+ return result
473
+ return self._run(_op)
474
+ except Exception as exc:
475
+ error = str(exc)
476
+ raise
477
+ finally:
478
+ self._audit_safe(
479
+ action="create",
480
+ model=model,
481
+ entity_id=entity_id,
482
+ meta=meta,
483
+ success=success,
484
+ before=None,
485
+ after=after_plain,
486
+ error=error,
487
+ engine_name=adapters.engine_name,
488
+ )
489
+
490
+ # ──────────────────────────────────────────────────────────────────────
491
+ # READ
492
+ # ──────────────────────────────────────────────────────────────────────
493
+
494
+ def read(
495
+ self,
496
+ model: Union[type, str],
497
+ query: Optional[Lookup] = None,
498
+ *,
499
+ limit: Optional[int] = None,
500
+ offset: Optional[int] = None,
501
+ no_cache: bool = False,
502
+ cache_ttl: Optional[int] = None,
503
+ include_deleted: bool = False,
504
+ engine_override: Optional[EngineOverride] = None,
505
+ ) -> List[JsonDict]:
506
+ name = _model_name(model)
507
+ meta = _extract_meta(model)
508
+
509
+ if self._soft_delete and not include_deleted:
510
+ query = self._apply_soft_delete_filter(query)
511
+
512
+ adapters = self._adapters_for(model, meta, engine_override)
513
+ use_external_cache = self._enable_cache and self._cache and getattr(meta, "cache", False)
514
+ encrypted_fields = getattr(meta, "encrypted_fields", [])
515
+
516
+ def _op() -> List[JsonDict]:
517
+ if self._is_sql(meta, engine_override):
518
+ raw = adapters.sql.select(meta.table, query, limit=limit, offset=offset)
519
+ else:
520
+ cls = (
521
+ model
522
+ if isinstance(model, type)
523
+ else type(name, (), {"__polydb__": meta.__dict__})
524
+ )
525
+ raw = adapters.nosql.query(
526
+ cls, query=query, limit=limit, no_cache=no_cache or bool(use_external_cache)
527
+ )
528
+ if self.encryption and encrypted_fields:
529
+ raw = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw]
530
+ if self._cache and use_external_cache and not no_cache:
531
+ ttl = cache_ttl or getattr(meta, "cache_ttl", 300)
532
+ self._cache.set(name, query or {}, raw, ttl)
533
+ return raw
534
+
535
+ # Check external cache first
536
+ if self._cache and use_external_cache and not no_cache:
537
+ cached = self._cache.get(name, query or {})
538
+ if cached is not None:
539
+ return cached
540
+
541
+ monitor = PerformanceMonitor(self.metrics, "read", name, None) if self.metrics else None
542
+ if monitor:
543
+ with monitor as m:
544
+ rows = self._run(_op)
545
+ m.rows_returned = len(rows)
546
+ return rows
547
+ return self._run(_op)
548
+
549
+ def read_one(
550
+ self,
551
+ model: Union[type, str],
552
+ query: Lookup,
553
+ *,
554
+ no_cache: bool = False,
555
+ include_deleted: bool = False,
556
+ engine_override: Optional[EngineOverride] = None,
557
+ ) -> Optional[JsonDict]:
558
+ rows = self.read(
559
+ model,
560
+ query=query,
561
+ limit=1,
562
+ no_cache=no_cache,
563
+ include_deleted=include_deleted,
564
+ engine_override=engine_override,
565
+ )
566
+ return rows[0] if rows else None
567
+
568
+ # ──────────────────────────────────────────────────────────────────────
569
+ # UPDATE
570
+ # ──────────────────────────────────────────────────────────────────────
571
+
572
+ def update(
573
+ self,
574
+ model: Union[type, str],
575
+ entity_id: Union[Any, Lookup],
576
+ data: JsonDict,
577
+ *,
578
+ etag: Optional[str] = None,
579
+ replace: bool = False,
580
+ engine_override: Optional[EngineOverride] = None,
581
+ ) -> JsonDict:
582
+ name = _model_name(model)
583
+ meta = _extract_meta(model)
584
+ data = self._inject_audit_fields(data, is_create=False)
585
+
586
+ encrypted_fields = getattr(meta, "encrypted_fields", [])
587
+ if self.encryption and encrypted_fields:
588
+ data = self.encryption.encrypt_fields(data, [f for f in encrypted_fields if f in data])
589
+
590
+ adapters = self._adapters_for(model, meta, engine_override)
591
+ before = self.read_one(
592
+ model,
593
+ {"id": entity_id} if not isinstance(entity_id, dict) else entity_id,
594
+ no_cache=True,
595
+ include_deleted=True,
596
+ engine_override=engine_override,
597
+ )
598
+ after_plain = None
599
+ success = False
600
+ error: Optional[str] = None
601
+
602
+ def _op() -> JsonDict:
603
+ nonlocal after_plain, success
604
+ if self._is_sql(meta, engine_override):
605
+ result = adapters.sql.update(meta.table, entity_id, data)
606
+ else:
607
+ pkey = data.get("PartitionKey") or data.get("partition_key") or data.get("pk")
608
+ en_id = entity_id
609
+ if not pkey and before:
610
+ pkey = (
611
+ before.get("PartitionKey")
612
+ or before.get("partition_key")
613
+ or before.get("pk")
614
+ )
615
+ if pkey:
616
+ if isinstance(en_id, dict):
617
+ en_pk = (
618
+ en_id.get("PartitionKey")
619
+ or en_id.get("partition_key")
620
+ or en_id.get("pk")
621
+ )
622
+ if not en_pk:
623
+ en_id["partition_key"] = pkey
624
+ elif isinstance(en_id, str):
625
+ en_id = {"partition_key": pkey, "id": entity_id}
626
+
627
+ cls = (
628
+ model
629
+ if isinstance(model, type)
630
+ else type(name, (), {"__polydb__": meta.__dict__})
631
+ )
632
+
633
+ result = adapters.nosql.patch(cls, en_id, data, etag=etag, replace=replace)
634
+ after_plain = result
635
+ if self.encryption and encrypted_fields:
636
+ after_plain = self.encryption.decrypt_fields(result, encrypted_fields)
637
+ success = True
638
+ if self._enable_cache and self._cache:
639
+ self._cache.invalidate(name)
640
+ return after_plain
641
+
642
+ try:
643
+ monitor = (
644
+ PerformanceMonitor(self.metrics, "update", name, None) if self.metrics else None
645
+ )
646
+ if monitor:
647
+ with monitor as m:
648
+ result = self._run(_op)
649
+ m.rows_affected = 1
650
+ return result
651
+ return self._run(_op)
652
+ except Exception as exc:
653
+ error = str(exc)
654
+ raise
655
+ finally:
656
+ self._audit_safe(
657
+ action="update",
658
+ model=model,
659
+ entity_id=entity_id if not isinstance(entity_id, dict) else None,
660
+ meta=meta,
661
+ success=success,
662
+ before=before,
663
+ after=after_plain,
664
+ error=error,
665
+ engine_name=adapters.engine_name,
666
+ )
667
+
668
+ # ──────────────────────────────────────────────────────────────────────
669
+ # UPSERT
670
+ # ──────────────────────────────────────────────────────────────────────
671
+
672
+ def upsert(
673
+ self,
674
+ model: Union[type, str],
675
+ data: JsonDict,
676
+ *,
677
+ replace: bool = False,
678
+ engine_override: Optional[EngineOverride] = None,
679
+ ) -> JsonDict:
680
+ name = _model_name(model)
681
+ meta = _extract_meta(model)
682
+ data = self._inject_audit_fields(data, is_create=True)
683
+
684
+ encrypted_fields = getattr(meta, "encrypted_fields", [])
685
+ if self.encryption and encrypted_fields:
686
+ data = self.encryption.encrypt_fields(data, encrypted_fields)
687
+
688
+ adapters = self._adapters_for(model, meta, engine_override)
689
+ after_plain = None
690
+ success = False
691
+ error: Optional[str] = None
692
+
693
+ def _op() -> JsonDict:
694
+ nonlocal after_plain, success
695
+ if self._is_sql(meta, engine_override):
696
+ result = adapters.sql.upsert(meta.table, data)
697
+ else:
698
+ cls = (
699
+ model
700
+ if isinstance(model, type)
701
+ else type(name, (), {"__polydb__": meta.__dict__})
702
+ )
703
+ result = adapters.nosql.upsert(cls, data, replace=replace)
704
+ after_plain = result
705
+ if self.encryption and encrypted_fields:
706
+ after_plain = self.encryption.decrypt_fields(result, encrypted_fields)
707
+ success = True
708
+ if self._enable_cache and self._cache:
709
+ self._cache.invalidate(name)
710
+ return after_plain
711
+
712
+ try:
713
+ monitor = (
714
+ PerformanceMonitor(self.metrics, "upsert", name, None) if self.metrics else None
715
+ )
716
+ if monitor:
717
+ with monitor as m:
718
+ result = self._run(_op)
719
+ m.rows_affected = 1
720
+ return result
721
+ return self._run(_op)
722
+ except Exception as exc:
723
+ error = str(exc)
724
+ raise
725
+ finally:
726
+ self._audit_safe(
727
+ action="upsert",
728
+ model=model,
729
+ entity_id=None,
730
+ meta=meta,
731
+ success=success,
732
+ before=None,
733
+ after=after_plain,
734
+ error=error,
735
+ engine_name=adapters.engine_name,
736
+ )
737
+
738
+ # ──────────────────────────────────────────────────────────────────────
739
+ # DELETE
740
+ # ──────────────────────────────────────────────────────────────────────
741
+
742
+ def delete(
743
+ self,
744
+ model: Union[type, str],
745
+ entity_id: Union[Any, Lookup],
746
+ *,
747
+ etag: Optional[str] = None,
748
+ hard: bool = False,
749
+ engine_override: Optional[EngineOverride] = None,
750
+ ) -> JsonDict:
751
+ meta = _extract_meta(model)
752
+ name = _model_name(model)
753
+
754
+ if self._soft_delete and not hard:
755
+ return self.update(
756
+ model,
757
+ entity_id,
758
+ {
759
+ "deleted_at": datetime.utcnow().isoformat(),
760
+ "deleted_by": AuditContext.actor_id.get(),
761
+ },
762
+ engine_override=engine_override,
763
+ )
764
+
765
+ adapters = self._adapters_for(model, meta, engine_override)
766
+ before = self.read_one(
767
+ model,
768
+ {"id": entity_id} if not isinstance(entity_id, dict) else entity_id,
769
+ no_cache=True,
770
+ include_deleted=True,
771
+ engine_override=engine_override,
772
+ )
773
+ success = False
774
+ error: Optional[str] = None
775
+
776
+ def _op() -> JsonDict:
777
+ nonlocal success
778
+ if self._is_sql(meta, engine_override):
779
+ result = adapters.sql.delete(meta.table, entity_id)
780
+ else:
781
+ cls = (
782
+ model
783
+ if isinstance(model, type)
784
+ else type(name, (), {"__polydb__": meta.__dict__})
785
+ )
786
+ result = adapters.nosql.delete(cls, entity_id, etag=etag)
787
+ success = True
788
+ if self._enable_cache and self._cache:
789
+ self._cache.invalidate(name)
790
+ return result
791
+
792
+ try:
793
+ monitor = (
794
+ PerformanceMonitor(self.metrics, "delete", name, None) if self.metrics else None
795
+ )
796
+ if monitor:
797
+ with monitor as m:
798
+ result = self._run(_op)
799
+ m.rows_affected = 1
800
+ return result
801
+ return self._run(_op)
802
+ except Exception as exc:
803
+ error = str(exc)
804
+ raise
805
+ finally:
806
+ self._audit_safe(
807
+ action="delete",
808
+ model=model,
809
+ entity_id=entity_id if not isinstance(entity_id, dict) else None,
810
+ meta=meta,
811
+ success=success,
812
+ before=before,
813
+ after=None,
814
+ error=error,
815
+ engine_name=adapters.engine_name,
816
+ )
817
+
818
+ # ──────────────────────────────────────────────────────────────────────
819
+ # QUERY (LINQ-style)
820
+ # ──────────────────────────────────────────────────────────────────────
821
+
822
+ def query_linq(
823
+ self,
824
+ model: Union[type, str],
825
+ builder: QueryBuilder,
826
+ *,
827
+ engine_override: Optional[EngineOverride] = None,
828
+ ) -> Union[List[JsonDict], int]:
829
+ name = _model_name(model)
830
+ meta = _extract_meta(model)
831
+ adapters = self._adapters_for(model, meta, engine_override)
832
+
833
+ def _op():
834
+ if self._is_sql(meta, engine_override):
835
+ return adapters.sql.query_linq(meta.table, builder)
836
+ cls = (
837
+ model if isinstance(model, type) else type(name, (), {"__polydb__": meta.__dict__})
838
+ )
839
+ return adapters.nosql.query_linq(cls, builder)
840
+
841
+ monitor = (
842
+ PerformanceMonitor(self.metrics, "query_linq", name, None) if self.metrics else None
843
+ )
844
+ if monitor:
845
+ with monitor as m:
846
+ result = self._run(_op)
847
+ if isinstance(result, list):
848
+ m.rows_returned = len(result)
849
+ return result
850
+ return self._run(_op)
851
+
852
+ # ──────────────────────────────────────────────────────────────────────
853
+ # PAGINATION
854
+ # ──────────────────────────────────────────────────────────────────────
855
+
856
+ def read_page(
857
+ self,
858
+ model: Union[type, str],
859
+ query: Lookup,
860
+ *,
861
+ page_size: int = 100,
862
+ continuation_token: Optional[str] = None,
863
+ include_deleted: bool = False,
864
+ engine_override: Optional[EngineOverride] = None,
865
+ ) -> Optional[Tuple[List[JsonDict], Optional[str]]]:
866
+ name = _model_name(model)
867
+ meta = _extract_meta(model)
868
+
869
+ if self._soft_delete and not include_deleted:
870
+ query = self._apply_soft_delete_filter(query)
871
+
872
+ adapters = self._adapters_for(model, meta, engine_override)
873
+ encrypted_fields = getattr(meta, "encrypted_fields", [])
874
+
875
+ def _op() -> Tuple[List[JsonDict], Optional[str]]:
876
+ if self._is_sql(meta, engine_override):
877
+ raw, token = adapters.sql.select_page(
878
+ meta.table, query, page_size, continuation_token
879
+ )
880
+ else:
881
+ cls = (
882
+ model
883
+ if isinstance(model, type)
884
+ else type(name, (), {"__polydb__": meta.__dict__})
885
+ )
886
+ raw, token = adapters.nosql.query_page(cls, query, page_size, continuation_token)
887
+ if self.encryption and encrypted_fields:
888
+ raw = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw]
889
+ return raw, token
890
+
891
+ monitor = (
892
+ PerformanceMonitor(self.metrics, "read_page", name, None) if self.metrics else None
893
+ )
894
+ if monitor:
895
+ with monitor as m:
896
+ result = self._run(_op)
897
+ m.rows_returned = len(result[0])
898
+ return result
899
+ return self._run(_op)
900
+
901
+ # ══════════════════════════════════════════════════════════════════════
902
+ # BLOB STORAGE
903
+ # ══════════════════════════════════════════════════════════════════════
904
+
905
+ def upload_blob(
906
+ self,
907
+ key: str,
908
+ data: bytes,
909
+ *,
910
+ file_name: Optional[str] = None,
911
+ media_type: Optional[str] = None,
912
+ metadata: Optional[Dict[str, Any]] = None,
913
+ storage_name: str = "azure",
914
+ container_name: Optional[str] = None,
915
+ ) -> str:
916
+ storage = self._engines[0].cloud_factory.get_object_storage(
917
+ storage_name, container_name=container_name
918
+ )
919
+ return storage.put(
920
+ key=key,
921
+ data=data,
922
+ fileName=file_name or key,
923
+ optimize=True,
924
+ media_type=media_type,
925
+ metadata=metadata or {},
926
+ )
927
+
928
+ def download_blob(
929
+ self, key: str, *, storage_name: str = "azure", container_name: Optional[str] = None
930
+ ) -> Optional[bytes]:
931
+ storage = self._engines[0].cloud_factory.get_object_storage(
932
+ storage_name, container_name=container_name
933
+ )
934
+ return storage.get(key)
935
+
936
+ def delete_blob(
937
+ self, key: str, *, storage_name: str = "azure", container_name: Optional[str] = None
938
+ ) -> bool:
939
+ storage = self._engines[0].cloud_factory.get_object_storage(
940
+ storage_name, container_name=container_name
941
+ )
942
+ return storage.delete(key)
943
+
944
+ def list_blob(
945
+ self, prefix: str = "", *, storage_name: str = "azure", container_name: Optional[str] = None
946
+ ) -> List[str]:
947
+ storage = self._engines[0].cloud_factory.get_object_storage(
948
+ storage_name, container_name=container_name
949
+ )
950
+ return storage.list(prefix)
951
+
952
+ # ══════════════════════════════════════════════════════════════════════
953
+ # QUEUE
954
+ # ══════════════════════════════════════════════════════════════════════
955
+
956
+ def send_queue(
957
+ self,
958
+ message: Dict[str, Any],
959
+ *,
960
+ queue_name: str = "default",
961
+ adapter_name: str = "azure_queue",
962
+ ) -> str:
963
+ queue = self._engines[0].cloud_factory.get_queue(adapter_name)
964
+ return queue.send(message=message, queue_name=queue_name)
965
+
966
+ def receive_queue(
967
+ self,
968
+ *,
969
+ queue_name: str = "default",
970
+ max_messages: int = 10,
971
+ adapter_name: str = "azure_queue",
972
+ ) -> List[Dict[str, Any]]:
973
+ queue = self._engines[0].cloud_factory.get_queue(adapter_name)
974
+ return queue.receive(queue_name=queue_name, max_messages=max_messages)
975
+
976
+ def ack_queue(
977
+ self, ack_id: str, *, queue_name: str = "default", adapter_name: str = "azure_queue"
978
+ ) -> bool:
979
+ queue = self._engines[0].cloud_factory.get_queue(adapter_name)
980
+ return (
981
+ queue.ack(ack_id, queue_name)
982
+ if hasattr(queue, "ack")
983
+ else queue.delete(ack_id, queue_name)
984
+ )
985
+
986
+ def delete_queue(
987
+ self,
988
+ message_id: str,
989
+ *,
990
+ queue_name: str = "default",
991
+ pop_receipt: Optional[str] = None,
992
+ adapter_name: str = "azure_queue",
993
+ ) -> bool:
994
+ queue = self._engines[0].cloud_factory.get_queue(adapter_name)
995
+ return (
996
+ queue.delete(message_id, queue_name, pop_receipt)
997
+ if pop_receipt
998
+ else queue.delete(message_id, queue_name)
999
+ )
1000
+
1001
+ # ══════════════════════════════════════════════════════════════════════
1002
+ # FILE STORAGE
1003
+ # ══════════════════════════════════════════════════════════════════════
1004
+
1005
+ def write_file(
1006
+ self, path: str, data: Union[bytes, str], *, adapter_name: str = "files"
1007
+ ) -> bool:
1008
+ files = self._engines[0].cloud_factory.get_files(adapter_name)
1009
+ return files.write(path, data.encode() if isinstance(data, str) else data) # type: ignore
1010
+
1011
+ def read_file(self, path: str, *, adapter_name: str = "files") -> Optional[bytes]:
1012
+ files = self._engines[0].cloud_factory.get_files(adapter_name)
1013
+ return files.read(path) # type: ignore
1014
+
1015
+ def delete_file(self, path: str, *, adapter_name: str = "files") -> bool:
1016
+ files = self._engines[0].cloud_factory.get_files(adapter_name)
1017
+ return files.delete(path)
1018
+
1019
+ def list_files(self, directory: str = "", *, adapter_name: str = "files") -> List[str]:
1020
+ files = self._engines[0].cloud_factory.get_files(adapter_name)
1021
+ return files.list(directory)
1022
+
1023
+ # ══════════════════════════════════════════════════════════════════════
1024
+ # CACHE
1025
+ # ══════════════════════════════════════════════════════════════════════
1026
+
1027
+ def set_cache(self, model: str, key: Any, value: Any, ttl: int = 300) -> None:
1028
+ if self._cache:
1029
+ self._cache.set(model, key, value, ttl)
1030
+
1031
+ def get_cache(self, model: str, key: Any) -> Optional[Any]:
1032
+ return self._cache.get(model, key) if self._cache else None
1033
+
1034
+ def invalidate_cache(self, model: str, key: Optional[Any] = None) -> None:
1035
+ if not self._cache:
1036
+ return
1037
+ if key:
1038
+ self._cache.invalidate(model, key)
1039
+ else:
1040
+ self._cache.clear()
polydb/retry.py CHANGED
@@ -3,10 +3,33 @@
3
3
  Retry logic with exponential backoff and metrics hooks
4
4
  """
5
5
 
6
+ import functools
6
7
  import time
7
8
  import logging
8
9
  from functools import wraps
9
10
  from typing import Callable, Optional, Tuple, Type
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _NON_RETRYABLE_MARKERS = (
14
+ "23505", # Postgres unique_violation
15
+ "23503", # Postgres foreign_key_violation
16
+ "23502", # Postgres not_null_violation
17
+ "23514", # Postgres check_violation
18
+ "duplicate key value violates",
19
+ "unique constraint",
20
+ "UniqueViolation",
21
+ "Duplicate entry",
22
+ "UNIQUE constraint failed",
23
+ "PropertyValueTooLarge",
24
+ "ResourceNotFound",
25
+ "InvalidArgument",
26
+ "AuthenticationFailed",
27
+ )
28
+
29
+
30
+ def _is_non_retryable(exc: BaseException) -> bool:
31
+ s = str(exc)
32
+ return any(m in s for m in _NON_RETRYABLE_MARKERS)
10
33
 
11
34
 
12
35
  # Metrics hooks for enterprise monitoring