altcodepro-polydb-python 2.3.10__py3-none-any.whl → 2.3.13__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.10
3
+ Version: 2.3.13
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,5 +1,5 @@
1
- altcodepro_polydb_python-2.3.10.dist-info/licenses/LICENSE,sha256=9X8GLocsBwy-5aR5JGOt2SAMDDPs9Qv-YnqmHBHOXrw,1067
2
- polydb/PolyDB.py,sha256=p9eDdvBGosE4fNSSAbtq3tHObdKZ-C2V2Q_ia39Ackk,23397
1
+ altcodepro_polydb_python-2.3.13.dist-info/licenses/LICENSE,sha256=9X8GLocsBwy-5aR5JGOt2SAMDDPs9Qv-YnqmHBHOXrw,1067
2
+ polydb/PolyDB.py,sha256=DJjS1a-gjkqqo32avhRM-4CT-9ZZO3LZJ_sUOfZ99L0,23485
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
@@ -13,18 +13,18 @@ polydb/models.py,sha256=9uu_BaJ95194n-vnd0Rx9KLc6aPS-mxn10P4W5grUcI,8155
13
13
  polydb/monitoring.py,sha256=UMm3ybyRJjAQi-prXXMLl9zuHhnhMnYBzMD3XWK66y8,9571
14
14
  polydb/multitenancy.py,sha256=9kyY98RpKg8xDy9ejB_MyV_YzF7eZd4uxashw5S8vlg,6408
15
15
  polydb/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
- polydb/query.py,sha256=L13FL8A06HxT0F_LGlY9IBh-62j1VWZrv2Tu_wS-Ed0,6348
16
+ polydb/query.py,sha256=3oWgRXIYLItmd_R-7u78hCdUgUaoiFmjVg38vi2-rVA,6600
17
17
  polydb/registry.py,sha256=RD_elvFXcmhTdCyZDm2f3ej0elxqhArnSJ2aO9k5VCU,2352
18
- polydb/retry.py,sha256=etsj8MGo1WMvlcZMzWmFELAsWCRs-XPEuJe6K76QgbM,2548
18
+ polydb/retry.py,sha256=hEyL3s-frHI0ceS9dqqjh6yGOyww6tyhe1Bw1Ht7LFs,2681
19
19
  polydb/schema.py,sha256=VrOayX6V6AD2Qh3-lm4ZVPTpI24e4V52IYheZf2rNQ4,5812
20
- polydb/security.py,sha256=-bXdRjFmvq4X6ie6FrZMcO9ZbgjWFkNySSbRwFt1X1Q,16281
20
+ polydb/security.py,sha256=9ju-hc6Y1sxobCoV_mZ3ZWroUD73LodyTLVMhY_HeKU,16360
21
21
  polydb/types.py,sha256=XB_85Un8_aWt4dSfpjIGotHbK3KBY2WurQGXr9EOxWY,2992
22
22
  polydb/utils.py,sha256=G_ki5zKr5rGPgpFQM1CTq6twQd5OytaHKfet267MftM,1662
23
23
  polydb/validation.py,sha256=a1o1d02k3c6PWQwkBbw_0nEmIgrdB5RR8OcpNQMn4cA,4810
24
24
  polydb/adapters/AzureBlobStorageAdapter.py,sha256=4vD55Z8DBTzBK66jIJbo5bNMY-AQ61MlP0-P2Fv_JgQ,7083
25
25
  polydb/adapters/AzureFileStorageAdapter.py,sha256=VZNprqlBXCuWUgtqClNT-NrQmRf-XFYEiRA2BLbf-Sc,7046
26
26
  polydb/adapters/AzureQueueAdapter.py,sha256=5PrKAX4OQxUD5nReZKrInF_mjQVdFcj2aYd0Xp-HjjQ,5254
