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.
Files changed (38) hide show
  1. altcodepro_polydb_python-2.2.4.dist-info/METADATA +489 -0
  2. altcodepro_polydb_python-2.2.4.dist-info/RECORD +57 -0
  3. {altcodepro_polydb_python-2.2.2.dist-info → altcodepro_polydb_python-2.2.4.dist-info}/WHEEL +1 -1
  4. polydb/__init__.py +2 -2
  5. polydb/adapters/AzureBlobStorageAdapter.py +146 -41
  6. polydb/adapters/AzureFileStorageAdapter.py +148 -43
  7. polydb/adapters/AzureQueueAdapter.py +96 -34
  8. polydb/adapters/AzureTableStorageAdapter.py +462 -119
  9. polydb/adapters/BlockchainBlobAdapter.py +111 -0
  10. polydb/adapters/BlockchainKVAdapter.py +152 -0
  11. polydb/adapters/BlockchainQueueAdapter.py +116 -0
  12. polydb/adapters/DynamoDBAdapter.py +463 -176
  13. polydb/adapters/FirestoreAdapter.py +320 -148
  14. polydb/adapters/GCPPubSubAdapter.py +217 -0
  15. polydb/adapters/GCPStorageAdapter.py +184 -39
  16. polydb/adapters/MongoDBAdapter.py +159 -39
  17. polydb/adapters/PostgreSQLAdapter.py +285 -83
  18. polydb/adapters/S3Adapter.py +172 -35
  19. polydb/adapters/S3CompatibleAdapter.py +62 -8
  20. polydb/adapters/SQSAdapter.py +121 -44
  21. polydb/adapters/VercelBlobAdapter.py +196 -0
  22. polydb/adapters/VercelKVAdapter.py +275 -283
  23. polydb/adapters/VercelQueueAdapter.py +61 -0
  24. polydb/audit/AuditStorage.py +1 -1
  25. polydb/base/NoSQLKVAdapter.py +113 -101
  26. polydb/base/ObjectStorageAdapter.py +42 -6
  27. polydb/base/QueueAdapter.py +2 -2
  28. polydb/base/SharedFilesAdapter.py +2 -2
  29. polydb/cloudDatabaseFactory.py +200 -0
  30. polydb/databaseFactory.py +434 -101
  31. polydb/models.py +63 -1
  32. polydb/query.py +111 -42
  33. altcodepro_polydb_python-2.2.2.dist-info/METADATA +0 -379
  34. altcodepro_polydb_python-2.2.2.dist-info/RECORD +0 -52
  35. polydb/adapters/PubSubAdapter.py +0 -85
  36. polydb/factory.py +0 -107
  37. {altcodepro_polydb_python-2.2.2.dist-info → altcodepro_polydb_python-2.2.4.dist-info}/licenses/LICENSE +0 -0
  38. {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 typing import Any, Dict, List, Optional
5
- import boto3
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 ..errors import NoSQLError, ConnectionError
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
- """DynamoDB with S3 overflow (limit: 400KB per item)"""
18
-
19
- DYNAMODB_MAX_SIZE = 400 * 1024 # 400KB
20
-
21
- def __init__(self, partition_config: Optional[PartitionConfig] = None):
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.table_name = os.getenv("DYNAMODB_TABLE_NAME", "default")
25
- self.bucket_name = os.getenv("S3_OVERFLOW_BUCKET", "dynamodb-overflow")
26
- self._resource = None
27
- self._s3_client = None
28
- self._client_lock = threading.Lock()
29
- self._initialize()
30
-
31
- def _initialize(self):
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._client_lock:
34
- if not self._resource:
35
- self._resource = boto3.resource('dynamodb')
36
- self._s3_client = boto3.client('s3')
37
-
38
- # Ensure bucket exists
39
- try:
40
- self._s3_client.create_bucket(Bucket=self.bucket_name)
41
- except:
42
- pass # Already exists
43
-
44
- self.logger.info("DynamoDB initialized with S3 overflow")
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: {str(e)}")
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._resource:
50
- self._initialize()
51
-
52
- meta = getattr(model, '__polydb__', {})
53
- table_name = meta.get('table') or self.table_name or model.__name__.lower()
54
- return self._resource.Table(table_name) # type: ignore
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
- import json
60
- import hashlib
61
-
62
- data_copy = dict(data)
63
- data_copy['PK'] = pk
64
- data_copy['SK'] = rk
65
-
66
- # Check size
67
- data_bytes = json.dumps(data_copy,default=json_safe).encode()
68
- data_size = len(data_bytes)
69
-
70
- if data_size > self.DYNAMODB_MAX_SIZE:
71
- # Store in S3
72
- blob_id = hashlib.md5(data_bytes).hexdigest()
73
- blob_key = f"overflow/{pk}/{rk}/{blob_id}.json"
74
-
75
- if self._s3_client:
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: {str(e)}")
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
- response = table.get_item(Key={'PK': pk, 'SK': rk})
112
-
113
- if 'Item' not in response:
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 = response['Item']
117
-
118
- # Check if overflow
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: {str(e)}")
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(self, model: type, filters: Dict[str, Any], limit: Optional[int]) -> List[JsonDict]:
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
- # If PK in filters, use query, else scan
149
- if 'PK' in filters or 'partition_key' in filters:
150
- pk_value = filters.get('PK') or filters.get('partition_key')
151
- key_condition = Key('PK').eq(pk_value)
152
-
153
- if 'SK' in filters:
154
- key_condition = key_condition & Key('SK').eq(filters['SK'])
155
-
156
- kwargs = {'KeyConditionExpression': key_condition}
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
- other_filters = {k: v for k, v in filters.items() if k not in ['PK', 'SK', 'partition_key']}
160
- if other_filters:
161
- filter_expr = None
162
- for k, v in other_filters.items():
163
- expr = Attr(k).eq(v)
164
- filter_expr = expr if filter_expr is None else filter_expr & expr
165
- kwargs['FilterExpression'] = filter_expr # type: ignore
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['Limit'] = limit # type: ignore
169
-
170
- response = table.query(**kwargs)
336
+ kwargs["Limit"] = limit
337
+
338
+ resp = table.query(**kwargs)
339
+ items = resp.get("Items", [])
171
340
  else:
172
- # Scan with filters
341
+ # Scan
173
342
  kwargs = {}
174
343
  if filters:
175
- filter_expr = None
344
+ expr = None
176
345
  for k, v in filters.items():
177
- expr = Attr(k).eq(v)
178
- filter_expr = expr if filter_expr is None else filter_expr & expr
179
- kwargs['FilterExpression'] = filter_expr
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['Limit'] = limit
183
-
184
- response = table.scan(**kwargs)
185
-
186
- return response.get('Items', [])
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: {str(e)}")
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
- # Check if overflow before deleting
196
- try:
197
- response = table.get_item(Key={'PK': pk, 'SK': rk})
198
- if 'Item' in response:
199
- item = response['Item']
200
-
201
- if item.get('_overflow'):
202
- blob_key = item.get('_blob_key')
203
- if blob_key and self._s3_client:
204
- self._s3_client.delete_object(
205
- Bucket=self.bucket_name,
206
- Key=blob_key
207
- )
208
- self.logger.debug(f"Deleted overflow S3 object: {blob_key}")
209
- except:
210
- pass # Item might not exist or no overflow
211
-
212
- # Delete DynamoDB item
213
- table.delete_item(Key={'PK': pk, 'SK': rk})
214
- return {'deleted': True, 'PK': pk, 'SK': rk}
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 delete failed: {str(e)}")
503
+ raise NoSQLError(f"DynamoDB query_page failed: {e}")