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,216 +1,503 @@
|
|
|
1
1
|
# src/polydb/adapters/DynamoDBAdapter.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
2
7
|
import os
|
|
3
8
|
import threading
|
|
4
|
-
from
|
|
5
|
-
import
|
|
6
|
-
from boto3.dynamodb.conditions import Key, Attr
|
|
7
|
-
|
|
8
|
-
from polydb.base.NoSQLKVAdapter import NoSQLKVAdapter
|
|
9
|
+
from polydb.errors import DatabaseError
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
9
11
|
|
|
10
|
-
from
|
|
12
|
+
from boto3.dynamodb.conditions import Attr, Key
|
|
13
|
+
from botocore.exceptions import ClientError
|
|
14
|
+
from boto3.session import Session
|
|
15
|
+
from ..base.NoSQLKVAdapter import NoSQLKVAdapter
|
|
16
|
+
from ..errors import ConnectionError, NoSQLError
|
|
17
|
+
from ..json_safe import json_safe
|
|
18
|
+
from ..models import PartitionConfig
|
|
11
19
|
from ..retry import retry
|
|
12
20
|
from ..types import JsonDict
|
|
13
|
-
from ..models import PartitionConfig
|
|
14
21
|
|
|
15
22
|
|
|
16
23
|
class DynamoDBAdapter(NoSQLKVAdapter):
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
"""
|
|
25
|
+
Production-grade DynamoDB adapter with optional S3 overflow.
|
|
26
|
+
|
|
27
|
+
Goals (matches your adapter test style)
|
|
28
|
+
- stored row keeps "id" == pk
|
|
29
|
+
- query({"id": ...}) works
|
|
30
|
+
- patch() merges (handled by NoSQLKVAdapter.patch)
|
|
31
|
+
- delete() returns {"id": <pk>} and raises sqlite3.DatabaseError on missing
|
|
32
|
+
- query_page() uses DynamoDB LastEvaluatedKey token (stable)
|
|
33
|
+
- LocalStack support (endpoint_url) + auto create table/bucket in test/dev
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
DYNAMODB_MAX_SIZE = 400 * 1024 # 400KB DynamoDB item limit
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
partition_config: Optional[PartitionConfig] = None,
|
|
41
|
+
table_name: Optional[str] = None,
|
|
42
|
+
bucket_name: Optional[str] = None,
|
|
43
|
+
region: Optional[str] = None,
|
|
44
|
+
endpoint_url: Optional[str] = None,
|
|
45
|
+
):
|
|
22
46
|
super().__init__(partition_config)
|
|
47
|
+
|
|
23
48
|
self.max_size = self.DYNAMODB_MAX_SIZE
|
|
24
|
-
self.
|
|
25
|
-
self.bucket_name = os.getenv("S3_OVERFLOW_BUCKET", "dynamodb-overflow")
|
|
26
|
-
|
|
27
|
-
self.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
49
|
+
self.default_table = table_name or os.getenv("DYNAMODB_TABLE_NAME", "polydb")
|
|
50
|
+
self.bucket_name = bucket_name or os.getenv("S3_OVERFLOW_BUCKET", "dynamodb-overflow")
|
|
51
|
+
|
|
52
|
+
self.region = (
|
|
53
|
+
region or os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION") or "us-east-1"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# LocalStack support:
|
|
57
|
+
# - Many setups export AWS_ENDPOINT_URL=http://localhost:4566
|
|
58
|
+
# - Some export LOCALSTACK_ENDPOINT_URL, or infer from LOCALSTACK_HOST
|
|
59
|
+
self.endpoint_url = (
|
|
60
|
+
endpoint_url
|
|
61
|
+
or os.getenv("AWS_ENDPOINT_URL")
|
|
62
|
+
or os.getenv("LOCALSTACK_ENDPOINT_URL")
|
|
63
|
+
or (
|
|
64
|
+
f"http://{os.getenv('LOCALSTACK_HOST')}:4566"
|
|
65
|
+
if os.getenv("LOCALSTACK_HOST")
|
|
66
|
+
else None
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self._dynamodb: Any = None
|
|
71
|
+
self._s3 = None
|
|
72
|
+
self._lock = threading.Lock()
|
|
73
|
+
|
|
74
|
+
self._initialize_clients()
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------
|
|
77
|
+
# Init / Helpers
|
|
78
|
+
# ---------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def _initialize_clients(self) -> None:
|
|
32
81
|
try:
|
|
33
|
-
with self.
|
|
34
|
-
if
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
82
|
+
with self._lock:
|
|
83
|
+
if self._dynamodb and self._s3:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
session = Session(region_name=self.region)
|
|
87
|
+
|
|
88
|
+
# LocalStack often needs dummy keys but boto3 doesn’t require them explicitly
|
|
89
|
+
self._dynamodb = session.resource(
|
|
90
|
+
"dynamodb",
|
|
91
|
+
endpoint_url=self.endpoint_url,
|
|
92
|
+
)
|
|
93
|
+
self._s3 = session.client(
|
|
94
|
+
"s3",
|
|
95
|
+
endpoint_url=self.endpoint_url,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
self.logger.info(
|
|
99
|
+
f"Initialized DynamoDB/S3 clients (region={self.region}, endpoint={self.endpoint_url or 'aws'})"
|
|
100
|
+
)
|
|
45
101
|
except Exception as e:
|
|
46
|
-
raise ConnectionError(f"DynamoDB init failed: {
|
|
47
|
-
|
|
102
|
+
raise ConnectionError(f"DynamoDB init failed: {e}")
|
|
103
|
+
|
|
104
|
+
def _table_name(self, model: type) -> str:
|
|
105
|
+
meta = getattr(model, "__polydb__", {}) or {}
|
|
106
|
+
return meta.get("table") or meta.get("collection") or model.__name__.lower()
|
|
107
|
+
|
|
48
108
|
def _get_table(self, model: type):
|
|
49
|
-
if not self.
|
|
50
|
-
self.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
table_name =
|
|
54
|
-
|
|
55
|
-
|
|
109
|
+
if not self._dynamodb:
|
|
110
|
+
self._initialize_clients()
|
|
111
|
+
if not self._dynamodb:
|
|
112
|
+
raise ConnectionError("DynamoDB resource not initialized")
|
|
113
|
+
table_name = self._table_name(model)
|
|
114
|
+
table = self._dynamodb.Table(table_name)
|
|
115
|
+
self._ensure_table_exists(table_name)
|
|
116
|
+
return table
|
|
117
|
+
|
|
118
|
+
def _ensure_table_exists(self, table_name: str) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Ensure PK/SK table exists. Safe in prod (no-op if exists).
|
|
121
|
+
Required for LocalStack integration tests.
|
|
122
|
+
"""
|
|
123
|
+
if not self._dynamodb:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
self._dynamodb.meta.client.describe_table(TableName=table_name)
|
|
128
|
+
return
|
|
129
|
+
except ClientError as e:
|
|
130
|
+
code = e.response.get("Error", {}).get("Code")
|
|
131
|
+
if code not in ("ResourceNotFoundException", "ValidationException"):
|
|
132
|
+
# ValidationException can happen on LocalStack when table doesn't exist yet
|
|
133
|
+
# but describe throws something odd; fall through to create attempt.
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
self._dynamodb.meta.client.create_table(
|
|
138
|
+
TableName=table_name,
|
|
139
|
+
KeySchema=[
|
|
140
|
+
{"AttributeName": "PK", "KeyType": "HASH"},
|
|
141
|
+
{"AttributeName": "SK", "KeyType": "RANGE"},
|
|
142
|
+
],
|
|
143
|
+
AttributeDefinitions=[
|
|
144
|
+
{"AttributeName": "PK", "AttributeType": "S"},
|
|
145
|
+
{"AttributeName": "SK", "AttributeType": "S"},
|
|
146
|
+
],
|
|
147
|
+
BillingMode="PAY_PER_REQUEST",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Wait until active (works in AWS + LocalStack)
|
|
151
|
+
self._dynamodb.meta.client.get_waiter("table_exists").wait(TableName=table_name)
|
|
152
|
+
self.logger.info(f"Created DynamoDB table: {table_name}")
|
|
153
|
+
except ClientError as e:
|
|
154
|
+
# If another process created it meanwhile
|
|
155
|
+
if e.response.get("Error", {}).get("Code") in ("ResourceInUseException",):
|
|
156
|
+
return
|
|
157
|
+
raise
|
|
158
|
+
|
|
159
|
+
def _ensure_bucket_exists(self) -> None:
|
|
160
|
+
"""Ensure overflow bucket exists (safe no-op in prod; needed in LocalStack)."""
|
|
161
|
+
if not self._s3:
|
|
162
|
+
return
|
|
163
|
+
try:
|
|
164
|
+
self._s3.head_bucket(Bucket=self.bucket_name)
|
|
165
|
+
return
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
# us-east-1 does not require LocationConstraint; other regions do.
|
|
171
|
+
if self.region == "us-east-1":
|
|
172
|
+
self._s3.create_bucket(Bucket=self.bucket_name)
|
|
173
|
+
else:
|
|
174
|
+
self._s3.create_bucket(
|
|
175
|
+
Bucket=self.bucket_name,
|
|
176
|
+
CreateBucketConfiguration={"LocationConstraint": self.region},
|
|
177
|
+
)
|
|
178
|
+
self.logger.info(f"Created S3 overflow bucket: {self.bucket_name}")
|
|
179
|
+
except Exception:
|
|
180
|
+
# If already exists or emulator differences
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
def _encode_token(self, lek: Dict[str, Any]) -> str:
|
|
184
|
+
raw = json.dumps(lek, default=json_safe).encode("utf-8")
|
|
185
|
+
return base64.urlsafe_b64encode(raw).decode("utf-8")
|
|
186
|
+
|
|
187
|
+
def _decode_token(self, tok: str) -> Dict[str, Any]:
|
|
188
|
+
raw = base64.urlsafe_b64decode(tok.encode("utf-8"))
|
|
189
|
+
return json.loads(raw.decode("utf-8"))
|
|
190
|
+
|
|
191
|
+
def _blob_key(self, model: type, pk: str, rk: str, checksum: str) -> str:
|
|
192
|
+
return f"overflow/{self._table_name(model)}/{pk}/{rk}/{checksum}.json"
|
|
193
|
+
|
|
194
|
+
def _maybe_overflow_to_s3(
|
|
195
|
+
self, model: type, pk: str, rk: str, payload: JsonDict
|
|
196
|
+
) -> Optional[JsonDict]:
|
|
197
|
+
data_bytes = json.dumps(payload, default=json_safe).encode("utf-8")
|
|
198
|
+
if len(data_bytes) <= self.DYNAMODB_MAX_SIZE:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
if not self._s3:
|
|
202
|
+
raise NoSQLError("DynamoDB item exceeds 400KB and S3 client is unavailable")
|
|
203
|
+
|
|
204
|
+
self._ensure_bucket_exists()
|
|
205
|
+
|
|
206
|
+
checksum = hashlib.md5(data_bytes).hexdigest()
|
|
207
|
+
blob_key = self._blob_key(model, pk, rk, checksum)
|
|
208
|
+
|
|
209
|
+
self._s3.put_object(Bucket=self.bucket_name, Key=blob_key, Body=data_bytes)
|
|
210
|
+
|
|
211
|
+
ref: JsonDict = {
|
|
212
|
+
"PK": pk,
|
|
213
|
+
"SK": rk,
|
|
214
|
+
"id": pk, # ✅ for tests and querying
|
|
215
|
+
"_pk": pk,
|
|
216
|
+
"_rk": rk,
|
|
217
|
+
"_overflow": True,
|
|
218
|
+
"_blob_key": blob_key,
|
|
219
|
+
"_size": len(data_bytes),
|
|
220
|
+
"_checksum": checksum,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# Best-effort keep scalar fields for filtering
|
|
224
|
+
kept = 0
|
|
225
|
+
for k, v in payload.items():
|
|
226
|
+
if k in ref:
|
|
227
|
+
continue
|
|
228
|
+
if isinstance(v, (str, int, float, bool)) or v is None:
|
|
229
|
+
ref[k] = v
|
|
230
|
+
kept += 1
|
|
231
|
+
if kept >= 50:
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
self.logger.info(f"Stored DynamoDB overflow to S3: {blob_key} ({len(data_bytes)} bytes)")
|
|
235
|
+
return ref
|
|
236
|
+
|
|
237
|
+
def _resolve_overflow(self, item: JsonDict) -> JsonDict:
|
|
238
|
+
if not item.get("_overflow"):
|
|
239
|
+
return item
|
|
240
|
+
|
|
241
|
+
if not self._s3:
|
|
242
|
+
raise NoSQLError("Overflow item present but S3 client unavailable")
|
|
243
|
+
|
|
244
|
+
blob_key = item.get("_blob_key")
|
|
245
|
+
checksum = item.get("_checksum")
|
|
246
|
+
if not blob_key:
|
|
247
|
+
raise NoSQLError("Overflow item missing _blob_key")
|
|
248
|
+
|
|
249
|
+
resp = self._s3.get_object(Bucket=self.bucket_name, Key=blob_key)
|
|
250
|
+
blob_data = resp["Body"].read()
|
|
251
|
+
|
|
252
|
+
actual = hashlib.md5(blob_data).hexdigest()
|
|
253
|
+
if checksum and actual != checksum:
|
|
254
|
+
raise NoSQLError(f"Checksum mismatch: expected {checksum}, got {actual}")
|
|
255
|
+
|
|
256
|
+
restored = json.loads(blob_data.decode("utf-8"))
|
|
257
|
+
return restored
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------
|
|
260
|
+
# Required NoSQLKVAdapter hooks
|
|
261
|
+
# ---------------------------------------------------------------------
|
|
262
|
+
|
|
56
263
|
@retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
|
|
57
264
|
def _put_raw(self, model: type, pk: str, rk: str, data: JsonDict) -> JsonDict:
|
|
58
265
|
try:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
self._s3_client.put_object(
|
|
77
|
-
Bucket=self.bucket_name,
|
|
78
|
-
Key=blob_key,
|
|
79
|
-
Body=data_bytes
|
|
80
|
-
)
|
|
81
|
-
self.logger.info(f"Stored overflow to S3: {blob_key} ({data_size} bytes)")
|
|
82
|
-
|
|
83
|
-
# Store reference in DynamoDB
|
|
84
|
-
reference_data = {
|
|
85
|
-
'PK': pk,
|
|
86
|
-
'SK': rk,
|
|
87
|
-
'_overflow': True,
|
|
88
|
-
'_blob_key': blob_key,
|
|
89
|
-
'_size': data_size,
|
|
90
|
-
'_checksum': blob_id,
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
table = self._get_table(model)
|
|
94
|
-
table.put_item(Item=reference_data)
|
|
95
|
-
else:
|
|
96
|
-
# Store directly in DynamoDB
|
|
97
|
-
table = self._get_table(model)
|
|
98
|
-
table.put_item(Item=data_copy)
|
|
99
|
-
|
|
100
|
-
return {'PK': pk, 'SK': rk}
|
|
266
|
+
table = self._get_table(model)
|
|
267
|
+
|
|
268
|
+
payload: JsonDict = dict(data or {})
|
|
269
|
+
payload["PK"] = pk
|
|
270
|
+
payload["SK"] = rk
|
|
271
|
+
|
|
272
|
+
# Ensure "id" field exists for tests/querying
|
|
273
|
+
payload.setdefault("id", pk)
|
|
274
|
+
payload.setdefault("_pk", pk)
|
|
275
|
+
payload.setdefault("_rk", rk)
|
|
276
|
+
|
|
277
|
+
ref = self._maybe_overflow_to_s3(model, pk, rk, payload)
|
|
278
|
+
table.put_item(Item=ref if ref is not None else payload)
|
|
279
|
+
|
|
280
|
+
# ✅ match pattern: put returns {"id": pk}
|
|
281
|
+
return {"id": pk}
|
|
282
|
+
|
|
101
283
|
except Exception as e:
|
|
102
|
-
raise NoSQLError(f"DynamoDB put failed: {
|
|
103
|
-
|
|
284
|
+
raise NoSQLError(f"DynamoDB put failed: {e}")
|
|
285
|
+
|
|
104
286
|
@retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
|
|
105
287
|
def _get_raw(self, model: type, pk: str, rk: str) -> Optional[JsonDict]:
|
|
106
288
|
try:
|
|
107
|
-
import json
|
|
108
|
-
import hashlib
|
|
109
|
-
|
|
110
289
|
table = self._get_table(model)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
290
|
+
|
|
291
|
+
resp = table.get_item(Key={"PK": pk, "SK": rk})
|
|
292
|
+
item = resp.get("Item")
|
|
293
|
+
if not item:
|
|
114
294
|
return None
|
|
115
|
-
|
|
116
|
-
item
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if item.get('_overflow'):
|
|
120
|
-
blob_key = item.get('_blob_key')
|
|
121
|
-
checksum = item.get('_checksum')
|
|
122
|
-
|
|
123
|
-
if blob_key and self._s3_client:
|
|
124
|
-
s3_response = self._s3_client.get_object(
|
|
125
|
-
Bucket=self.bucket_name,
|
|
126
|
-
Key=blob_key
|
|
127
|
-
)
|
|
128
|
-
blob_data = s3_response['Body'].read()
|
|
129
|
-
|
|
130
|
-
# Verify checksum
|
|
131
|
-
actual_checksum = hashlib.md5(blob_data).hexdigest()
|
|
132
|
-
if actual_checksum != checksum:
|
|
133
|
-
raise NoSQLError(f"Checksum mismatch: expected {checksum}, got {actual_checksum}")
|
|
134
|
-
|
|
135
|
-
retrieved = json.loads(blob_data.decode())
|
|
136
|
-
self.logger.debug(f"Retrieved overflow from S3: {blob_key}")
|
|
137
|
-
return retrieved
|
|
138
|
-
|
|
139
|
-
return item
|
|
295
|
+
|
|
296
|
+
item.setdefault("id", pk)
|
|
297
|
+
return self._resolve_overflow(item)
|
|
298
|
+
|
|
140
299
|
except Exception as e:
|
|
141
|
-
raise NoSQLError(f"DynamoDB get failed: {
|
|
142
|
-
|
|
300
|
+
raise NoSQLError(f"DynamoDB get failed: {e}")
|
|
301
|
+
|
|
143
302
|
@retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
|
|
144
|
-
def _query_raw(
|
|
303
|
+
def _query_raw(
|
|
304
|
+
self, model: type, filters: Dict[str, Any], limit: Optional[int]
|
|
305
|
+
) -> List[JsonDict]:
|
|
306
|
+
"""
|
|
307
|
+
Prefer Query when PK can be derived. Otherwise Scan.
|
|
308
|
+
Supports filters like {"id": "..."} and arbitrary Attr equality.
|
|
309
|
+
"""
|
|
145
310
|
try:
|
|
146
311
|
table = self._get_table(model)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
312
|
+
filters = filters or {}
|
|
313
|
+
|
|
314
|
+
# Treat 'id' as PK by convention (since we store PK=pk=id in NoSQLKVAdapter by default)
|
|
315
|
+
pk_value = filters.get("PK") or filters.get("partition_key") or filters.get("id")
|
|
316
|
+
|
|
317
|
+
if pk_value is not None:
|
|
318
|
+
key_cond = Key("PK").eq(str(pk_value))
|
|
319
|
+
if "SK" in filters:
|
|
320
|
+
key_cond = key_cond & Key("SK").eq(str(filters["SK"]))
|
|
321
|
+
|
|
322
|
+
kwargs: Dict[str, Any] = {"KeyConditionExpression": key_cond}
|
|
323
|
+
|
|
158
324
|
# Other filters as FilterExpression
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
325
|
+
other = {
|
|
326
|
+
k: v for k, v in filters.items() if k not in ("PK", "SK", "partition_key", "id")
|
|
327
|
+
}
|
|
328
|
+
if other:
|
|
329
|
+
expr = None
|
|
330
|
+
for k, v in other.items():
|
|
331
|
+
part = Attr(k).eq(v)
|
|
332
|
+
expr = part if expr is None else (expr & part)
|
|
333
|
+
kwargs["FilterExpression"] = expr
|
|
334
|
+
|
|
167
335
|
if limit:
|
|
168
|
-
kwargs[
|
|
169
|
-
|
|
170
|
-
|
|
336
|
+
kwargs["Limit"] = limit
|
|
337
|
+
|
|
338
|
+
resp = table.query(**kwargs)
|
|
339
|
+
items = resp.get("Items", [])
|
|
171
340
|
else:
|
|
172
|
-
# Scan
|
|
341
|
+
# Scan
|
|
173
342
|
kwargs = {}
|
|
174
343
|
if filters:
|
|
175
|
-
|
|
344
|
+
expr = None
|
|
176
345
|
for k, v in filters.items():
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
kwargs[
|
|
180
|
-
|
|
346
|
+
part = Attr(k).eq(v)
|
|
347
|
+
expr = part if expr is None else (expr & part)
|
|
348
|
+
kwargs["FilterExpression"] = expr
|
|
181
349
|
if limit:
|
|
182
|
-
kwargs[
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
350
|
+
kwargs["Limit"] = limit
|
|
351
|
+
|
|
352
|
+
resp = table.scan(**kwargs)
|
|
353
|
+
items = resp.get("Items", [])
|
|
354
|
+
|
|
355
|
+
out: List[JsonDict] = []
|
|
356
|
+
for it in items:
|
|
357
|
+
it.setdefault("id", it.get("_pk") or it.get("PK"))
|
|
358
|
+
out.append(self._resolve_overflow(it))
|
|
359
|
+
return out
|
|
360
|
+
|
|
187
361
|
except Exception as e:
|
|
188
|
-
raise NoSQLError(f"DynamoDB query failed: {
|
|
189
|
-
|
|
362
|
+
raise NoSQLError(f"DynamoDB query failed: {e}")
|
|
363
|
+
|
|
190
364
|
@retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
|
|
191
365
|
def _delete_raw(self, model: type, pk: str, rk: str, etag: Optional[str]) -> JsonDict:
|
|
366
|
+
"""
|
|
367
|
+
- if missing => raise sqlite3.DatabaseError (matches Firestore style tests)
|
|
368
|
+
- if overflow => delete S3 object best-effort
|
|
369
|
+
- return {"id": pk}
|
|
370
|
+
"""
|
|
192
371
|
try:
|
|
193
372
|
table = self._get_table(model)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
373
|
+
|
|
374
|
+
resp = table.get_item(Key={"PK": pk, "SK": rk})
|
|
375
|
+
item = resp.get("Item")
|
|
376
|
+
if not item:
|
|
377
|
+
raise DatabaseError(f"Item {pk}/{rk} does not exist")
|
|
378
|
+
|
|
379
|
+
if item.get("_overflow") and self._s3:
|
|
380
|
+
blob_key = item.get("_blob_key")
|
|
381
|
+
if blob_key:
|
|
382
|
+
try:
|
|
383
|
+
self._s3.delete_object(Bucket=self.bucket_name, Key=blob_key)
|
|
384
|
+
except Exception:
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
table.delete_item(Key={"PK": pk, "SK": rk})
|
|
388
|
+
return {"id": pk}
|
|
389
|
+
|
|
390
|
+
except DatabaseError:
|
|
391
|
+
raise
|
|
392
|
+
except Exception as e:
|
|
393
|
+
raise NoSQLError(f"DynamoDB delete failed: {e}")
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------------
|
|
396
|
+
# Provider-specific pagination (stable, not offset-based)
|
|
397
|
+
# ---------------------------------------------------------------------
|
|
398
|
+
def query_page(
|
|
399
|
+
self,
|
|
400
|
+
model: type,
|
|
401
|
+
query: Dict[str, Any],
|
|
402
|
+
page_size: int,
|
|
403
|
+
continuation_token: Optional[str] = None,
|
|
404
|
+
) -> Tuple[List[JsonDict], Optional[str]]:
|
|
405
|
+
"""
|
|
406
|
+
DynamoDB pagination.
|
|
407
|
+
|
|
408
|
+
Behaviour:
|
|
409
|
+
- Uses Query when PK/id is available
|
|
410
|
+
- Uses Scan otherwise
|
|
411
|
+
- Handles FilterExpression correctly when scanning by collecting
|
|
412
|
+
results until page_size items are returned
|
|
413
|
+
- continuation_token = base64(json(LastEvaluatedKey))
|
|
414
|
+
"""
|
|
415
|
+
try:
|
|
416
|
+
table = self._get_table(model)
|
|
417
|
+
query = query or {}
|
|
418
|
+
|
|
419
|
+
start_key = self._decode_token(continuation_token) if continuation_token else None
|
|
420
|
+
|
|
421
|
+
pk_value = query.get("PK") or query.get("partition_key") or query.get("id")
|
|
422
|
+
|
|
423
|
+
items: List[JsonDict] = []
|
|
424
|
+
last_key = start_key
|
|
425
|
+
|
|
426
|
+
if pk_value is not None:
|
|
427
|
+
|
|
428
|
+
key_cond = Key("PK").eq(str(pk_value))
|
|
429
|
+
|
|
430
|
+
if "SK" in query:
|
|
431
|
+
key_cond = key_cond & Key("SK").eq(str(query["SK"]))
|
|
432
|
+
|
|
433
|
+
kwargs: Dict[str, Any] = {
|
|
434
|
+
"KeyConditionExpression": key_cond,
|
|
435
|
+
"Limit": page_size,
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if last_key:
|
|
439
|
+
kwargs["ExclusiveStartKey"] = last_key
|
|
440
|
+
|
|
441
|
+
other_filters = {
|
|
442
|
+
k: v for k, v in query.items() if k not in ("PK", "SK", "partition_key", "id")
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if other_filters:
|
|
446
|
+
expr = None
|
|
447
|
+
for k, v in other_filters.items():
|
|
448
|
+
part = Attr(k).eq(v)
|
|
449
|
+
expr = part if expr is None else expr & part
|
|
450
|
+
kwargs["FilterExpression"] = expr
|
|
451
|
+
|
|
452
|
+
resp = table.query(**kwargs)
|
|
453
|
+
items = resp.get("Items", [])
|
|
454
|
+
last_key = resp.get("LastEvaluatedKey")
|
|
455
|
+
|
|
456
|
+
# ------------------------------------------------------------------
|
|
457
|
+
# SCAN path (collect until page_size)
|
|
458
|
+
# ------------------------------------------------------------------
|
|
459
|
+
else:
|
|
460
|
+
|
|
461
|
+
filter_expr = None
|
|
462
|
+
if query:
|
|
463
|
+
for k, v in query.items():
|
|
464
|
+
part = Attr(k).eq(v)
|
|
465
|
+
filter_expr = part if filter_expr is None else filter_expr & part
|
|
466
|
+
|
|
467
|
+
while len(items) < page_size:
|
|
468
|
+
|
|
469
|
+
kwargs: Dict[str, Any] = {"Limit": page_size}
|
|
470
|
+
|
|
471
|
+
if last_key:
|
|
472
|
+
kwargs["ExclusiveStartKey"] = last_key
|
|
473
|
+
|
|
474
|
+
if filter_expr is not None:
|
|
475
|
+
kwargs["FilterExpression"] = filter_expr
|
|
476
|
+
|
|
477
|
+
resp = table.scan(**kwargs)
|
|
478
|
+
|
|
479
|
+
batch = resp.get("Items", [])
|
|
480
|
+
items.extend(batch)
|
|
481
|
+
|
|
482
|
+
last_key = resp.get("LastEvaluatedKey")
|
|
483
|
+
|
|
484
|
+
if not last_key:
|
|
485
|
+
break
|
|
486
|
+
|
|
487
|
+
items = items[:page_size]
|
|
488
|
+
|
|
489
|
+
# ------------------------------------------------------------------
|
|
490
|
+
# Normalize output
|
|
491
|
+
# ------------------------------------------------------------------
|
|
492
|
+
out: List[JsonDict] = []
|
|
493
|
+
|
|
494
|
+
for it in items:
|
|
495
|
+
it.setdefault("id", it.get("_pk") or it.get("PK"))
|
|
496
|
+
out.append(self._resolve_overflow(it))
|
|
497
|
+
|
|
498
|
+
next_tok = self._encode_token(last_key) if last_key else None
|
|
499
|
+
|
|
500
|
+
return out, next_tok
|
|
501
|
+
|
|
215
502
|
except Exception as e:
|
|
216
|
-
raise NoSQLError(f"DynamoDB
|
|
503
|
+
raise NoSQLError(f"DynamoDB query_page failed: {e}")
|