27
- polydb/adapters/AzureTableStorageAdapter.py,sha256=EA7v5YUJwe0S3ql0EPg-ObNtX2iNJ38fJiwvRU-1Blo,23295
27
+ polydb/adapters/AzureTableStorageAdapter.py,sha256=ILEB1mlQ96JumxQZ1BcYKGLvxTOeyFYU3tlTuTwQ6bU,23535
28
28
  polydb/adapters/BlockchainBlobAdapter.py,sha256=D01Yua9mkKfaQrxKYApblIyI6DSP0dtNAh4Tav51HJ4,3299
29
29
  polydb/adapters/BlockchainFileAdapter.py,sha256=G749xOVpG20HuKS8zCgi6PMjoJNu-YXK7zitygjLdzM,8335
30
30
  polydb/adapters/BlockchainKVAdapter.py,sha256=UFYHyTgvdW-sZUBqyHEG3Cdx6wSTiF2QowEVWL3XPTg,4564
@@ -36,7 +36,7 @@ polydb/adapters/GCPFilestoreAdapter.py,sha256=yjFQQwsWYWc8mo8XwMViVTWb5_D--xAyTM
36
36
  polydb/adapters/GCPPubSubAdapter.py,sha256=7XNots2VA0ReEDku-rjg-OTYmftIpx5UgnXYDdXNkOo,8692
37
37
  polydb/adapters/GCPStorageAdapter.py,sha256=9yS1Jhcn5_rCRdZ5uOqcRW6Ba-UNb6VOYpwENP-C6Qk,7133
38
38
  polydb/adapters/MongoDBAdapter.py,sha256=vX3SAHDLbTnHABGesES9N-gYSQqPqdqFLJgd7pYWZzw,7471
39
- polydb/adapters/PostgreSQLAdapter.py,sha256=atK8MEm2ui5kb-xWaDW0-6kn4vwTmaBl5G4BPhK4Gh0,24867
39
+ polydb/adapters/PostgreSQLAdapter.py,sha256=oi3kIM6KXYTyqrTSaMozQHt-qqM4_wcJvk_4-ha3Itc,25613
40
40
  polydb/adapters/S3Adapter.py,sha256=5R0zHAL2SkGFjp1L3bp-IU468bXYdSf6nKx974MN104,7586
41
41
  polydb/adapters/S3CompatibleAdapter.py,sha256=jpafqbAjA8-irdXBrfXa1QJySIzrcUQ6UrFt5h5FAEc,7006
42
42
  polydb/adapters/SQSAdapter.py,sha256=1vfbNoqIDy-b8t2xcxy91SoxSYBPFDfUh7yCQWxdS84,5778
@@ -45,17 +45,17 @@ polydb/adapters/VercelFileAdapter.py,sha256=-fLRCi0AUbyXAR4nkHCV-wXkociHF2hzzEDq
45
45
  polydb/adapters/VercelKVAdapter.py,sha256=QZxRkuYzVNWFCEFaJPSph8YEAut-YtlXPqbCt0JlROI,8647
46
46
  polydb/adapters/VercelQueueAdapter.py,sha256=cWtPaMIWCako0HHr_rzAE6vMLugSR6zBXqp3VP9MXwY,2375
47
47
  polydb/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
48
- polydb/audit/AuditStorage.py,sha256=A5HLhkWG8kef_caUuWakJf4fSK_r03NN4JLbbK7N55c,4949
48
+ polydb/audit/AuditStorage.py,sha256=7HBEN6m-tBWVJJXxXIe13pe4SMi2BxncPm-_MNPgwNw,6455
49
49
  polydb/audit/__init__.py,sha256=Z7-y5djq3glQ2Yun6nj-13Efpj3oGz9Qc0veS2g06Y4,245
50
50
  polydb/audit/context.py,sha256=-A1FMtmr-2snVAHpTrVT80u-D_MCaqX6AoV4Ku2bz_o,1955
