altcodepro-polydb-python 2.2.2__py3-none-any.whl → 2.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- altcodepro_polydb_python-2.2.4.dist-info/METADATA +489 -0
- altcodepro_polydb_python-2.2.4.dist-info/RECORD +57 -0
- {altcodepro_polydb_python-2.2.2.dist-info → altcodepro_polydb_python-2.2.4.dist-info}/WHEEL +1 -1
- polydb/__init__.py +2 -2
- polydb/adapters/AzureBlobStorageAdapter.py +146 -41
- polydb/adapters/AzureFileStorageAdapter.py +148 -43
- polydb/adapters/AzureQueueAdapter.py +96 -34
- polydb/adapters/AzureTableStorageAdapter.py +462 -119
- polydb/adapters/BlockchainBlobAdapter.py +111 -0
- polydb/adapters/BlockchainKVAdapter.py +152 -0
- polydb/adapters/BlockchainQueueAdapter.py +116 -0
- polydb/adapters/DynamoDBAdapter.py +463 -176
- polydb/adapters/FirestoreAdapter.py +320 -148
- polydb/adapters/GCPPubSubAdapter.py +217 -0
- polydb/adapters/GCPStorageAdapter.py +184 -39
- polydb/adapters/MongoDBAdapter.py +159 -39
- polydb/adapters/PostgreSQLAdapter.py +285 -83
- polydb/adapters/S3Adapter.py +172 -35
- polydb/adapters/S3CompatibleAdapter.py +62 -8
- polydb/adapters/SQSAdapter.py +121 -44
- polydb/adapters/VercelBlobAdapter.py +196 -0
- polydb/adapters/VercelKVAdapter.py +275 -283
- polydb/adapters/VercelQueueAdapter.py +61 -0
- polydb/audit/AuditStorage.py +1 -1
- polydb/base/NoSQLKVAdapter.py +113 -101
- polydb/base/ObjectStorageAdapter.py +42 -6
- polydb/base/QueueAdapter.py +2 -2
- polydb/base/SharedFilesAdapter.py +2 -2
- polydb/cloudDatabaseFactory.py +200 -0
- polydb/databaseFactory.py +434 -101
- polydb/models.py +63 -1
- polydb/query.py +111 -42
- altcodepro_polydb_python-2.2.2.dist-info/METADATA +0 -379
- altcodepro_polydb_python-2.2.2.dist-info/RECORD +0 -52
- polydb/adapters/PubSubAdapter.py +0 -85
- polydb/factory.py +0 -107
- {altcodepro_polydb_python-2.2.2.dist-info → altcodepro_polydb_python-2.2.4.dist-info}/licenses/LICENSE +0 -0
- {altcodepro_polydb_python-2.2.2.dist-info → altcodepro_polydb_python-2.2.4.dist-info}/top_level.txt +0 -0
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
# src/polydb/adapters/AzureTableStorageAdapter.py
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
2
5
|
import os
|
|
6
|
+
import re
|
|
7
|
+
import json
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
3
10
|
import threading
|
|
11
|
+
from datetime import datetime, date
|
|
12
|
+
from decimal import Decimal
|
|
4
13
|
from typing import Any, Dict, List, Optional
|
|
5
|
-
from
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
from ..base.NoSQLKVAdapter import NoSQLKVAdapter
|
|
6
17
|
from ..json_safe import json_safe
|
|
7
18
|
from ..errors import NoSQLError, ConnectionError
|
|
8
19
|
from ..retry import retry
|
|
@@ -10,174 +21,506 @@ from ..types import JsonDict
|
|
|
10
21
|
from ..models import PartitionConfig
|
|
11
22
|
|
|
12
23
|
|
|
24
|
+
_BYTES_PREFIX = "@@polydb_bytes@@:"
|
|
25
|
+
_JSON_PREFIX = "@@polydb_json@@:"
|
|
26
|
+
_BASE64_RE = re.compile(r"^[A-Za-z0-9+/]*={0,2}$")
|
|
27
|
+
|
|
28
|
+
# ensures model isolation across the same table
|
|
29
|
+
_MODEL_FIELD = "__polydb_model__"
|
|
30
|
+
|
|
31
|
+
|
|
13
32
|
class AzureTableStorageAdapter(NoSQLKVAdapter):
|
|
14
|
-
"""
|
|
15
|
-
|
|
33
|
+
"""
|
|
34
|
+
Azure Table Storage adapter with:
|
|
35
|
+
- Any-type payload support (dict/list/custom objects -> JSON)
|
|
36
|
+
- Key sanitization for PartitionKey/RowKey and property names
|
|
37
|
+
- Query support for scalar fields
|
|
38
|
+
- Blob overflow for entities > 1MB
|
|
39
|
+
- Model isolation using __polydb_model__
|
|
40
|
+
- Always returns id (derived from RowKey if missing)
|
|
41
|
+
"""
|
|
42
|
+
|
|
16
43
|
AZURE_TABLE_MAX_SIZE = 1024 * 1024 # 1MB
|
|
17
|
-
|
|
18
|
-
|
|
44
|
+
_RESERVED = {"PartitionKey", "RowKey", "Timestamp", "etag", "ETag"}
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
partition_config: Optional[PartitionConfig] = None,
|
|
49
|
+
connection_string: str = "",
|
|
50
|
+
table_name="",
|
|
51
|
+
container_name="",
|
|
52
|
+
):
|
|
19
53
|
super().__init__(partition_config)
|
|
20
54
|
self.max_size = self.AZURE_TABLE_MAX_SIZE
|
|
21
|
-
self.connection_string =
|
|
22
|
-
|
|
23
|
-
|
|
55
|
+
self.connection_string = (
|
|
56
|
+
connection_string or os.getenv("AZURE_STORAGE_CONNECTION_STRING") or ""
|
|
57
|
+
)
|
|
58
|
+
self.table_name = table_name or os.getenv("AZURE_TABLE_NAME", "defaulttable") or ""
|
|
59
|
+
self.container_name = container_name or os.getenv("AZURE_CONTAINER_NAME", "overflow") or ""
|
|
60
|
+
|
|
61
|
+
if not self.connection_string:
|
|
62
|
+
raise ConnectionError("AZURE_STORAGE_CONNECTION_STRING must be set")
|
|
63
|
+
|
|
24
64
|
self._client = None
|
|
25
65
|
self._table_client = None
|
|
26
66
|
self._blob_service = None
|
|
27
67
|
self._client_lock = threading.Lock()
|
|
28
68
|
self._initialize_client()
|
|
29
|
-
|
|
69
|
+
|
|
30
70
|
def _initialize_client(self):
|
|
31
71
|
try:
|
|
32
72
|
from azure.data.tables import TableServiceClient
|
|
33
73
|
from azure.storage.blob import BlobServiceClient
|
|
34
|
-
|
|
74
|
+
|
|
35
75
|
with self._client_lock:
|
|
36
76
|
if not self._client:
|
|
37
77
|
self._client = TableServiceClient.from_connection_string(self.connection_string)
|
|
38
78
|
self._table_client = self._client.get_table_client(self.table_name)
|
|
39
|
-
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
self._client.create_table_if_not_exists(self.table_name)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
self._blob_service = BlobServiceClient.from_connection_string(
|
|
86
|
+
self.connection_string
|
|
87
|
+
)
|
|
88
|
+
|
|
40
89
|
try:
|
|
41
90
|
self._blob_service.create_container(self.container_name)
|
|
42
|
-
except:
|
|
43
|
-
pass
|
|
44
|
-
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
45
94
|
self.logger.info("Azure Table Storage initialized with Blob overflow")
|
|
46
95
|
except Exception as e:
|
|
47
96
|
raise ConnectionError(f"Azure Table init failed: {str(e)}")
|
|
48
|
-
|
|
97
|
+
|
|
98
|
+
# -----------------------------
|
|
99
|
+
# Key / property sanitization
|
|
100
|
+
# -----------------------------
|
|
101
|
+
|
|
102
|
+
def _sanitize_pk_rk(self, value: Any) -> str:
|
|
103
|
+
s = str(value)
|
|
104
|
+
s = re.sub(r"[\\/#\?\x00-\x1f\x7f:+ ]", "_", s)
|
|
105
|
+
return s[:1024] if len(s) > 1024 else s
|
|
106
|
+
|
|
107
|
+
def _sanitize_prop_name(self, name: Any) -> str:
|
|
108
|
+
s = str(name)
|
|
109
|
+
s = re.sub(r"[^A-Za-z0-9_]", "_", s)
|
|
110
|
+
if not re.match(r"^[A-Za-z_]", s):
|
|
111
|
+
s = f"f_{s}"
|
|
112
|
+
if s in self._RESERVED:
|
|
113
|
+
s = f"f_{s}"
|
|
114
|
+
return s[:255] if len(s) > 255 else s
|
|
115
|
+
|
|
116
|
+
# -----------------------------
|
|
117
|
+
# Value encoding / decoding
|
|
118
|
+
# -----------------------------
|
|
119
|
+
|
|
120
|
+
def _encode_value(self, v: Any) -> Any:
|
|
121
|
+
if v is None:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
if isinstance(v, bytes):
|
|
125
|
+
return _BYTES_PREFIX + base64.b64encode(v).decode("ascii")
|
|
126
|
+
|
|
127
|
+
if isinstance(v, (dict, list)):
|
|
128
|
+
return _JSON_PREFIX + json.dumps(v, default=json_safe)
|
|
129
|
+
|
|
130
|
+
if isinstance(v, UUID):
|
|
131
|
+
return str(v)
|
|
132
|
+
|
|
133
|
+
if isinstance(v, Decimal):
|
|
134
|
+
return float(v)
|
|
135
|
+
|
|
136
|
+
if isinstance(v, date) and not isinstance(v, datetime):
|
|
137
|
+
return v.isoformat()
|
|
138
|
+
|
|
139
|
+
if isinstance(v, datetime):
|
|
140
|
+
return v
|
|
141
|
+
|
|
142
|
+
if isinstance(v, (str, bool, int, float)):
|
|
143
|
+
return v
|
|
144
|
+
|
|
145
|
+
return _JSON_PREFIX + json.dumps(v, default=json_safe)
|
|
146
|
+
|
|
147
|
+
def _decode_value(self, v: Any) -> Any:
|
|
148
|
+
if isinstance(v, str):
|
|
149
|
+
if v.startswith(_JSON_PREFIX):
|
|
150
|
+
payload = v[len(_JSON_PREFIX) :]
|
|
151
|
+
try:
|
|
152
|
+
return json.loads(payload)
|
|
153
|
+
except Exception:
|
|
154
|
+
return v
|
|
155
|
+
|
|
156
|
+
if v.startswith(_BYTES_PREFIX):
|
|
157
|
+
payload = v[len(_BYTES_PREFIX) :].strip()
|
|
158
|
+
if (len(payload) % 4) == 1:
|
|
159
|
+
return v
|
|
160
|
+
if not _BASE64_RE.match(payload):
|
|
161
|
+
return v
|
|
162
|
+
pad = (-len(payload)) % 4
|
|
163
|
+
if pad:
|
|
164
|
+
payload = payload + ("=" * pad)
|
|
165
|
+
try:
|
|
166
|
+
return base64.b64decode(payload, validate=True)
|
|
167
|
+
except TypeError:
|
|
168
|
+
try:
|
|
169
|
+
return base64.b64decode(payload)
|
|
170
|
+
except Exception:
|
|
171
|
+
return v
|
|
172
|
+
except Exception:
|
|
173
|
+
return v
|
|
174
|
+
return v
|
|
175
|
+
|
|
176
|
+
if isinstance(v, dict) and "__type__" in v:
|
|
177
|
+
t = v.get("__type__")
|
|
178
|
+
if t == "json":
|
|
179
|
+
raw = v.get("value")
|
|
180
|
+
try:
|
|
181
|
+
return json.loads(raw or "null")
|
|
182
|
+
except Exception:
|
|
183
|
+
return raw
|
|
184
|
+
if t == "bytes":
|
|
185
|
+
payload = (v.get("b64") or "").strip()
|
|
186
|
+
if (len(payload) % 4) == 1:
|
|
187
|
+
return b""
|
|
188
|
+
if payload and not _BASE64_RE.match(payload):
|
|
189
|
+
return b""
|
|
190
|
+
pad = (-len(payload)) % 4
|
|
191
|
+
if pad:
|
|
192
|
+
payload = payload + ("=" * pad)
|
|
193
|
+
try:
|
|
194
|
+
return base64.b64decode(payload, validate=True)
|
|
195
|
+
except TypeError:
|
|
196
|
+
try:
|
|
197
|
+
return base64.b64decode(payload)
|
|
198
|
+
except Exception:
|
|
199
|
+
return b""
|
|
200
|
+
except Exception:
|
|
201
|
+
return b""
|
|
202
|
+
if t == "str":
|
|
203
|
+
return v.get("value")
|
|
204
|
+
|
|
205
|
+
return v
|
|
206
|
+
|
|
207
|
+
# -----------------------------
|
|
208
|
+
# Entity pack/unpack
|
|
209
|
+
# -----------------------------
|
|
210
|
+
|
|
211
|
+
def _pack_entity(self, model: type, pk: str, rk: str, data: JsonDict) -> JsonDict:
|
|
212
|
+
entity: JsonDict = {"PartitionKey": pk, "RowKey": rk}
|
|
213
|
+
|
|
214
|
+
# ✅ model isolation
|
|
215
|
+
entity[_MODEL_FIELD] = model.__qualname__
|
|
216
|
+
|
|
217
|
+
keymap: Dict[str, str] = {}
|
|
218
|
+
revmap: Dict[str, str] = {}
|
|
219
|
+
|
|
220
|
+
for orig_key, orig_val in (data or {}).items():
|
|
221
|
+
if str(orig_key) in self._RESERVED or str(orig_key) in ("PartitionKey", "RowKey"):
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
skey = revmap.get(str(orig_key))
|
|
225
|
+
if not skey:
|
|
226
|
+
skey = self._sanitize_prop_name(orig_key)
|
|
227
|
+
base = skey
|
|
228
|
+
i = 1
|
|
229
|
+
while skey in entity:
|
|
230
|
+
skey = f"{base}_{i}"
|
|
231
|
+
i += 1
|
|
232
|
+
revmap[str(orig_key)] = skey
|
|
233
|
+
keymap[skey] = str(orig_key)
|
|
234
|
+
|
|
235
|
+
entity[skey] = self._encode_value(orig_val)
|
|
236
|
+
|
|
237
|
+
entity["__keymap__"] = json.dumps(keymap, default=json_safe)
|
|
238
|
+
return entity
|
|
239
|
+
|
|
240
|
+
def _unpack_entity(self, entity: JsonDict) -> JsonDict:
|
|
241
|
+
if not entity:
|
|
242
|
+
return {}
|
|
243
|
+
|
|
244
|
+
raw = dict(entity)
|
|
245
|
+
raw.pop("etag", None)
|
|
246
|
+
raw.pop("ETag", None)
|
|
247
|
+
raw.pop("Timestamp", None)
|
|
248
|
+
|
|
249
|
+
keymap_str = raw.pop("__keymap__", None)
|
|
250
|
+
keymap: Dict[str, str] = {}
|
|
251
|
+
if keymap_str:
|
|
252
|
+
try:
|
|
253
|
+
keymap = json.loads(keymap_str)
|
|
254
|
+
except Exception:
|
|
255
|
+
keymap = {}
|
|
256
|
+
|
|
257
|
+
out: JsonDict = {}
|
|
258
|
+
|
|
259
|
+
pk = raw.get("PartitionKey")
|
|
260
|
+
rk = raw.get("RowKey")
|
|
261
|
+
if pk is not None:
|
|
262
|
+
out["PartitionKey"] = pk
|
|
263
|
+
if rk is not None:
|
|
264
|
+
out["RowKey"] = rk
|
|
265
|
+
|
|
266
|
+
for k, v in raw.items():
|
|
267
|
+
if k in ("PartitionKey", "RowKey"):
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
# keep internal metadata fields too
|
|
271
|
+
if k.startswith("_") or k in (_MODEL_FIELD,):
|
|
272
|
+
out[k] = v
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
orig_key = keymap.get(k, k)
|
|
276
|
+
out[orig_key] = self._decode_value(v)
|
|
277
|
+
|
|
278
|
+
# ✅ guarantee id for tests & ergonomics
|
|
279
|
+
if "id" not in out and rk is not None:
|
|
280
|
+
out["id"] = rk
|
|
281
|
+
|
|
282
|
+
return out
|
|
283
|
+
|
|
284
|
+
def _entity_size_bytes(self, entity: JsonDict) -> int:
|
|
285
|
+
return len(json.dumps(entity, default=json_safe).encode("utf-8"))
|
|
286
|
+
|
|
287
|
+
def _blob_key(self, pk: str, rk: str, checksum: str) -> str:
|
|
288
|
+
return f"{pk}/{rk}/{checksum}.json"
|
|
289
|
+
|
|
290
|
+
def _blob_upload(self, blob_key: str, data_bytes: bytes):
|
|
291
|
+
if not self._blob_service:
|
|
292
|
+
return
|
|
293
|
+
blob_client = self._blob_service.get_blob_client(self.container_name, blob_key)
|
|
294
|
+
blob_client.upload_blob(data_bytes, overwrite=True)
|
|
295
|
+
|
|
296
|
+
def _blob_download(self, blob_key: str) -> bytes:
|
|
297
|
+
if not self._blob_service:
|
|
298
|
+
raise NoSQLError("Blob service not initialized")
|
|
299
|
+
blob_client = self._blob_service.get_blob_client(self.container_name, blob_key)
|
|
300
|
+
return blob_client.download_blob().readall()
|
|
301
|
+
|
|
302
|
+
def _blob_delete(self, blob_key: str):
|
|
303
|
+
if not self._blob_service:
|
|
304
|
+
return
|
|
305
|
+
blob_client = self._blob_service.get_blob_client(self.container_name, blob_key)
|
|
306
|
+
try:
|
|
307
|
+
blob_client.delete_blob()
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
# -----------------------------
|
|
312
|
+
# Required NoSQLKVAdapter hooks
|
|
313
|
+
# -----------------------------
|
|
314
|
+
|
|
49
315
|
@retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
|
|
50
316
|
def _put_raw(self, model: type, pk: str, rk: str, data: JsonDict) -> JsonDict:
|
|
51
317
|
try:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
'RowKey': rk,
|
|
77
|
-
'_overflow': True,
|
|
78
|
-
'_blob_key': blob_key,
|
|
79
|
-
'_size': data_size,
|
|
80
|
-
'_checksum': blob_id,
|
|
318
|
+
if not self._table_client:
|
|
319
|
+
raise NoSQLError("Azure Table client not initialized")
|
|
320
|
+
|
|
321
|
+
safe_pk = self._sanitize_pk_rk(pk)
|
|
322
|
+
safe_rk = self._sanitize_pk_rk(rk)
|
|
323
|
+
|
|
324
|
+
entity = self._pack_entity(model, safe_pk, safe_rk, data)
|
|
325
|
+
|
|
326
|
+
size = self._entity_size_bytes(entity)
|
|
327
|
+
if size > self.AZURE_TABLE_MAX_SIZE:
|
|
328
|
+
full_payload_bytes = json.dumps(entity, default=json_safe).encode("utf-8")
|
|
329
|
+
checksum = hashlib.md5(full_payload_bytes).hexdigest()
|
|
330
|
+
blob_key = self._blob_key(safe_pk, safe_rk, checksum)
|
|
331
|
+
self._blob_upload(blob_key, full_payload_bytes)
|
|
332
|
+
|
|
333
|
+
reference_entity: JsonDict = {
|
|
334
|
+
"PartitionKey": safe_pk,
|
|
335
|
+
"RowKey": safe_rk,
|
|
336
|
+
_MODEL_FIELD: model.__qualname__,
|
|
337
|
+
"_overflow": True,
|
|
338
|
+
"_blob_key": blob_key,
|
|
339
|
+
"_size": len(full_payload_bytes),
|
|
340
|
+
"_checksum": checksum,
|
|
341
|
+
"__keymap__": entity.get("__keymap__", "{}"),
|
|
81
342
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
343
|
+
|
|
344
|
+
# keep a small index of scalars for basic filtering
|
|
345
|
+
kept = 0
|
|
346
|
+
for k, v in entity.items():
|
|
347
|
+
if k in ("PartitionKey", "RowKey", "__keymap__", _MODEL_FIELD):
|
|
348
|
+
continue
|
|
349
|
+
if k.startswith("_"):
|
|
350
|
+
continue
|
|
351
|
+
if v is None or isinstance(v, (str, bool, int, float, datetime)):
|
|
352
|
+
reference_entity[k] = v
|
|
353
|
+
kept += 1
|
|
354
|
+
if kept >= 50:
|
|
355
|
+
break
|
|
356
|
+
|
|
357
|
+
self._table_client.upsert_entity(reference_entity)
|
|
358
|
+
return {
|
|
359
|
+
"PartitionKey": safe_pk,
|
|
360
|
+
"RowKey": safe_rk,
|
|
361
|
+
"_overflow": True,
|
|
362
|
+
"id": safe_rk,
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
self._table_client.upsert_entity(entity)
|
|
366
|
+
return {"PartitionKey": safe_pk, "RowKey": safe_rk, "id": safe_rk}
|
|
367
|
+
|
|
91
368
|
except Exception as e:
|
|
92
369
|
raise NoSQLError(f"Azure Table put failed: {str(e)}")
|
|
93
|
-
|
|
370
|
+
|
|
94
371
|
@retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
|
|
95
372
|
def _get_raw(self, model: type, pk: str, rk: str) -> Optional[JsonDict]:
|
|
96
373
|
try:
|
|
97
|
-
import json
|
|
98
|
-
import hashlib
|
|
99
|
-
|
|
100
374
|
if not self._table_client:
|
|
101
375
|
return None
|
|
102
|
-
|
|
103
|
-
|
|
376
|
+
|
|
377
|
+
safe_pk = self._sanitize_pk_rk(pk)
|
|
378
|
+
safe_rk = self._sanitize_pk_rk(rk)
|
|
379
|
+
|
|
380
|
+
entity = self._table_client.get_entity(safe_pk, safe_rk)
|
|
104
381
|
entity_dict = dict(entity)
|
|
105
|
-
|
|
106
|
-
#
|
|
107
|
-
if entity_dict.get(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
382
|
+
|
|
383
|
+
# model isolation
|
|
384
|
+
if entity_dict.get(_MODEL_FIELD) != model.__qualname__:
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
if entity_dict.get("_overflow"):
|
|
388
|
+
blob_key = entity_dict.get("_blob_key")
|
|
389
|
+
checksum = entity_dict.get("_checksum")
|
|
390
|
+
if not blob_key:
|
|
391
|
+
raise NoSQLError("Overflow entity missing _blob_key")
|
|
392
|
+
|
|
393
|
+
blob_data = self._blob_download(blob_key)
|
|
394
|
+
actual_checksum = hashlib.md5(blob_data).hexdigest()
|
|
395
|
+
if checksum and actual_checksum != checksum:
|
|
396
|
+
raise NoSQLError(
|
|
397
|
+
f"Checksum mismatch: expected {checksum}, got {actual_checksum}"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
restored = json.loads(blob_data.decode("utf-8"))
|
|
401
|
+
out = self._unpack_entity(restored)
|
|
402
|
+
if "id" not in out:
|
|
403
|
+
out["id"] = safe_rk
|
|
404
|
+
return out
|
|
405
|
+
|
|
406
|
+
out = self._unpack_entity(entity_dict)
|
|
407
|
+
if "id" not in out:
|
|
408
|
+
out["id"] = safe_rk
|
|
409
|
+
return out
|
|
410
|
+
|
|
125
411
|
except Exception as e:
|
|
126
412
|
if "ResourceNotFound" in str(e):
|
|
127
413
|
return None
|
|
128
414
|
raise NoSQLError(f"Azure Table get failed: {str(e)}")
|
|
129
|
-
|
|
415
|
+
|
|
130
416
|
@retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
|
|
131
|
-
def _query_raw(
|
|
417
|
+
def _query_raw(
|
|
418
|
+
self, model: type, filters: Dict[str, Any], limit: Optional[int]
|
|
419
|
+
) -> List[JsonDict]:
|
|
132
420
|
try:
|
|
133
421
|
if not self._table_client:
|
|
134
422
|
return []
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
423
|
+
|
|
424
|
+
# always enforce model filter
|
|
425
|
+
eff_filters = dict(filters or {})
|
|
426
|
+
eff_filters[_MODEL_FIELD] = model.__qualname__
|
|
427
|
+
|
|
428
|
+
parts: List[str] = []
|
|
429
|
+
for orig_k, orig_v in eff_filters.items():
|
|
430
|
+
if orig_v is None:
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
if orig_k in ("partition_key", "PartitionKey"):
|
|
434
|
+
sk = "PartitionKey"
|
|
435
|
+
elif orig_k in ("row_key", "RowKey"):
|
|
436
|
+
sk = "RowKey"
|
|
437
|
+
else:
|
|
438
|
+
sk = (
|
|
439
|
+
self._sanitize_prop_name(orig_k) if orig_k != _MODEL_FIELD else _MODEL_FIELD
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
ev = self._encode_value(orig_v)
|
|
443
|
+
|
|
444
|
+
if ev is None:
|
|
445
|
+
parts.append(f"{sk} eq null")
|
|
446
|
+
elif isinstance(ev, bool):
|
|
447
|
+
parts.append(f"{sk} eq {str(ev).lower()}")
|
|
448
|
+
elif isinstance(ev, (int, float)):
|
|
449
|
+
parts.append(f"{sk} eq {ev}")
|
|
450
|
+
elif isinstance(ev, datetime):
|
|
451
|
+
iso = ev.isoformat()
|
|
452
|
+
if not iso.endswith("Z"):
|
|
453
|
+
iso = iso + "Z"
|
|
454
|
+
parts.append(f"{sk} eq datetime'{iso}'")
|
|
455
|
+
else:
|
|
456
|
+
sval = str(ev).replace("'", "''")
|
|
457
|
+
parts.append(f"{sk} eq '{sval}'")
|
|
458
|
+
|
|
459
|
+
query_filter = " and ".join(parts) if parts else None
|
|
460
|
+
|
|
461
|
+
entities = self._table_client.query_entities(query_filter=query_filter) # type: ignore
|
|
462
|
+
|
|
463
|
+
results: List[JsonDict] = []
|
|
464
|
+
count = 0
|
|
465
|
+
for ent in entities:
|
|
466
|
+
ent_dict = dict(ent)
|
|
467
|
+
|
|
468
|
+
# defensive: enforce model even if query_filter omitted
|
|
469
|
+
if ent_dict.get(_MODEL_FIELD) != model.__qualname__:
|
|
470
|
+
continue
|
|
471
|
+
|
|
472
|
+
if ent_dict.get("_overflow"):
|
|
473
|
+
blob_key = ent_dict.get("_blob_key")
|
|
474
|
+
if blob_key:
|
|
475
|
+
blob_data = self._blob_download(blob_key)
|
|
476
|
+
restored = json.loads(blob_data.decode("utf-8"))
|
|
477
|
+
out = self._unpack_entity(restored)
|
|
144
478
|
else:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
479
|
+
out = self._unpack_entity(ent_dict)
|
|
480
|
+
else:
|
|
481
|
+
out = self._unpack_entity(ent_dict)
|
|
482
|
+
|
|
483
|
+
# guarantee id
|
|
484
|
+
if "id" not in out and out.get("RowKey") is not None:
|
|
485
|
+
out["id"] = out["RowKey"]
|
|
486
|
+
|
|
487
|
+
results.append(out)
|
|
488
|
+
|
|
489
|
+
count += 1
|
|
490
|
+
if limit and count >= limit:
|
|
491
|
+
break
|
|
492
|
+
|
|
493
|
+
return results
|
|
494
|
+
|
|
155
495
|
except Exception as e:
|
|
156
496
|
raise NoSQLError(f"Azure Table query failed: {str(e)}")
|
|
157
|
-
|
|
497
|
+
|
|
158
498
|
@retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
|
|
159
499
|
def _delete_raw(self, model: type, pk: str, rk: str, etag: Optional[str]) -> JsonDict:
|
|
160
500
|
try:
|
|
161
501
|
if not self._table_client:
|
|
162
|
-
return {
|
|
163
|
-
|
|
164
|
-
|
|
502
|
+
return {"deleted": False}
|
|
503
|
+
|
|
504
|
+
safe_pk = self._sanitize_pk_rk(pk)
|
|
505
|
+
safe_rk = self._sanitize_pk_rk(rk)
|
|
506
|
+
|
|
507
|
+
# read to check model + overflow
|
|
165
508
|
try:
|
|
166
|
-
entity = self._table_client.get_entity(
|
|
509
|
+
entity = self._table_client.get_entity(safe_pk, safe_rk)
|
|
167
510
|
entity_dict = dict(entity)
|
|
168
|
-
|
|
169
|
-
if entity_dict.get(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
self._table_client.delete_entity(
|
|
180
|
-
return {
|
|
511
|
+
|
|
512
|
+
if entity_dict.get(_MODEL_FIELD) != model.__qualname__:
|
|
513
|
+
return {"deleted": False}
|
|
514
|
+
|
|
515
|
+
if entity_dict.get("_overflow"):
|
|
516
|
+
blob_key = entity_dict.get("_blob_key")
|
|
517
|
+
if blob_key:
|
|
518
|
+
self._blob_delete(blob_key)
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
self._table_client.delete_entity(safe_pk, safe_rk, etag=etag)
|
|
523
|
+
return {"deleted": True, "PartitionKey": safe_pk, "RowKey": safe_rk, "id": safe_rk}
|
|
524
|
+
|
|
181
525
|
except Exception as e:
|
|
182
526
|
raise NoSQLError(f"Azure Table delete failed: {str(e)}")
|
|
183
|
-
|