altcodepro-polydb-python 2.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- altcodepro_polydb_python-2.1.0.dist-info/METADATA +378 -0
- altcodepro_polydb_python-2.1.0.dist-info/RECORD +51 -0
- altcodepro_polydb_python-2.1.0.dist-info/WHEEL +5 -0
- altcodepro_polydb_python-2.1.0.dist-info/licenses/LICENSE +21 -0
- altcodepro_polydb_python-2.1.0.dist-info/top_level.txt +1 -0
- polydb/__init__.py +64 -0
- polydb/adapters/AzureBlobStorageAdapter.py +77 -0
- polydb/adapters/AzureFileStorageAdapter.py +79 -0
- polydb/adapters/AzureQueueAdapter.py +61 -0
- polydb/adapters/AzureTableStorageAdapter.py +182 -0
- polydb/adapters/DynamoDBAdapter.py +216 -0
- polydb/adapters/EFSAdapter.py +50 -0
- polydb/adapters/FirestoreAdapter.py +193 -0
- polydb/adapters/GCPStorageAdapter.py +81 -0
- polydb/adapters/MongoDBAdapter.py +136 -0
- polydb/adapters/PostgreSQLAdapter.py +453 -0
- polydb/adapters/PubSubAdapter.py +83 -0
- polydb/adapters/S3Adapter.py +86 -0
- polydb/adapters/S3CompatibleAdapter.py +90 -0
- polydb/adapters/SQSAdapter.py +84 -0
- polydb/adapters/VercelKVAdapter.py +327 -0
- polydb/adapters/__init__.py +0 -0
- polydb/advanced_query.py +147 -0
- polydb/audit/AuditStorage.py +136 -0
- polydb/audit/__init__.py +7 -0
- polydb/audit/context.py +53 -0
- polydb/audit/manager.py +47 -0
- polydb/audit/models.py +86 -0
- polydb/base/NoSQLKVAdapter.py +301 -0
- polydb/base/ObjectStorageAdapter.py +42 -0
- polydb/base/QueueAdapter.py +27 -0
- polydb/base/SharedFilesAdapter.py +32 -0
- polydb/base/__init__.py +0 -0
- polydb/batch.py +163 -0
- polydb/cache.py +204 -0
- polydb/databaseFactory.py +748 -0
- polydb/decorators.py +21 -0
- polydb/errors.py +82 -0
- polydb/factory.py +107 -0
- polydb/models.py +39 -0
- polydb/monitoring.py +313 -0
- polydb/multitenancy.py +197 -0
- polydb/py.typed +0 -0
- polydb/query.py +150 -0
- polydb/registry.py +71 -0
- polydb/retry.py +76 -0
- polydb/schema.py +205 -0
- polydb/security.py +458 -0
- polydb/types.py +127 -0
- polydb/utils.py +61 -0
- polydb/validation.py +131 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any, Callable, List, Optional, Tuple, Union
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
8
|
+
from polydb.batch import BatchOperations
|
|
9
|
+
from polydb.cache import CacheWarmer, RedisCacheEngine
|
|
10
|
+
from polydb.monitoring import HealthCheck, MetricsCollector, PerformanceMonitor
|
|
11
|
+
from polydb.multitenancy import TenantContext, TenantIsolationEnforcer, TenantRegistry
|
|
12
|
+
from polydb.security import DataMasking, FieldEncryption, RowLevelSecurity
|
|
13
|
+
from polydb.validation import ModelValidator
|
|
14
|
+
from .errors import AdapterConfigurationError
|
|
15
|
+
from .registry import ModelRegistry
|
|
16
|
+
from .types import JsonDict, Lookup, ModelMeta
|
|
17
|
+
from .audit.manager import AuditManager
|
|
18
|
+
from .audit.context import AuditContext
|
|
19
|
+
from .query import Operator, QueryBuilder
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_DEFAULT_RETRY = retry(
|
|
24
|
+
wait=wait_exponential(multiplier=0.5, min=0.5, max=6),
|
|
25
|
+
stop=stop_after_attempt(3),
|
|
26
|
+
reraise=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DatabaseFactory:
|
|
31
|
+
"""Universal CRUD with cache, soft delete, audit, multi-tenancy, RLS, encryption, monitoring"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
provider: Optional[Any] = None,
|
|
37
|
+
cloud_factory: Optional[Any] = None,
|
|
38
|
+
tenant_registry: Optional[TenantRegistry] = None,
|
|
39
|
+
enable_retries: bool = True,
|
|
40
|
+
enable_audit: bool = True,
|
|
41
|
+
enable_audit_reads: bool = False,
|
|
42
|
+
enable_cache: bool = True,
|
|
43
|
+
soft_delete: bool = False,
|
|
44
|
+
use_redis_cache: bool = False,
|
|
45
|
+
enable_monitoring: bool = False,
|
|
46
|
+
enable_encryption: bool = False,
|
|
47
|
+
enable_rls: bool = False,
|
|
48
|
+
):
|
|
49
|
+
from .factory import CloudDatabaseFactory
|
|
50
|
+
|
|
51
|
+
self._enable_retries = enable_retries
|
|
52
|
+
self._enable_audit = enable_audit
|
|
53
|
+
self._enable_audit_reads = enable_audit_reads
|
|
54
|
+
self._enable_cache = enable_cache
|
|
55
|
+
self._soft_delete = soft_delete
|
|
56
|
+
|
|
57
|
+
# Monitoring
|
|
58
|
+
if enable_monitoring:
|
|
59
|
+
self.metrics = MetricsCollector()
|
|
60
|
+
self.health = HealthCheck(self)
|
|
61
|
+
else:
|
|
62
|
+
self.metrics = None
|
|
63
|
+
self.health = None
|
|
64
|
+
|
|
65
|
+
# Redis cache (only if explicitly enabled + URL present)
|
|
66
|
+
self._cache: Optional[RedisCacheEngine] = None
|
|
67
|
+
self.cache_warmer: Optional[CacheWarmer] = None
|
|
68
|
+
if enable_cache and use_redis_cache:
|
|
69
|
+
redis_url = os.getenv("REDIS_CACHE_URL")
|
|
70
|
+
if redis_url:
|
|
71
|
+
self._cache = RedisCacheEngine(redis_url=redis_url)
|
|
72
|
+
self.cache_warmer = CacheWarmer(self, self._cache)
|
|
73
|
+
else:
|
|
74
|
+
logger.warning("use_redis_cache=True but REDIS_CACHE_URL not set")
|
|
75
|
+
|
|
76
|
+
# Encryption
|
|
77
|
+
self.encryption = FieldEncryption() if enable_encryption else None
|
|
78
|
+
|
|
79
|
+
# Always available (can be no-op if not configured)
|
|
80
|
+
self.masking = DataMasking()
|
|
81
|
+
|
|
82
|
+
# Row-level security
|
|
83
|
+
self.rls = RowLevelSecurity() if enable_rls else None
|
|
84
|
+
|
|
85
|
+
# Multi-tenancy
|
|
86
|
+
self.tenant_registry = tenant_registry
|
|
87
|
+
self.tenant_enforcer = TenantIsolationEnforcer(tenant_registry) if tenant_registry else None
|
|
88
|
+
|
|
89
|
+
self.batch = BatchOperations(self)
|
|
90
|
+
self._cloud_factory = cloud_factory or CloudDatabaseFactory(provider=provider)
|
|
91
|
+
self._provider_name = self._cloud_factory.provider.value
|
|
92
|
+
|
|
93
|
+
self._sql = self._cloud_factory.get_sql()
|
|
94
|
+
self._nosql = self._cloud_factory.get_nosql_kv()
|
|
95
|
+
|
|
96
|
+
if not self._sql or not self._nosql:
|
|
97
|
+
raise AdapterConfigurationError("Adapters not initialized")
|
|
98
|
+
|
|
99
|
+
self._audit = AuditManager() if enable_audit else None
|
|
100
|
+
|
|
101
|
+
def _meta(self, model: Union[type, str]) -> ModelMeta:
|
|
102
|
+
return ModelRegistry.get(model)
|
|
103
|
+
|
|
104
|
+
def _model_type(self, model: Union[type, str]) -> type:
|
|
105
|
+
return ModelRegistry.resolve(model)
|
|
106
|
+
|
|
107
|
+
def _model_name(self, model: Union[type, str]) -> str:
|
|
108
|
+
return model.__name__ if isinstance(model, type) else str(model)
|
|
109
|
+
|
|
110
|
+
def _current_tenant_id(self) -> Optional[str]:
|
|
111
|
+
tenant = TenantContext.get_tenant()
|
|
112
|
+
return tenant.tenant_id if tenant else None
|
|
113
|
+
|
|
114
|
+
def _current_actor_id(self) -> Optional[str]:
|
|
115
|
+
return AuditContext.actor_id.get()
|
|
116
|
+
|
|
117
|
+
def _inject_tenant(self, data: JsonDict) -> JsonDict:
|
|
118
|
+
tenant_id = self._current_tenant_id()
|
|
119
|
+
if tenant_id and "tenant_id" not in data:
|
|
120
|
+
data = dict(data)
|
|
121
|
+
data["tenant_id"] = tenant_id
|
|
122
|
+
return data
|
|
123
|
+
|
|
124
|
+
def _inject_audit_fields(self, data: JsonDict, is_create: bool = False) -> JsonDict:
|
|
125
|
+
data = dict(data)
|
|
126
|
+
actor_id = self._current_actor_id()
|
|
127
|
+
now = datetime.utcnow().isoformat()
|
|
128
|
+
|
|
129
|
+
if is_create:
|
|
130
|
+
if "created_at" not in data:
|
|
131
|
+
data["created_at"] = now
|
|
132
|
+
if "created_by" not in data and actor_id:
|
|
133
|
+
data["created_by"] = actor_id
|
|
134
|
+
|
|
135
|
+
if "updated_at" not in data:
|
|
136
|
+
data["updated_at"] = now
|
|
137
|
+
if "updated_by" not in data and actor_id:
|
|
138
|
+
data["updated_by"] = actor_id
|
|
139
|
+
|
|
140
|
+
return data
|
|
141
|
+
|
|
142
|
+
def _apply_soft_delete_filter(self, query: Optional[Lookup]) -> Lookup:
|
|
143
|
+
if not self._soft_delete:
|
|
144
|
+
return query or {}
|
|
145
|
+
|
|
146
|
+
result = dict(query or {})
|
|
147
|
+
result.setdefault("deleted_at", None)
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
def _compute_field_changes(
|
|
151
|
+
self, before: Optional[JsonDict], after: Optional[JsonDict]
|
|
152
|
+
) -> Optional[List[str]]:
|
|
153
|
+
if not before or not after:
|
|
154
|
+
return None
|
|
155
|
+
changed = [
|
|
156
|
+
key
|
|
157
|
+
for key in set(before.keys()) | set(after.keys())
|
|
158
|
+
if before.get(key) != after.get(key)
|
|
159
|
+
]
|
|
160
|
+
return changed or None
|
|
161
|
+
|
|
162
|
+
def _audit_safe(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
action: str,
|
|
166
|
+
model: Union[type, str],
|
|
167
|
+
entity_id: Optional[Any],
|
|
168
|
+
meta: ModelMeta,
|
|
169
|
+
success: bool,
|
|
170
|
+
before: Optional[JsonDict],
|
|
171
|
+
after: Optional[JsonDict],
|
|
172
|
+
error: Optional[str],
|
|
173
|
+
):
|
|
174
|
+
if not self._audit:
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
changed_fields = self._compute_field_changes(before, after)
|
|
179
|
+
self._audit.record(
|
|
180
|
+
action=action,
|
|
181
|
+
model=self._model_name(model),
|
|
182
|
+
entity_id=str(entity_id) if entity_id else None,
|
|
183
|
+
storage_type=meta.storage,
|
|
184
|
+
provider=self._provider_name,
|
|
185
|
+
success=success,
|
|
186
|
+
before=before,
|
|
187
|
+
after=after,
|
|
188
|
+
changed_fields=changed_fields,
|
|
189
|
+
error=error,
|
|
190
|
+
)
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
logger.error(f"Audit recording failed: {exc}")
|
|
193
|
+
|
|
194
|
+
def _run(self, fn: Callable[[], Any]) -> Any:
|
|
195
|
+
if not self._enable_retries:
|
|
196
|
+
return fn()
|
|
197
|
+
retry_fn = _DEFAULT_RETRY(fn)
|
|
198
|
+
return retry_fn()
|
|
199
|
+
|
|
200
|
+
def create(self, model: Union[type, str], data: JsonDict) -> JsonDict:
|
|
201
|
+
model_type = self._model_type(model)
|
|
202
|
+
ModelValidator.validate_and_raise(model_type)
|
|
203
|
+
|
|
204
|
+
model_name = self._model_name(model)
|
|
205
|
+
meta = self._meta(model)
|
|
206
|
+
|
|
207
|
+
tenant_id = self._current_tenant_id()
|
|
208
|
+
actor_id = self._current_actor_id()
|
|
209
|
+
|
|
210
|
+
# Security & policies
|
|
211
|
+
if self.tenant_enforcer:
|
|
212
|
+
data = self.tenant_enforcer.enforce_write(model_name, data)
|
|
213
|
+
if self.rls:
|
|
214
|
+
data = self.rls.enforce_write(model_name, data)
|
|
215
|
+
|
|
216
|
+
data = self._inject_tenant(data)
|
|
217
|
+
data = self._inject_audit_fields(data, is_create=True)
|
|
218
|
+
|
|
219
|
+
# Encryption (uses meta.encrypted_fields – assumed defined on model)
|
|
220
|
+
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
221
|
+
if self.encryption and encrypted_fields:
|
|
222
|
+
data = self.encryption.encrypt_fields(data, encrypted_fields)
|
|
223
|
+
|
|
224
|
+
before = None
|
|
225
|
+
after_plain = None
|
|
226
|
+
success = False
|
|
227
|
+
error: Optional[str] = None
|
|
228
|
+
entity_id: Optional[Any] = None
|
|
229
|
+
|
|
230
|
+
def _op() -> JsonDict:
|
|
231
|
+
nonlocal after_plain, success, entity_id
|
|
232
|
+
|
|
233
|
+
if meta.storage == "sql" and meta.table:
|
|
234
|
+
result = self._sql.insert(meta.table, data)
|
|
235
|
+
else:
|
|
236
|
+
result = self._nosql.put(model_type, data)
|
|
237
|
+
|
|
238
|
+
entity_id = result.get("id")
|
|
239
|
+
# Decrypt for audit / returned value (plain text)
|
|
240
|
+
after_plain = result
|
|
241
|
+
if self.encryption and encrypted_fields:
|
|
242
|
+
after_plain = self.encryption.decrypt_fields(result, encrypted_fields)
|
|
243
|
+
|
|
244
|
+
success = True
|
|
245
|
+
|
|
246
|
+
# Cache invalidation
|
|
247
|
+
if self._enable_cache and self._cache:
|
|
248
|
+
self._cache.invalidate(model_name)
|
|
249
|
+
|
|
250
|
+
return after_plain
|
|
251
|
+
|
|
252
|
+
monitor_ctx = (
|
|
253
|
+
PerformanceMonitor(self.metrics, "create", model_name, tenant_id)
|
|
254
|
+
if self.metrics
|
|
255
|
+
else None
|
|
256
|
+
)
|
|
257
|
+
try:
|
|
258
|
+
if monitor_ctx:
|
|
259
|
+
with monitor_ctx as m:
|
|
260
|
+
result = self._run(_op)
|
|
261
|
+
m.rows_affected = 1 # type: ignore
|
|
262
|
+
return result
|
|
263
|
+
else:
|
|
264
|
+
return self._run(_op)
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
error = str(exc)
|
|
267
|
+
raise
|
|
268
|
+
finally:
|
|
269
|
+
self._audit_safe(
|
|
270
|
+
action="create",
|
|
271
|
+
model=model,
|
|
272
|
+
entity_id=entity_id,
|
|
273
|
+
meta=meta,
|
|
274
|
+
success=success,
|
|
275
|
+
before=before,
|
|
276
|
+
after=after_plain,
|
|
277
|
+
error=error,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def read(
|
|
281
|
+
self,
|
|
282
|
+
model: Union[type, str],
|
|
283
|
+
query: Optional[Lookup] = None,
|
|
284
|
+
*,
|
|
285
|
+
limit: Optional[int] = None,
|
|
286
|
+
offset: Optional[int] = None,
|
|
287
|
+
no_cache: bool = False,
|
|
288
|
+
cache_ttl: Optional[int] = None,
|
|
289
|
+
include_deleted: bool = False,
|
|
290
|
+
) -> List[JsonDict]:
|
|
291
|
+
|
|
292
|
+
model_name = self._model_name(model)
|
|
293
|
+
meta = self._meta(model)
|
|
294
|
+
tenant_id = self._current_tenant_id()
|
|
295
|
+
actor_id = self._current_actor_id()
|
|
296
|
+
|
|
297
|
+
query = self._apply_soft_delete_filter(query if not include_deleted else None)
|
|
298
|
+
|
|
299
|
+
# Multi-tenancy & RLS filters
|
|
300
|
+
if self.tenant_enforcer:
|
|
301
|
+
query = self.tenant_enforcer.enforce_read(model_name, query or {})
|
|
302
|
+
if self.rls:
|
|
303
|
+
query = self.rls.enforce_read(model_name, query or {})
|
|
304
|
+
|
|
305
|
+
use_external_cache = self._enable_cache and self._cache and getattr(meta, "cache", False)
|
|
306
|
+
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
307
|
+
|
|
308
|
+
def _op() -> List[JsonDict]:
|
|
309
|
+
if meta.storage == "sql" and meta.table:
|
|
310
|
+
raw_rows = self._sql.select(meta.table, query, limit=limit, offset=offset)
|
|
311
|
+
else:
|
|
312
|
+
model_type = self._model_type(model)
|
|
313
|
+
eff_no_cache = no_cache or use_external_cache
|
|
314
|
+
eff_ttl = cache_ttl if cache_ttl is not None else getattr(meta, "cache_ttl", None)
|
|
315
|
+
raw_rows = self._nosql.query(
|
|
316
|
+
model_type,
|
|
317
|
+
query=query,
|
|
318
|
+
limit=limit,
|
|
319
|
+
no_cache=eff_no_cache, # type: ignore
|
|
320
|
+
cache_ttl=None if eff_no_cache else eff_ttl,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Decrypt + mask
|
|
324
|
+
if self.encryption and encrypted_fields:
|
|
325
|
+
raw_rows = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw_rows]
|
|
326
|
+
rows = [
|
|
327
|
+
self.masking.mask(r, model=model_name, actor_id=actor_id, tenant_id=tenant_id)
|
|
328
|
+
for r in raw_rows
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
# Set external cache
|
|
332
|
+
if use_external_cache and not no_cache:
|
|
333
|
+
ttl = cache_ttl or getattr(meta, "cache_ttl", 300)
|
|
334
|
+
self._cache.set(model_name, query or {}, rows, ttl) # type: ignore
|
|
335
|
+
|
|
336
|
+
return rows
|
|
337
|
+
|
|
338
|
+
# Cache check
|
|
339
|
+
rows: List[JsonDict] = []
|
|
340
|
+
cached = None
|
|
341
|
+
if use_external_cache and not no_cache:
|
|
342
|
+
cached = self._cache.get(model_name, query or {}) # type: ignore
|
|
343
|
+
if cached is not None:
|
|
344
|
+
rows = cached
|
|
345
|
+
else:
|
|
346
|
+
# Run op with monitoring
|
|
347
|
+
monitor_ctx = (
|
|
348
|
+
PerformanceMonitor(self.metrics, "read", model_name, tenant_id)
|
|
349
|
+
if self.metrics
|
|
350
|
+
else None
|
|
351
|
+
)
|
|
352
|
+
if monitor_ctx:
|
|
353
|
+
with monitor_ctx as m:
|
|
354
|
+
rows = self._run(_op)
|
|
355
|
+
m.rows_returned = len(rows) # type: ignore
|
|
356
|
+
else:
|
|
357
|
+
rows = self._run(_op)
|
|
358
|
+
|
|
359
|
+
# Audit on success
|
|
360
|
+
if self._audit and self._enable_audit_reads:
|
|
361
|
+
self._audit_safe(
|
|
362
|
+
action="read",
|
|
363
|
+
model=model,
|
|
364
|
+
entity_id=None,
|
|
365
|
+
meta=meta,
|
|
366
|
+
success=True,
|
|
367
|
+
before=None,
|
|
368
|
+
after={"count": len(rows)},
|
|
369
|
+
error=None,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
return rows
|
|
373
|
+
|
|
374
|
+
def read_one(
|
|
375
|
+
self,
|
|
376
|
+
model: Union[type, str],
|
|
377
|
+
query: Lookup,
|
|
378
|
+
*,
|
|
379
|
+
no_cache: bool = False,
|
|
380
|
+
include_deleted: bool = False,
|
|
381
|
+
) -> Optional[JsonDict]:
|
|
382
|
+
rows = self.read(
|
|
383
|
+
model,
|
|
384
|
+
query=query,
|
|
385
|
+
limit=1,
|
|
386
|
+
no_cache=no_cache,
|
|
387
|
+
include_deleted=include_deleted,
|
|
388
|
+
)
|
|
389
|
+
return rows[0] if rows else None
|
|
390
|
+
|
|
391
|
+
def read_page(
|
|
392
|
+
self,
|
|
393
|
+
model: Union[type, str],
|
|
394
|
+
query: Lookup,
|
|
395
|
+
*,
|
|
396
|
+
page_size: int = 100,
|
|
397
|
+
continuation_token: Optional[str] = None,
|
|
398
|
+
include_deleted: bool = False,
|
|
399
|
+
) -> Tuple[List[JsonDict], Optional[str]] | None:
|
|
400
|
+
model_name = self._model_name(model)
|
|
401
|
+
meta = self._meta(model)
|
|
402
|
+
tenant_id = self._current_tenant_id()
|
|
403
|
+
actor_id = self._current_actor_id()
|
|
404
|
+
|
|
405
|
+
query = self._apply_soft_delete_filter(query if not include_deleted else None)
|
|
406
|
+
|
|
407
|
+
if self.tenant_enforcer:
|
|
408
|
+
query = self.tenant_enforcer.enforce_read(model_name, query or {})
|
|
409
|
+
if self.rls:
|
|
410
|
+
query = self.rls.enforce_read(model_name, query or {})
|
|
411
|
+
|
|
412
|
+
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
413
|
+
|
|
414
|
+
def _op() -> Tuple[List[JsonDict], Optional[str]]:
|
|
415
|
+
if meta.storage == "sql" and meta.table:
|
|
416
|
+
raw_rows, next_token = self._sql.select_page(
|
|
417
|
+
meta.table, query, page_size, continuation_token
|
|
418
|
+
)
|
|
419
|
+
else:
|
|
420
|
+
model_type = self._model_type(model)
|
|
421
|
+
raw_rows, next_token = self._nosql.query_page(
|
|
422
|
+
model_type, query, page_size, continuation_token
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
if self.encryption and encrypted_fields:
|
|
426
|
+
raw_rows = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw_rows]
|
|
427
|
+
rows = [
|
|
428
|
+
self.masking.mask(r, model=model_name, actor_id=actor_id, tenant_id=tenant_id)
|
|
429
|
+
for r in raw_rows
|
|
430
|
+
]
|
|
431
|
+
return rows, next_token
|
|
432
|
+
|
|
433
|
+
# Run with monitoring
|
|
434
|
+
monitor_ctx = (
|
|
435
|
+
PerformanceMonitor(self.metrics, "read_page", model_name, tenant_id)
|
|
436
|
+
if self.metrics
|
|
437
|
+
else None
|
|
438
|
+
)
|
|
439
|
+
result: Optional[Tuple[List[JsonDict], Optional[str]]] = None
|
|
440
|
+
try:
|
|
441
|
+
if monitor_ctx:
|
|
442
|
+
with monitor_ctx as m:
|
|
443
|
+
result = self._run(_op)
|
|
444
|
+
m.rows_returned = len(result[0]) # type: ignore
|
|
445
|
+
else:
|
|
446
|
+
result = self._run(_op)
|
|
447
|
+
|
|
448
|
+
# Audit on success
|
|
449
|
+
if self._audit and self._enable_audit_reads and result:
|
|
450
|
+
count = len(result[0])
|
|
451
|
+
self._audit_safe(
|
|
452
|
+
action="read_page",
|
|
453
|
+
model=model,
|
|
454
|
+
entity_id=None,
|
|
455
|
+
meta=meta,
|
|
456
|
+
success=True,
|
|
457
|
+
before=None,
|
|
458
|
+
after={"count": count},
|
|
459
|
+
error=None,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
return result
|
|
463
|
+
except Exception:
|
|
464
|
+
raise
|
|
465
|
+
|
|
466
|
+
def update(
|
|
467
|
+
self,
|
|
468
|
+
model: Union[type, str],
|
|
469
|
+
entity_id: Union[Any, Lookup],
|
|
470
|
+
data: JsonDict,
|
|
471
|
+
*,
|
|
472
|
+
etag: Optional[str] = None,
|
|
473
|
+
replace: bool = False,
|
|
474
|
+
) -> JsonDict:
|
|
475
|
+
model_name = self._model_name(model)
|
|
476
|
+
meta = self._meta(model)
|
|
477
|
+
tenant_id = self._current_tenant_id()
|
|
478
|
+
|
|
479
|
+
data = self._inject_audit_fields(data, is_create=False)
|
|
480
|
+
|
|
481
|
+
# Security on changed fields
|
|
482
|
+
if self.tenant_enforcer:
|
|
483
|
+
data = self.tenant_enforcer.enforce_write(model_name, data)
|
|
484
|
+
if self.rls:
|
|
485
|
+
data = self.rls.enforce_write(model_name, data)
|
|
486
|
+
|
|
487
|
+
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
488
|
+
if self.encryption and encrypted_fields:
|
|
489
|
+
data = self.encryption.encrypt_fields(data, [f for f in encrypted_fields if f in data])
|
|
490
|
+
|
|
491
|
+
before = self._fetch_before(model, meta, entity_id, etag=etag)
|
|
492
|
+
after_plain = None
|
|
493
|
+
success = False
|
|
494
|
+
error: Optional[str] = None
|
|
495
|
+
|
|
496
|
+
def _op() -> JsonDict:
|
|
497
|
+
nonlocal after_plain, success
|
|
498
|
+
|
|
499
|
+
if meta.storage == "sql" and meta.table:
|
|
500
|
+
result = self._sql.update(meta.table, entity_id, data)
|
|
501
|
+
else:
|
|
502
|
+
model_type = self._model_type(model)
|
|
503
|
+
result = self._nosql.patch(model_type, entity_id, data, etag=etag, replace=replace)
|
|
504
|
+
|
|
505
|
+
after_plain = result
|
|
506
|
+
if self.encryption and encrypted_fields:
|
|
507
|
+
after_plain = self.encryption.decrypt_fields(result, encrypted_fields)
|
|
508
|
+
|
|
509
|
+
success = True
|
|
510
|
+
|
|
511
|
+
if self._enable_cache and self._cache:
|
|
512
|
+
self._cache.invalidate(model_name)
|
|
513
|
+
|
|
514
|
+
return after_plain
|
|
515
|
+
|
|
516
|
+
monitor_ctx = (
|
|
517
|
+
PerformanceMonitor(self.metrics, "update", model_name, tenant_id)
|
|
518
|
+
if self.metrics
|
|
519
|
+
else None
|
|
520
|
+
)
|
|
521
|
+
try:
|
|
522
|
+
if monitor_ctx:
|
|
523
|
+
with monitor_ctx as m:
|
|
524
|
+
result = self._run(_op)
|
|
525
|
+
m.rows_affected = 1 # type: ignore
|
|
526
|
+
return result
|
|
527
|
+
else:
|
|
528
|
+
return self._run(_op)
|
|
529
|
+
except Exception as exc:
|
|
530
|
+
error = str(exc)
|
|
531
|
+
raise
|
|
532
|
+
finally:
|
|
533
|
+
self._audit_safe(
|
|
534
|
+
action="update",
|
|
535
|
+
model=model,
|
|
536
|
+
entity_id=entity_id if not isinstance(entity_id, dict) else None,
|
|
537
|
+
meta=meta,
|
|
538
|
+
success=success,
|
|
539
|
+
before=before,
|
|
540
|
+
after=after_plain,
|
|
541
|
+
error=error,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
def upsert(self, model: Union[type, str], data: JsonDict, *, replace: bool = False) -> JsonDict:
|
|
545
|
+
model_name = self._model_name(model)
|
|
546
|
+
meta = self._meta(model)
|
|
547
|
+
tenant_id = self._current_tenant_id()
|
|
548
|
+
|
|
549
|
+
if self.tenant_enforcer:
|
|
550
|
+
data = self.tenant_enforcer.enforce_write(model_name, data)
|
|
551
|
+
if self.rls:
|
|
552
|
+
data = self.rls.enforce_write(model_name, data)
|
|
553
|
+
|
|
554
|
+
data = self._inject_tenant(data)
|
|
555
|
+
data = self._inject_audit_fields(data, is_create=True)
|
|
556
|
+
|
|
557
|
+
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
558
|
+
if self.encryption and encrypted_fields:
|
|
559
|
+
data = self.encryption.encrypt_fields(data, encrypted_fields)
|
|
560
|
+
|
|
561
|
+
after_plain = None
|
|
562
|
+
success = False
|
|
563
|
+
error: Optional[str] = None
|
|
564
|
+
|
|
565
|
+
def _op() -> JsonDict:
|
|
566
|
+
nonlocal after_plain, success
|
|
567
|
+
|
|
568
|
+
if meta.storage == "sql" and meta.table:
|
|
569
|
+
result = self._sql.upsert(meta.table, data)
|
|
570
|
+
else:
|
|
571
|
+
model_type = self._model_type(model)
|
|
572
|
+
result = self._nosql.upsert(model_type, data, replace=replace)
|
|
573
|
+
|
|
574
|
+
after_plain = result
|
|
575
|
+
if self.encryption and encrypted_fields:
|
|
576
|
+
after_plain = self.encryption.decrypt_fields(result, encrypted_fields)
|
|
577
|
+
|
|
578
|
+
success = True
|
|
579
|
+
|
|
580
|
+
if self._enable_cache and self._cache:
|
|
581
|
+
self._cache.invalidate(model_name)
|
|
582
|
+
|
|
583
|
+
return after_plain
|
|
584
|
+
|
|
585
|
+
monitor_ctx = (
|
|
586
|
+
PerformanceMonitor(self.metrics, "upsert", model_name, tenant_id)
|
|
587
|
+
if self.metrics
|
|
588
|
+
else None
|
|
589
|
+
)
|
|
590
|
+
try:
|
|
591
|
+
if monitor_ctx:
|
|
592
|
+
with monitor_ctx as m:
|
|
593
|
+
result = self._run(_op)
|
|
594
|
+
m.rows_affected = 1 # type: ignore
|
|
595
|
+
return result
|
|
596
|
+
else:
|
|
597
|
+
return self._run(_op)
|
|
598
|
+
except Exception as exc:
|
|
599
|
+
error = str(exc)
|
|
600
|
+
raise
|
|
601
|
+
finally:
|
|
602
|
+
self._audit_safe(
|
|
603
|
+
action="upsert",
|
|
604
|
+
model=model,
|
|
605
|
+
entity_id=None,
|
|
606
|
+
meta=meta,
|
|
607
|
+
success=success,
|
|
608
|
+
before=None,
|
|
609
|
+
after=after_plain,
|
|
610
|
+
error=error,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
def delete(
|
|
614
|
+
self,
|
|
615
|
+
model: Union[type, str],
|
|
616
|
+
entity_id: Union[Any, Lookup],
|
|
617
|
+
*,
|
|
618
|
+
etag: Optional[str] = None,
|
|
619
|
+
hard: bool = False,
|
|
620
|
+
) -> JsonDict:
|
|
621
|
+
meta = self._meta(model)
|
|
622
|
+
model_name = self._model_name(model)
|
|
623
|
+
tenant_id = self._current_tenant_id()
|
|
624
|
+
|
|
625
|
+
if self._soft_delete and not hard:
|
|
626
|
+
now = datetime.utcnow().isoformat()
|
|
627
|
+
delete_payload = {
|
|
628
|
+
"deleted_at": now,
|
|
629
|
+
"deleted_by": self._current_actor_id(),
|
|
630
|
+
}
|
|
631
|
+
return self.update(model, entity_id, delete_payload)
|
|
632
|
+
|
|
633
|
+
before = self._fetch_before(model, meta, entity_id, etag=etag)
|
|
634
|
+
success = False
|
|
635
|
+
error: Optional[str] = None
|
|
636
|
+
|
|
637
|
+
def _op() -> JsonDict:
|
|
638
|
+
nonlocal success
|
|
639
|
+
|
|
640
|
+
if meta.storage == "sql" and meta.table:
|
|
641
|
+
result = self._sql.delete(meta.table, entity_id)
|
|
642
|
+
else:
|
|
643
|
+
model_type = self._model_type(model)
|
|
644
|
+
result = self._nosql.delete(model_type, entity_id, etag=etag)
|
|
645
|
+
|
|
646
|
+
success = True
|
|
647
|
+
|
|
648
|
+
if self._enable_cache and self._cache:
|
|
649
|
+
self._cache.invalidate(model_name)
|
|
650
|
+
|
|
651
|
+
return result
|
|
652
|
+
|
|
653
|
+
monitor_ctx = (
|
|
654
|
+
PerformanceMonitor(self.metrics, "delete", model_name, tenant_id)
|
|
655
|
+
if self.metrics
|
|
656
|
+
else None
|
|
657
|
+
)
|
|
658
|
+
try:
|
|
659
|
+
if monitor_ctx:
|
|
660
|
+
with monitor_ctx as m:
|
|
661
|
+
result = self._run(_op)
|
|
662
|
+
m.rows_affected = 1 # type: ignore
|
|
663
|
+
return result
|
|
664
|
+
else:
|
|
665
|
+
return self._run(_op)
|
|
666
|
+
except Exception as exc:
|
|
667
|
+
error = str(exc)
|
|
668
|
+
raise
|
|
669
|
+
finally:
|
|
670
|
+
self._audit_safe(
|
|
671
|
+
action="delete",
|
|
672
|
+
model=model,
|
|
673
|
+
entity_id=entity_id if not isinstance(entity_id, dict) else None,
|
|
674
|
+
meta=meta,
|
|
675
|
+
success=success,
|
|
676
|
+
before=before,
|
|
677
|
+
after=None,
|
|
678
|
+
error=error,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
def _fetch_before(
|
|
682
|
+
self,
|
|
683
|
+
model: Union[type, str],
|
|
684
|
+
meta: ModelMeta,
|
|
685
|
+
entity_id: Union[Any, Lookup],
|
|
686
|
+
*,
|
|
687
|
+
etag: Optional[str] = None,
|
|
688
|
+
) -> Optional[JsonDict]:
|
|
689
|
+
lookup = {"id": entity_id} if not isinstance(entity_id, dict) else entity_id
|
|
690
|
+
# read_one already applies tenant + RLS + decryption + masking
|
|
691
|
+
return self.read_one(model, lookup, no_cache=True, include_deleted=True)
|
|
692
|
+
|
|
693
|
+
def query_linq(
|
|
694
|
+
self, model: Union[type, str], builder: QueryBuilder
|
|
695
|
+
) -> Union[List[JsonDict], int]:
|
|
696
|
+
model_name = self._model_name(model)
|
|
697
|
+
meta = self._meta(model)
|
|
698
|
+
extra_filter = {}
|
|
699
|
+
if self.tenant_enforcer:
|
|
700
|
+
extra_filter = self.tenant_enforcer.enforce_read(model_name, extra_filter)
|
|
701
|
+
if self.rls:
|
|
702
|
+
extra_filter = self.rls.enforce_read(model_name, extra_filter)
|
|
703
|
+
|
|
704
|
+
if extra_filter:
|
|
705
|
+
for field, value in extra_filter.items():
|
|
706
|
+
builder = builder.where(field, Operator.EQ, value)
|
|
707
|
+
|
|
708
|
+
tenant_id = self._current_tenant_id()
|
|
709
|
+
|
|
710
|
+
def _op():
|
|
711
|
+
if meta.storage == "sql" and meta.table:
|
|
712
|
+
return self._sql.query_linq(meta.table, builder)
|
|
713
|
+
else:
|
|
714
|
+
model_type = self._model_type(model)
|
|
715
|
+
return self._nosql.query_linq(model_type, builder)
|
|
716
|
+
|
|
717
|
+
# Run with monitoring
|
|
718
|
+
monitor_ctx = (
|
|
719
|
+
PerformanceMonitor(self.metrics, "query_linq", model_name, tenant_id)
|
|
720
|
+
if self.metrics
|
|
721
|
+
else None
|
|
722
|
+
)
|
|
723
|
+
result: Union[List[JsonDict], int]
|
|
724
|
+
try:
|
|
725
|
+
if monitor_ctx:
|
|
726
|
+
with monitor_ctx as m:
|
|
727
|
+
result = self._run(_op)
|
|
728
|
+
if isinstance(result, list):
|
|
729
|
+
m.rows_returned = len(result) # type: ignore
|
|
730
|
+
else:
|
|
731
|
+
result = self._run(_op)
|
|
732
|
+
|
|
733
|
+
# Audit on success
|
|
734
|
+
if self._audit and self._enable_audit_reads and isinstance(result, list):
|
|
735
|
+
self._audit_safe(
|
|
736
|
+
action="query_linq",
|
|
737
|
+
model=model,
|
|
738
|
+
entity_id=None,
|
|
739
|
+
meta=meta,
|
|
740
|
+
success=True,
|
|
741
|
+
before=None,
|
|
742
|
+
after={"count": len(result)},
|
|
743
|
+
error=None,
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
return result
|
|
747
|
+
except Exception:
|
|
748
|
+
raise
|