51
51
  polydb/audit/manager.py,sha256=KzaaOf5bDfr4M-CkCAZBG_U_4xIBCKDLRAf3hsm-DAk,1236
52
- polydb/audit/models.py,sha256=BgkSEQRbjbourxyGcEeJYIYzozwTM-pqTiSOM_BhWHs,2256
52
+ polydb/audit/models.py,sha256=NapdH5dXU9GMoP9ccbDFaeMWvWbwoh2B5N7Nd3Ci6Cg,3745
53
53
  polydb/base/NoSQLKVAdapter.py,sha256=oa3MT7Z6E5zsF6mDeqjaMfefScKXJgne79LkB7dN8ZA,11557
54
54
  polydb/base/ObjectStorageAdapter.py,sha256=mNdJnhoB3VqSCQvmcoel5PohrVQw7Nrajdd5suGBOvQ,2242
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.10.dist-info/METADATA,sha256=6w5pCWplrFUC9Ntyq-7bxXxCbVhEOWUmnoQygy1LjMM,12303
59
- altcodepro_polydb_python-2.3.10.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
60
- altcodepro_polydb_python-2.3.10.dist-info/top_level.txt,sha256=WgLFWJoYjUhwvyPxJFl6jYLrVFuBJDX3OABf4ocwk_E,7
61
- altcodepro_polydb_python-2.3.10.dist-info/RECORD,,
58
+ altcodepro_polydb_python-2.3.13.dist-info/METADATA,sha256=KAxUf3jUoAFH8XIWpisSRTY1D61pJ2T87SI_VdILnKI,12303
59
+ altcodepro_polydb_python-2.3.13.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
60
+ altcodepro_polydb_python-2.3.13.dist-info/top_level.txt,sha256=WgLFWJoYjUhwvyPxJFl6jYLrVFuBJDX3OABf4ocwk_E,7
61
+ altcodepro_polydb_python-2.3.13.dist-info/RECORD,,
polydb/PolyDB.py CHANGED
@@ -23,7 +23,6 @@ from .types import JsonDict, Lookup
23
23
  from .utils import setup_logger
24
24
  from .validation import ModelValidator, SchemaValidator
25
25
 
26
-
27
26
  ModelRef = Union[Type, str]
28
27
 
29
28
 
@@ -336,6 +335,7 @@ class PolyDB:
336
335
  metadata: Optional[Dict[str, Any]] = None,
337
336
  storage_name: str = "default",
338
337
  optimize: bool = True,
338
+ container_name: Optional[str] = None,
339
339
  ) -> str:
340
340
  storage = self.get_object_storage(storage_name)
341
341
  return storage.put(
@@ -345,6 +345,7 @@ class PolyDB:
345
345
  optimize=optimize,
346
346
  media_type=media_type,
347
347
  metadata=metadata or {},
348
+ container_name=container_name,
348
349
  )
349
350
 
