altcodepro-polydb-python 2.3.17__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.
- {altcodepro_polydb_python-2.3.17.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/METADATA +1 -1
- {altcodepro_polydb_python-2.3.17.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/RECORD +7 -7
- polydb/databaseFactory.py +21 -101
- polydb/decorators.py +1031 -12
- {altcodepro_polydb_python-2.3.17.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/WHEEL +0 -0
- {altcodepro_polydb_python-2.3.17.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/licenses/LICENSE +0 -0
- {altcodepro_polydb_python-2.3.17.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/top_level.txt +0 -0
{altcodepro_polydb_python-2.3.17.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: altcodepro-polydb-python
|
|
3
|
-
Version: 2.3.
|
|
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
|
{altcodepro_polydb_python-2.3.17.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/RECORD
RENAMED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
altcodepro_polydb_python-2.3.
|
|
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=
|
|
9
|
-
polydb/decorators.py,sha256=
|
|
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
|
|
@@ -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.
|
|
59
|
-
altcodepro_polydb_python-2.3.
|
|
60
|
-
altcodepro_polydb_python-2.3.
|
|
61
|
-
altcodepro_polydb_python-2.3.
|
|
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
|
@@ -35,6 +35,7 @@ from .audit.context import AuditContext
|
|
|
35
35
|
from .query import Operator, QueryBuilder
|
|
36
36
|
from .cloudDatabaseFactory import CloudDatabaseFactory
|
|
37
37
|
import re as _re
|
|
38
|
+
|
|
38
39
|
logger = logging.getLogger(__name__)
|
|
39
40
|
|
|
40
41
|
_DEFAULT_RETRY = retry(
|
|
@@ -44,12 +45,12 @@ _DEFAULT_RETRY = retry(
|
|
|
44
45
|
)
|
|
45
46
|
|
|
46
47
|
_UNIQUE_VIOLATION_MARKERS = (
|
|
47
|
-
"23505",
|
|
48
|
-
"duplicate key value violates",
|
|
49
|
-
"unique constraint",
|
|
50
|
-
"UniqueViolation",
|
|
51
|
-
"Duplicate entry",
|
|
52
|
-
"UNIQUE constraint failed",
|
|
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
|
|
53
54
|
)
|
|
54
55
|
_UNIQUE_KEY_RE = _re.compile(r"Key \(([^)]+)\)=")
|
|
55
56
|
|
|
@@ -69,6 +70,8 @@ def _parse_unique_violation_columns(exc: BaseException) -> list[str]:
|
|
|
69
70
|
if not m:
|
|
70
71
|
return []
|
|
71
72
|
return [c.strip() for c in m.group(1).split(",") if c.strip()]
|
|
73
|
+
|
|
74
|
+
|
|
72
75
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
73
76
|
# ENGINE CONFIG
|
|
74
77
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -347,42 +350,6 @@ class DatabaseFactory:
|
|
|
347
350
|
result.setdefault("deleted_at", None)
|
|
348
351
|
return result
|
|
349
352
|
|
|
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
353
|
def _run(self, fn: Callable[[], Any]) -> Any:
|
|
387
354
|
return fn()
|
|
388
355
|
|
|
@@ -437,7 +404,8 @@ class DatabaseFactory:
|
|
|
437
404
|
where = {c: data[c] for c in conflict_cols}
|
|
438
405
|
logger.warning(
|
|
439
406
|
"insert %s hit unique violation on %s — falling through to update",
|
|
440
|
-
meta.table,
|
|
407
|
+
meta.table,
|
|
408
|
+
conflict_cols,
|
|
441
409
|
)
|
|
442
410
|
# Drop the conflict columns from the UPDATE SET clause — they're
|
|
443
411
|
# already the matching key.
|
|
@@ -445,8 +413,11 @@ class DatabaseFactory:
|
|
|
445
413
|
result = adapters.sql.update(meta.table, where, update_data)
|
|
446
414
|
else:
|
|
447
415
|
result = adapters.nosql.put(
|
|
448
|
-
|
|
449
|
-
|
|
416
|
+
(
|
|
417
|
+
model
|
|
418
|
+
if isinstance(model, type)
|
|
419
|
+
else type(name, (), {"__polydb__": meta.__dict__})
|
|
420
|
+
),
|
|
450
421
|
data,
|
|
451
422
|
)
|
|
452
423
|
entity_id = result.get("id")
|
|
@@ -457,6 +428,7 @@ class DatabaseFactory:
|
|
|
457
428
|
if self._enable_cache and self._cache:
|
|
458
429
|
self._cache.invalidate(name)
|
|
459
430
|
return after_plain
|
|
431
|
+
|
|
460
432
|
try:
|
|
461
433
|
monitor = (
|
|
462
434
|
PerformanceMonitor(self.metrics, "create", name, None) if self.metrics else None
|
|
@@ -467,21 +439,8 @@ class DatabaseFactory:
|
|
|
467
439
|
m.rows_affected = 1
|
|
468
440
|
return result
|
|
469
441
|
return self._run(_op)
|
|
470
|
-
except Exception
|
|
471
|
-
error = str(exc)
|
|
442
|
+
except Exception:
|
|
472
443
|
raise
|
|
473
|
-
finally:
|
|
474
|
-
self._audit_safe(
|
|
475
|
-
action="create",
|
|
476
|
-
model=model,
|
|
477
|
-
entity_id=entity_id,
|
|
478
|
-
meta=meta,
|
|
479
|
-
success=success,
|
|
480
|
-
before=None,
|
|
481
|
-
after=after_plain,
|
|
482
|
-
error=error,
|
|
483
|
-
engine_name=adapters.engine_name,
|
|
484
|
-
)
|
|
485
444
|
|
|
486
445
|
# ──────────────────────────────────────────────────────────────────────
|
|
487
446
|
# READ
|
|
@@ -645,21 +604,8 @@ class DatabaseFactory:
|
|
|
645
604
|
m.rows_affected = 1
|
|
646
605
|
return result
|
|
647
606
|
return self._run(_op)
|
|
648
|
-
except Exception
|
|
649
|
-
error = str(exc)
|
|
607
|
+
except Exception:
|
|
650
608
|
raise
|
|
651
|
-
finally:
|
|
652
|
-
self._audit_safe(
|
|
653
|
-
action="update",
|
|
654
|
-
model=model,
|
|
655
|
-
entity_id=entity_id if not isinstance(entity_id, dict) else None,
|
|
656
|
-
meta=meta,
|
|
657
|
-
success=success,
|
|
658
|
-
before=before,
|
|
659
|
-
after=after_plain,
|
|
660
|
-
error=error,
|
|
661
|
-
engine_name=adapters.engine_name,
|
|
662
|
-
)
|
|
663
609
|
|
|
664
610
|
# ──────────────────────────────────────────────────────────────────────
|
|
665
611
|
# UPSERT
|
|
@@ -715,21 +661,8 @@ class DatabaseFactory:
|
|
|
715
661
|
m.rows_affected = 1
|
|
716
662
|
return result
|
|
717
663
|
return self._run(_op)
|
|
718
|
-
except Exception
|
|
719
|
-
error = str(exc)
|
|
664
|
+
except Exception:
|
|
720
665
|
raise
|
|
721
|
-
finally:
|
|
722
|
-
self._audit_safe(
|
|
723
|
-
action="upsert",
|
|
724
|
-
model=model,
|
|
725
|
-
entity_id=None,
|
|
726
|
-
meta=meta,
|
|
727
|
-
success=success,
|
|
728
|
-
before=None,
|
|
729
|
-
after=after_plain,
|
|
730
|
-
error=error,
|
|
731
|
-
engine_name=adapters.engine_name,
|
|
732
|
-
)
|
|
733
666
|
|
|
734
667
|
# ──────────────────────────────────────────────────────────────────────
|
|
735
668
|
# DELETE
|
|
@@ -795,21 +728,8 @@ class DatabaseFactory:
|
|
|
795
728
|
m.rows_affected = 1
|
|
796
729
|
return result
|
|
797
730
|
return self._run(_op)
|
|
798
|
-
except Exception
|
|
799
|
-
error = str(exc)
|
|
731
|
+
except Exception:
|
|
800
732
|
raise
|
|
801
|
-
finally:
|
|
802
|
-
self._audit_safe(
|
|
803
|
-
action="delete",
|
|
804
|
-
model=model,
|
|
805
|
-
entity_id=entity_id if not isinstance(entity_id, dict) else None,
|
|
806
|
-
meta=meta,
|
|
807
|
-
success=success,
|
|
808
|
-
before=before,
|
|
809
|
-
after=None,
|
|
810
|
-
error=error,
|
|
811
|
-
engine_name=adapters.engine_name,
|
|
812
|
-
)
|
|
813
733
|
|
|
814
734
|
# ──────────────────────────────────────────────────────────────────────
|
|
815
735
|
# QUERY (LINQ-style)
|
polydb/decorators.py
CHANGED
|
@@ -1,21 +1,1040 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
+
|
|
156
|
+
class DatabaseFactory:
|
|
12
157
|
"""
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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()
|
{altcodepro_polydb_python-2.3.17.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|