350
351
  def get_blob(
@@ -116,11 +116,17 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
116
116
  if v is None:
117
117
  return None
118
118
 
119
+ # Treat empty containers as absent — let the row omit the property.
120
+ if isinstance(v, (list, tuple, dict)) and len(v) == 0:
121
+ return None
122
+
119
123
  if isinstance(v, bytes):
120
124
  return _BYTES_PREFIX + base64.b64encode(v).decode("ascii")
121
125
 
122
- if isinstance(v, (dict, list)):
123
- return _JSON_PREFIX + json.dumps(v, default=json_safe)
126
+ if isinstance(v, (dict, list, tuple)):
127
+ return _JSON_PREFIX + json.dumps(
128
+ list(v) if isinstance(v, tuple) else v, default=json_safe
129
+ )
124
130
 
125
131
  if isinstance(v, UUID):
126
132
  return str(v)
@@ -8,8 +8,6 @@ import hashlib
8
8
  from contextlib import contextmanager
9
9
  import json
10
10
  from datetime import datetime, date
11
-
12
-
13
11
  from ..errors import DatabaseError, ConnectionError
14
12
  from ..retry import retry
15
13
  from ..utils import validate_table_name, validate_column_name
@@ -102,34 +100,46 @@ class PostgreSQLAdapter:
102
100
 
103
101
  def _serialize_value(self, v: Any) -> Any:
104
102
  """
105
- Make all outgoing values safe for psycopg2.
103
+ Make outgoing values safe for psycopg2 across mixed column types.
106
104
 
107
105
  Rules:
108
- - dict -> Json()
109
- - list -> leave as list (so TEXT[] works)
110
- - datetime/date -> pass as native (psycopg2 handles it)
111
- - Decimal -> convert to float
112
- - everything else -> pass as-is
106
+ None / empty list / empty dict -> None (becomes NULL on any column)
107
+ list of primitives (str/int/...) -> native list (psycopg2 maps to TEXT[]/INT[])
108
+ list containing dicts -> Json(list) (for JSONB columns)
109
+ dict -> Json(dict)
110
+ datetime/date -> native
111
+ Decimal -> float
112
+ everything else -> as-is
113
113
  """
114
+ # NULL-ify empties so they're valid for TEXT[], JSONB, and plain columns alike.
114
115
  from psycopg2.extras import Json
115
116
 
116
117
  if v is None:
117
118
  return None
119
+ if isinstance(v, (list, tuple)) and len(v) == 0:
120
+ return None
121
+ if isinstance(v, dict) and len(v) == 0:
122
+ return None
118
123
 
119
- # Dict -> JSON/JSONB
124
+ # Dict -> JSONB
120
125
  if isinstance(v, dict):
121
126
  return Json(self._json_safe(v))
122
127
 
123
- # List:
124
- # DO NOT wrap in Json() automatically.
125
- # If column is JSONB, Postgres will still accept Json(list).
126
- # But for TEXT[] columns we must send Python list.
127
- if isinstance(v, list):
128
+ # List: route by element type.
129
+ if isinstance(v, (list, tuple)):
130
+ v = list(v)
131
+ # If ANY element is a dict, treat as JSON payload (for JSONB columns).
132
+ if any(isinstance(x, dict) for x in v):
133
+ return Json(v)
134
+ # If ALL elements are primitives, send as native list for TEXT[]/INT[].
135
+ if all(isinstance(x, (str, int, float, bool, type(None))) for x in v):
136
+ return v
137
+ # Mixed / nested -> safest is JSONB
128
138
  return Json(v)
129
139
 
130
140
  # Datetime / date
131
141
  if isinstance(v, (datetime, date)):
132
- return v # psycopg2 handles natively
142
+ return v
133
143
 
134
144
  # Decimal
135
145
  if isinstance(v, Decimal):
@@ -10,14 +10,19 @@ from ..cloudDatabaseFactory import CloudDatabaseFactory
10
10
 
11
11
  class AuditStorage:
12
12
  """Audit log with distributed-safe hash chaining"""
13
-
13
+
14
14
  _lock = threading.Lock()
15
-
15
+
16
16
  def __init__(self):
17
17
  self.factory = CloudDatabaseFactory()
18
18
  self.sql = self.factory.get_sql()
19
19
  self._ensure_table()
20
-
20
+
21
+ @staticmethod
22
+ def _is_unique_violation(exc: Exception) -> bool:
23
+ s = str(exc).lower()
24
+ return "23505" in s or "duplicate key" in s or "unique constraint" in s
25
+
21
26
  def _ensure_table(self):
22
27
  """Create audit table if not exists"""
23
28
  try:
@@ -43,7 +48,8 @@ class AuditStorage:
43
48
  user_agent TEXT,
44
49
  error TEXT,
45
50
  hash VARCHAR(64) NOT NULL,
46
- previous_hash VARCHAR(64),
51
+ previous_hash VARCHAR(64) NOT NULL DEFAULT '',
52
+ CONSTRAINT uq_audit_chain UNIQUE (tenant_id, previous_hash),
47
53
  created_at TIMESTAMP DEFAULT NOW()
48
54
  );
49
55
 
@@ -56,81 +62,103 @@ class AuditStorage:
56
62
  CREATE INDEX IF NOT EXISTS idx_audit_hash_chain
57
63
  ON polydb_audit_log(tenant_id, timestamp DESC, previous_hash);
58
64
  """
59
-
65
+
60
66
  self.sql.execute(schema)
61
67
  except Exception:
62
68
  # Table may already exist
63
69
  pass
64
-
70
+
65
71
  def get_last_hash(self, tenant_id: Optional[str]) -> Optional[str]:
66
72
  """Get most recent hash with strict ordering (distributed-safe)"""
67
73
  with self._lock:
68
74
  try:
69
75
  from ..query import QueryBuilder, Operator
70
-
76
+
71
77
  builder = QueryBuilder()
72
-
78
+
73
79
  if tenant_id is not None:
74
- builder.where('tenant_id', Operator.EQ, tenant_id)
75
-
76
- builder.order_by('timestamp', descending=True).take(1)
77
-
78
- results = self.sql.query_linq('polydb_audit_log', builder)
79
-
80
+ builder.where("tenant_id", Operator.EQ, tenant_id)
81
+
82
+ builder.order_by("timestamp", descending=True).take(1)
83
+
84
+ results = self.sql.query_linq("polydb_audit_log", builder)
85
+
80
86
  if results and len(results) > 0:
81
- return results[0].get('hash')
82
-
87
+ return results[0].get("hash")
88
+
83
89
  return None
84
90
  except Exception:
85
91
  return None
86
-
92
+
87
93
  def persist(self, record: AuditRecord) -> None:
88
- """Persist with lock to ensure chain integrity"""
89
- with self._lock:
90
- self.sql.insert('polydb_audit_log', {
91
- 'audit_id': record.audit_id,
92
- 'timestamp': record.timestamp,
93
- 'tenant_id': record.tenant_id,
94
- 'actor_id': record.actor_id,
95
- 'roles': record.roles,
96
- 'action': record.action,
97
- 'model': record.model,
98
- 'entity_id': record.entity_id,
99
- 'storage_type': record.storage_type,
100
- 'provider': record.provider,
101
- 'success': record.success,
102
- 'before': record.before,
103
- 'after': record.after,
104
- 'changed_fields': record.changed_fields,
105
- 'trace_id': record.trace_id,
106
- 'request_id': record.request_id,
107
- 'ip_address': record.ip_address,
108
- 'user_agent': record.user_agent,
109
- 'error': record.error,
110
- 'hash': record.hash,
111
- 'previous_hash': record.previous_hash,
112
- })
113
-
94
+ """Append to the hash chain. Concurrency-safe ACROSS PROCESSES via the
95
+ UNIQUE(tenant_id, previous_hash) constraint + bounded retry: if two
96
+ writers race on the same predecessor, the loser re-reads the new tail
97
+ and re-chains instead of forking. (threading.Lock alone was only
98
+ process-local — the old "distributed-safe" claim was false.)"""
99
+ from dataclasses import asdict
100
+ from .models import compute_audit_hash
101
+
102
+ last_err: Optional[Exception] = None
103
+ for _ in range(8):
104
+ with self._lock:
105
+ prev = self.get_last_hash(record.tenant_id) or ""
106
+ record.previous_hash = prev
107
+ record.hash = compute_audit_hash(asdict(record))
108
+ row = {
109
+ "audit_id": record.audit_id,
110
+ "timestamp": record.timestamp,
111
+ "tenant_id": record.tenant_id,
112
+ "actor_id": record.actor_id,
113
+ "roles": record.roles or None,
114
+ "action": record.action,
115
+ "model": record.model,
116
+ "entity_id": record.entity_id,
117
+ "storage_type": record.storage_type,
118
+ "provider": record.provider,
119
+ "success": record.success,
120
+ "before": record.before,
121
+ "after": record.after,
122
+ "changed_fields": record.changed_fields or None,
123
+ "trace_id": record.trace_id,
124
+ "request_id": record.request_id,
125
+ "ip_address": record.ip_address,
126
+ "user_agent": record.user_agent,
127
+ "error": record.error,
128
+ "hash": record.hash,
129
+ "previous_hash": record.previous_hash,
130
+ }
131
+ try:
132
+ self.sql.insert("polydb_audit_log", row)
133
+ return
134
+ except Exception as e:
135
+ if self._is_unique_violation(e):
136
+ last_err = e
137
+ continue
138
+ raise
139
+ raise last_err or RuntimeError("audit persist failed after retries")
140
+
114
141
  def verify_chain(self, tenant_id: Optional[str] = None) -> bool:
115
- """Verify hash chain integrity"""
142
+ """Verify BOTH chain linkage AND per-record content integrity.
143
+ The old version only checked previous_hash linkage, so editing
144
+ before/after/action while leaving `hash` intact passed silently."""
116
145
  from ..query import QueryBuilder, Operator
117
-
146
+ from .models import compute_audit_hash
147
+
118
148
  builder = QueryBuilder()
119
-
120
149
  if tenant_id is not None:
121
- builder.where('tenant_id', Operator.EQ, tenant_id)
122
-
123
- builder.order_by('timestamp', descending=False)
124
-
125
- records = self.sql.query_linq('polydb_audit_log', builder)
126
-
150
+ builder.where("tenant_id", Operator.EQ, tenant_id)
151
+ builder.order_by("timestamp", descending=False)
152
+
153
+ records = self.sql.query_linq("polydb_audit_log", builder)
127
154
  if not records:
128
155
  return True
129
-
130
- prev_hash = None
131
- for record in records:
132
- if record.get('previous_hash') != prev_hash:
156
+
157
+ prev = ""
158
+ for r in records:
159
+ if (r.get("previous_hash") or "") != prev:
160
+ return False
161
+ if r.get("hash") != compute_audit_hash(r): # content tamper check
133
162
  return False
134
- prev_hash = record.get('hash')
135
-
136
- return True
163
+ prev = r.get("hash")
164
+ return True
polydb/audit/models.py CHANGED
@@ -1,15 +1,52 @@
1
1
  # src/polydb/audit/models.py
2
2
 
3
3
  from dataclasses import dataclass, asdict
4
- from datetime import datetime
5
4
  from typing import Any, Dict, List, Optional
6
5
  import uuid
7
6
  import hashlib
8
7
  import json
9
-
8
+ from datetime import datetime, timezone
10
9
  from ..json_safe import json_safe
11
10
 
12
11
 
12
+ def _iso(ts: Any) -> str:
13
+ return ts.isoformat() if hasattr(ts, "isoformat") else str(ts)
14
+
15
+
16
+ def canonical_audit_payload(src: Dict[str, Any]) -> str:
17
+ """Deterministic JSON for the hash chain. Identical whether `src` is a
18
+ freshly-built record (asdict) or a row read back from Postgres, so the
19
+ create-time hash and the verify-time recomputed hash match.
20
+ Normalizes [] vs NULL and timestamp formatting; excludes `hash`."""
21
+ payload = {
22
+ "audit_id": src.get("audit_id"),
23
+ "timestamp": _iso(src.get("timestamp")),
24
+ "tenant_id": src.get("tenant_id"),
25
+ "actor_id": src.get("actor_id"),
26
+ "roles": list(src.get("roles") or []),
27
+ "action": src.get("action"),
28
+ "model": src.get("model"),
29
+ "entity_id": src.get("entity_id"),
30
+ "storage_type": src.get("storage_type"),
31
+ "provider": src.get("provider"),
32
+ "success": bool(src.get("success")),
33
+ "before": src.get("before"),
34
+ "after": src.get("after"),
35
+ "changed_fields": list(src.get("changed_fields") or []),
36
+ "trace_id": src.get("trace_id"),
37
+ "request_id": src.get("request_id"),
38
+ "ip_address": src.get("ip_address"),
39
+ "user_agent": src.get("user_agent"),
40
+ "error": src.get("error"),
41
+ "previous_hash": src.get("previous_hash") or "",
42
+ }
43
+ return json.dumps(payload, sort_keys=True, default=json_safe)
44
+
45
+
46
+ def compute_audit_hash(src: Dict[str, Any]) -> str:
47
+ return hashlib.sha256(canonical_audit_payload(src).encode()).hexdigest()
48
+
49
+
13
50
  @dataclass
14
51
  class AuditRecord:
15
52
  audit_id: str
@@ -55,7 +92,7 @@ class AuditRecord:
55
92
  context,
56
93
  previous_hash: Optional[str] = None,
57
94
  ):
58
- now = datetime.utcnow().isoformat()
95
+ now = datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
59
96
  audit_id = str(uuid.uuid4())
60
97
 
61
98
  record = cls(
@@ -81,8 +118,6 @@ class AuditRecord:
81
118
  previous_hash=previous_hash,
82
119
  )
83
120
 
84
- record.hash = hashlib.sha256(
85
- json.dumps(asdict(record), sort_keys=True,default=json_safe).encode()
86
- ).hexdigest()
121
+ record.hash = compute_audit_hash(asdict(record))
87
122
 
88
123
  return record
polydb/query.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  from dataclasses import dataclass, field
5
5
  from typing import Any, Dict, List, Optional, Union
6
6
  from enum import Enum
7
+ from .utils import validate_column_name
7
8
 
8
9
 
9
10
  class Operator(Enum):
@@ -130,6 +131,7 @@ class QueryBuilder:
130
131
  params = []
131
132
 
132
133
  for f in self.filters:
134
+ validate_column_name(f.field)
133
135
 
134
136
  if f.operator == Operator.EQ:
135
137
  clauses.append(f"{f.field} = %s")
@@ -156,14 +158,15 @@ class QueryBuilder:
156
158
  params.append(f.value)
157
159
 
158
160
  elif f.operator == Operator.IN:
159
-
160
161
  if isinstance(f.value, (list, tuple)):
161
- placeholders = ",".join(["%s"] * len(f.value))
162
- clauses.append(f"{f.field} IN ({placeholders})")
163
- params.extend(f.value)
164
-
162
+ if not f.value:
163
+ clauses.append("1=0") # empty IN → match nothing
164
+ else:
165
+ placeholders = ",".join(["%s"] * len(f.value))
166
+ clauses.append(f"{f.field} IN ({placeholders})")
167
+ params.extend(f.value)
165
168
  else:
166
- clauses.append(f"{f.field} LIKE %s")
169
+ clauses.append(f"{f.field} = %s") # scalar IN == equality
167
170
  params.append(f.value)
168
171
 
169
172
  elif f.operator == Operator.NOT_IN:
polydb/retry.py CHANGED
@@ -12,42 +12,49 @@ from typing import Callable, Optional, Tuple, Type
12
12
  # Metrics hooks for enterprise monitoring
13
13
  class MetricsHooks:
14
14
  """Metrics hooks that users can override for monitoring"""
15
-
15
+
16
16
  @staticmethod
17
17
  def on_query_start(operation: str, **kwargs):
18
18
  """Called when query starts"""
19
19
  pass
20
-
20
+
21
21
  @staticmethod
22
22
  def on_query_end(operation: str, duration: float, success: bool, **kwargs):
23
23
  """Called when query ends"""
24
24
  pass
25
-
25
+
26
26
  @staticmethod
27
27
  def on_error(operation: str, error: Exception, **kwargs):
28
28
  """Called when error occurs"""
29
29
  pass
30
30
 
31
31
 
32
- def retry(max_attempts: int = 3, delay: float = 1.0, backoff: float = 2.0,
33
- exceptions: Tuple[Type[Exception], ...] = (Exception,)):
32
+ def retry(
33
+ max_attempts: int = 3,
34
+ delay: float = 1.0,
35
+ backoff: float = 2.0,
36
+ exceptions: Tuple[Type[Exception], ...] = (Exception,),
37
+ ):
34
38
  """
35
39
  Retry decorator with exponential backoff
36
-
40
+
37
41
  Args:
38
42
  max_attempts: Maximum number of retry attempts
39
43
  delay: Initial delay between retries (seconds)
40
44
  backoff: Backoff multiplier
41
45
  exceptions: Tuple of exceptions to catch
42
46
  """
47
+ if max_attempts < 1:
48
+ raise ValueError("max_attempts must be >= 1")
49
+
43
50
  def decorator(func: Callable) -> Callable:
44
51
  @wraps(func)
45
52
  def wrapper(*args, **kwargs):
46
53
  attempt = 0
47
54
  current_delay = delay
48
-
55
+
49
56
  logger = logging.getLogger(__name__)
50
-
57
+
51
58
  while attempt < max_attempts:
52
59
  start_time = time.time()
53
60
  try:
@@ -61,16 +68,20 @@ def retry(max_attempts: int = 3, delay: float = 1.0, backoff: float = 2.0,
61
68
  duration = time.time() - start_time
62
69
  MetricsHooks.on_query_end(func.__name__, duration, False)
63
70
  MetricsHooks.on_error(func.__name__, e)
64
-
71
+
65
72
  if attempt >= max_attempts:
66
73
  raise
67
-
74
+
68
75
  logger.warning(
69
76
  f"Attempt {attempt}/{max_attempts} failed for {func.__name__}: {str(e)}. "
70
77
  f"Retrying in {current_delay}s..."
71
78
  )
72
79
  time.sleep(current_delay)
73
80
  current_delay *= backoff
74
-
81
+ raise RuntimeError(
82
+ f"{func.__name__} exhausted {max_attempts} attempts without returning"
83
+ )
84
+
75
85
  return wrapper
76
- return decorator
86
+
87
+ return decorator
polydb/security.py CHANGED
@@ -2,6 +2,7 @@
2
2
  """
3
3
  Security features: encryption, masking, row-level security
4
4
  """
5
+
5
6
  from typing import Dict, Any, List, Optional, Callable, Union
6
7
  from dataclasses import dataclass
7
8
  import hashlib
@@ -50,7 +51,7 @@ class FieldEncryption:
50
51
  """Encrypt arbitrary value (serialize if non-str)"""
51
52
  if value is None:
52
53
  return ""
53
- data = json.dumps(value,default=json_safe) if not isinstance(value, str) else value
54
+ data = json.dumps(value, default=json_safe) if not isinstance(value, str) else value
54
55
  try:
55
56
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
56
57
 
@@ -91,9 +92,11 @@ class FieldEncryption:
91
92
  except ImportError:
92
93
  raise ImportError("cryptography not installed")
93
94
  except Exception as e:
94
- # On decrypt failure, return masked or original to avoid crashes
95
- logger.warning(f"Decryption failed: {e}. Returning original value.")
96
- return encrypted_data
95
+ # Fail loud. Returning ciphertext as if it were plaintext masks
96
+ # key-rotation errors / corruption and leaks the 'encrypted:' blob
97
+ # into application data.
98
+ logger.error("Field decryption failed: %s", e)
99
+ raise
97
100
 
98
101
  def encrypt_fields(self, data: Dict[str, Any], fields: List[str]) -> Dict[str, Any]:
99
102
  """Encrypt specified fields in data dict"""