• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

localstack / localstack / 18505123992

14 Oct 2025 05:30PM UTC coverage: 86.888% (-0.01%) from 86.899%
18505123992

push

github

web-flow
S3: fix `aws-global` validation in CreateBucket (#13250)

10 of 10 new or added lines in 4 files covered. (100.0%)

831 existing lines in 40 files now uncovered.

68028 of 78294 relevant lines covered (86.89%)

0.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.88
/localstack-core/localstack/services/dynamodb/provider.py
1
import copy
1✔
2
import json
1✔
3
import logging
1✔
4
import os
1✔
5
import random
1✔
6
import re
1✔
7
import threading
1✔
8
import time
1✔
9
import traceback
1✔
10
from collections import defaultdict
1✔
11
from concurrent.futures import ThreadPoolExecutor
1✔
12
from contextlib import contextmanager
1✔
13
from datetime import datetime
1✔
14
from operator import itemgetter
1✔
15

16
import requests
1✔
17
import werkzeug
1✔
18

19
from localstack import config
1✔
20
from localstack.aws import handlers
1✔
21
from localstack.aws.api import (
1✔
22
    CommonServiceException,
23
    RequestContext,
24
    ServiceRequest,
25
    ServiceResponse,
26
    handler,
27
)
28
from localstack.aws.api.dynamodb import (
1✔
29
    ApproximateCreationDateTimePrecision,
30
    AttributeMap,
31
    BatchExecuteStatementOutput,
32
    BatchGetItemOutput,
33
    BatchGetRequestMap,
34
    BatchGetResponseMap,
35
    BatchWriteItemInput,
36
    BatchWriteItemOutput,
37
    BatchWriteItemRequestMap,
38
    BillingMode,
39
    ContinuousBackupsDescription,
40
    ContinuousBackupsStatus,
41
    CreateGlobalTableOutput,
42
    CreateTableInput,
43
    CreateTableOutput,
44
    Delete,
45
    DeleteItemInput,
46
    DeleteItemOutput,
47
    DeleteRequest,
48
    DeleteTableOutput,
49
    DescribeContinuousBackupsOutput,
50
    DescribeContributorInsightsInput,
51
    DescribeContributorInsightsOutput,
52
    DescribeGlobalTableOutput,
53
    DescribeKinesisStreamingDestinationOutput,
54
    DescribeTableOutput,
55
    DescribeTimeToLiveOutput,
56
    DestinationStatus,
57
    DynamodbApi,
58
    EnableKinesisStreamingConfiguration,
59
    ExecuteStatementInput,
60
    ExecuteStatementOutput,
61
    ExecuteTransactionInput,
62
    ExecuteTransactionOutput,
63
    GetItemInput,
64
    GetItemOutput,
65
    GlobalTableAlreadyExistsException,
66
    GlobalTableNotFoundException,
67
    KinesisStreamingDestinationOutput,
68
    ListGlobalTablesOutput,
69
    ListTablesInputLimit,
70
    ListTablesOutput,
71
    ListTagsOfResourceOutput,
72
    NextTokenString,
73
    PartiQLBatchRequest,
74
    PointInTimeRecoveryDescription,
75
    PointInTimeRecoverySpecification,
76
    PointInTimeRecoveryStatus,
77
    PositiveIntegerObject,
78
    ProvisionedThroughputExceededException,
79
    Put,
80
    PutItemInput,
81
    PutItemOutput,
82
    PutRequest,
83
    QueryInput,
84
    QueryOutput,
85
    RegionName,
86
    ReplicaDescription,
87
    ReplicaList,
88
    ReplicaStatus,
89
    ReplicaUpdateList,
90
    ResourceArnString,
91
    ResourceInUseException,
92
    ResourceNotFoundException,
93
    ReturnConsumedCapacity,
94
    ScanInput,
95
    ScanOutput,
96
    StreamArn,
97
    TableArn,
98
    TableDescription,
99
    TableName,
100
    TagKeyList,
101
    TagList,
102
    TimeToLiveSpecification,
103
    TransactGetItemList,
104
    TransactGetItemsOutput,
105
    TransactWriteItem,
106
    TransactWriteItemList,
107
    TransactWriteItemsInput,
108
    TransactWriteItemsOutput,
109
    Update,
110
    UpdateContinuousBackupsOutput,
111
    UpdateGlobalTableOutput,
112
    UpdateItemInput,
113
    UpdateItemOutput,
114
    UpdateKinesisStreamingConfiguration,
115
    UpdateKinesisStreamingDestinationOutput,
116
    UpdateTableInput,
117
    UpdateTableOutput,
118
    UpdateTimeToLiveOutput,
119
    WriteRequest,
120
)
121
from localstack.aws.api.dynamodbstreams import StreamStatus
1✔
122
from localstack.aws.connect import connect_to
1✔
123
from localstack.config import is_persistence_enabled
1✔
124
from localstack.constants import (
1✔
125
    AUTH_CREDENTIAL_REGEX,
126
    AWS_REGION_US_EAST_1,
127
    INTERNAL_AWS_SECRET_ACCESS_KEY,
128
)
129
from localstack.http import Request, Response, route
1✔
130
from localstack.services.dynamodb.models import (
1✔
131
    DynamoDBStore,
132
    RecordsMap,
133
    StreamRecord,
134
    StreamRecords,
135
    TableRecords,
136
    TableStreamType,
137
    dynamodb_stores,
138
)
139
from localstack.services.dynamodb.server import DynamodbServer
1✔
140
from localstack.services.dynamodb.utils import (
1✔
141
    ItemFinder,
142
    ItemSet,
143
    SchemaExtractor,
144
    de_dynamize_record,
145
    extract_table_name_from_partiql_update,
146
    get_ddb_access_key,
147
    modify_ddblocal_arns,
148
)
149
from localstack.services.dynamodbstreams import dynamodbstreams_api
1✔
150
from localstack.services.dynamodbstreams.models import dynamodbstreams_stores
1✔
151
from localstack.services.edge import ROUTER
1✔
152
from localstack.services.plugins import ServiceLifecycleHook
1✔
153
from localstack.state import AssetDirectory, StateVisitor
1✔
154
from localstack.utils.aws import arns
1✔
155
from localstack.utils.aws.arns import (
1✔
156
    extract_account_id_from_arn,
157
    extract_region_from_arn,
158
    get_partition,
159
)
160
from localstack.utils.aws.aws_stack import get_valid_regions_for_service
1✔
161
from localstack.utils.aws.request_context import (
1✔
162
    extract_account_id_from_headers,
163
    extract_region_from_headers,
164
)
165
from localstack.utils.collections import select_attributes, select_from_typed_dict
1✔
166
from localstack.utils.common import short_uid, to_bytes
1✔
167
from localstack.utils.files import cp_r, rm_rf
1✔
168
from localstack.utils.json import BytesEncoder, canonical_json
1✔
169
from localstack.utils.scheduler import Scheduler
1✔
170
from localstack.utils.strings import long_uid, md5, to_str
1✔
171
from localstack.utils.threads import FuncThread, start_thread
1✔
172

173
# set up logger
174
LOG = logging.getLogger(__name__)
1✔
175

176
# action header prefix
177
ACTION_PREFIX = "DynamoDB_20120810."
1✔
178

179
# list of actions subject to throughput limitations
180
READ_THROTTLED_ACTIONS = [
1✔
181
    "GetItem",
182
    "Query",
183
    "Scan",
184
    "TransactGetItems",
185
    "BatchGetItem",
186
]
187
WRITE_THROTTLED_ACTIONS = [
1✔
188
    "PutItem",
189
    "BatchWriteItem",
190
    "UpdateItem",
191
    "DeleteItem",
192
    "TransactWriteItems",
193
]
194
THROTTLED_ACTIONS = READ_THROTTLED_ACTIONS + WRITE_THROTTLED_ACTIONS
1✔
195

196
MANAGED_KMS_KEYS = {}
1✔
197

198

199
def dynamodb_table_exists(table_name: str, client=None) -> bool:
1✔
200
    client = client or connect_to().dynamodb
1✔
201
    paginator = client.get_paginator("list_tables")
1✔
202
    pages = paginator.paginate(PaginationConfig={"PageSize": 100})
1✔
203
    table_name = to_str(table_name)
1✔
204
    return any(table_name in page["TableNames"] for page in pages)
1✔
205

206

207
class EventForwarder:
1✔
208
    def __init__(self, num_thread: int = 10):
1✔
209
        self.executor = ThreadPoolExecutor(num_thread, thread_name_prefix="ddb_stream_fwd")
1✔
210

211
    def shutdown(self):
1✔
212
        self.executor.shutdown(wait=False)
1✔
213

214
    def forward_to_targets(
1✔
215
        self, account_id: str, region_name: str, records_map: RecordsMap, background: bool = True
216
    ) -> None:
217
        if background:
1✔
218
            self._submit_records(
1✔
219
                account_id=account_id,
220
                region_name=region_name,
221
                records_map=records_map,
222
            )
223
        else:
224
            self._forward(account_id, region_name, records_map)
×
225

226
    def _submit_records(self, account_id: str, region_name: str, records_map: RecordsMap):
1✔
227
        """Required for patching submit with local thread context for EventStudio"""
228
        self.executor.submit(
1✔
229
            self._forward,
230
            account_id,
231
            region_name,
232
            records_map,
233
        )
234

235
    def _forward(self, account_id: str, region_name: str, records_map: RecordsMap) -> None:
1✔
236
        try:
1✔
237
            self.forward_to_kinesis_stream(account_id, region_name, records_map)
1✔
238
        except Exception as e:
×
239
            LOG.debug(
×
240
                "Error while publishing to Kinesis streams: '%s'",
241
                e,
242
                exc_info=LOG.isEnabledFor(logging.DEBUG),
243
            )
244

245
        try:
1✔
246
            self.forward_to_ddb_stream(account_id, region_name, records_map)
1✔
247
        except Exception as e:
×
248
            LOG.debug(
×
249
                "Error while publishing to DynamoDB streams, '%s'",
250
                e,
251
                exc_info=LOG.isEnabledFor(logging.DEBUG),
252
            )
253

254
    @staticmethod
1✔
255
    def forward_to_ddb_stream(account_id: str, region_name: str, records_map: RecordsMap) -> None:
1✔
256
        dynamodbstreams_api.forward_events(account_id, region_name, records_map)
1✔
257

258
    @staticmethod
1✔
259
    def forward_to_kinesis_stream(
1✔
260
        account_id: str, region_name: str, records_map: RecordsMap
261
    ) -> None:
262
        # You can only stream data from DynamoDB to Kinesis Data Streams in the same AWS account and AWS Region as your
263
        # table.
264
        # You can only stream data from a DynamoDB table to one Kinesis data stream.
265
        store = get_store(account_id, region_name)
1✔
266

267
        for table_name, table_records in records_map.items():
1✔
268
            table_stream_type = table_records["table_stream_type"]
1✔
269
            if not table_stream_type.is_kinesis:
1✔
270
                continue
1✔
271

272
            kinesis_records = []
1✔
273

274
            table_arn = arns.dynamodb_table_arn(table_name, account_id, region_name)
1✔
275
            records = table_records["records"]
1✔
276
            table_def = store.table_definitions.get(table_name) or {}
1✔
277
            stream_arn = table_def["KinesisDataStreamDestinations"][-1]["StreamArn"]
1✔
278
            for record in records:
1✔
279
                kinesis_record = dict(
1✔
280
                    tableName=table_name,
281
                    recordFormat="application/json",
282
                    userIdentity=None,
283
                    **record,
284
                )
285
                fields_to_remove = {"StreamViewType", "SequenceNumber"}
1✔
286
                kinesis_record["dynamodb"] = {
1✔
287
                    k: v for k, v in record["dynamodb"].items() if k not in fields_to_remove
288
                }
289
                kinesis_record.pop("eventVersion", None)
1✔
290

291
                hash_keys = list(
1✔
292
                    filter(lambda key: key["KeyType"] == "HASH", table_def["KeySchema"])
293
                )
294
                # TODO: reverse properly how AWS creates the partition key, it seems to be an MD5 hash
295
                kinesis_partition_key = md5(f"{table_name}{hash_keys[0]['AttributeName']}")
1✔
296

297
                kinesis_records.append(
1✔
298
                    {
299
                        "Data": json.dumps(kinesis_record, cls=BytesEncoder),
300
                        "PartitionKey": kinesis_partition_key,
301
                    }
302
                )
303

304
            kinesis = connect_to(
1✔
305
                aws_access_key_id=account_id,
306
                aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY,
307
                region_name=region_name,
308
            ).kinesis.request_metadata(service_principal="dynamodb", source_arn=table_arn)
309

310
            kinesis.put_records(
1✔
311
                StreamARN=stream_arn,
312
                Records=kinesis_records,
313
            )
314

315
    @classmethod
1✔
316
    def is_kinesis_stream_exists(cls, stream_arn):
1✔
317
        account_id = extract_account_id_from_arn(stream_arn)
1✔
318
        region_name = extract_region_from_arn(stream_arn)
1✔
319

320
        kinesis = connect_to(
1✔
321
            aws_access_key_id=account_id,
322
            aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY,
323
            region_name=region_name,
324
        ).kinesis
325
        stream_name_from_arn = stream_arn.split("/", 1)[1]
1✔
326
        # check if the stream exists in kinesis for the user
327
        filtered = list(
1✔
328
            filter(
329
                lambda stream_name: stream_name == stream_name_from_arn,
330
                kinesis.list_streams()["StreamNames"],
331
            )
332
        )
333
        return bool(filtered)
1✔
334

335

336
class SSEUtils:
1✔
337
    """Utils for server-side encryption (SSE)"""
338

339
    @classmethod
1✔
340
    def get_sse_kms_managed_key(cls, account_id: str, region_name: str):
1✔
341
        from localstack.services.kms import provider
1✔
342

343
        existing_key = MANAGED_KMS_KEYS.get(region_name)
1✔
344
        if existing_key:
1✔
345
            return existing_key
1✔
346
        kms_client = connect_to(
1✔
347
            aws_access_key_id=account_id,
348
            aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY,
349
            region_name=region_name,
350
        ).kms
351
        key_data = kms_client.create_key(
1✔
352
            Description="Default key that protects my DynamoDB data when no other key is defined"
353
        )
354
        key_id = key_data["KeyMetadata"]["KeyId"]
1✔
355

356
        provider.set_key_managed(key_id, account_id, region_name)
1✔
357
        MANAGED_KMS_KEYS[region_name] = key_id
1✔
358
        return key_id
1✔
359

360
    @classmethod
1✔
361
    def get_sse_description(cls, account_id: str, region_name: str, data):
1✔
362
        if data.get("Enabled"):
1✔
363
            kms_master_key_id = data.get("KMSMasterKeyId")
1✔
364
            if not kms_master_key_id:
1✔
365
                # this is of course not the actual key for dynamodb, just a better, since existing, mock
366
                kms_master_key_id = cls.get_sse_kms_managed_key(account_id, region_name)
1✔
367
            kms_master_key_id = arns.kms_key_arn(kms_master_key_id, account_id, region_name)
1✔
368
            return {
1✔
369
                "Status": "ENABLED",
370
                "SSEType": "KMS",  # no other value is allowed here
371
                "KMSMasterKeyArn": kms_master_key_id,
372
            }
373
        return {}
×
374

375

376
class ValidationException(CommonServiceException):
1✔
377
    def __init__(self, message: str):
1✔
378
        super().__init__(code="ValidationException", status_code=400, message=message)
1✔
379

380

381
def get_store(account_id: str, region_name: str) -> DynamoDBStore:
1✔
382
    # special case: AWS NoSQL Workbench sends "localhost" as region - replace with proper region here
383
    region_name = DynamoDBProvider.ddb_region_name(region_name)
1✔
384
    return dynamodb_stores[account_id][region_name]
1✔
385

386

387
@contextmanager
1✔
388
def modify_context_region(context: RequestContext, region: str):
1✔
389
    """
390
    Context manager that modifies the region of a `RequestContext`. At the exit, the context is restored to its
391
    original state.
392

393
    :param context: the context to modify
394
    :param region: the modified region
395
    :return: a modified `RequestContext`
396
    """
397
    original_region = context.region
1✔
398
    original_authorization = context.request.headers.get("Authorization")
1✔
399

400
    key = get_ddb_access_key(context.account_id, region)
1✔
401

402
    context.region = region
1✔
403
    context.request.headers["Authorization"] = re.sub(
1✔
404
        AUTH_CREDENTIAL_REGEX,
405
        rf"Credential={key}/\2/{region}/\4/",
406
        original_authorization or "",
407
        flags=re.IGNORECASE,
408
    )
409

410
    try:
1✔
411
        yield context
1✔
412
    except Exception:
1✔
413
        raise
1✔
414
    finally:
415
        # revert the original context
416
        context.region = original_region
1✔
417
        context.request.headers["Authorization"] = original_authorization
1✔
418

419

420
class DynamoDBDeveloperEndpoints:
1✔
421
    """
422
    Developer endpoints for DynamoDB
423
    DELETE /_aws/dynamodb/expired - delete expired items from tables with TTL enabled; return the number of expired
424
        items deleted
425
    """
426

427
    @route("/_aws/dynamodb/expired", methods=["DELETE"])
1✔
428
    def delete_expired_messages(self, _: Request):
1✔
429
        no_expired_items = delete_expired_items()
1✔
430
        return {"ExpiredItems": no_expired_items}
1✔
431

432

433
def delete_expired_items() -> int:
1✔
434
    """
435
    This utility function iterates over all stores, looks for tables with TTL enabled,
436
    scan such tables and delete expired items.
437
    """
438
    no_expired_items = 0
1✔
439
    for account_id, region_name, state in dynamodb_stores.iter_stores():
1✔
440
        ttl_specs = state.ttl_specifications
1✔
441
        client = connect_to(aws_access_key_id=account_id, region_name=region_name).dynamodb
1✔
442
        for table_name, ttl_spec in ttl_specs.items():
1✔
443
            if ttl_spec.get("Enabled", False):
1✔
444
                attribute_name = ttl_spec.get("AttributeName")
1✔
445
                current_time = int(datetime.now().timestamp())
1✔
446
                try:
1✔
447
                    result = client.scan(
1✔
448
                        TableName=table_name,
449
                        FilterExpression="#ttl <= :threshold",
450
                        ExpressionAttributeValues={":threshold": {"N": str(current_time)}},
451
                        ExpressionAttributeNames={"#ttl": attribute_name},
452
                    )
453
                    items_to_delete = result.get("Items", [])
1✔
454
                    no_expired_items += len(items_to_delete)
1✔
455
                    table_description = client.describe_table(TableName=table_name)
1✔
456
                    partition_key, range_key = _get_hash_and_range_key(table_description)
1✔
457
                    keys_to_delete = [
1✔
458
                        {partition_key: item.get(partition_key)}
459
                        if range_key is None
460
                        else {
461
                            partition_key: item.get(partition_key),
462
                            range_key: item.get(range_key),
463
                        }
464
                        for item in items_to_delete
465
                    ]
466
                    delete_requests = [{"DeleteRequest": {"Key": key}} for key in keys_to_delete]
1✔
467
                    for i in range(0, len(delete_requests), 25):
1✔
468
                        batch = delete_requests[i : i + 25]
1✔
469
                        client.batch_write_item(RequestItems={table_name: batch})
1✔
470
                except Exception as e:
×
471
                    LOG.warning(
×
472
                        "An error occurred when deleting expired items from table %s: %s",
473
                        table_name,
474
                        e,
475
                    )
476
    return no_expired_items
1✔
477

478

479
def _get_hash_and_range_key(table_description: DescribeTableOutput) -> [str, str | None]:
1✔
480
    key_schema = table_description.get("Table", {}).get("KeySchema", [])
1✔
481
    hash_key, range_key = None, None
1✔
482
    for key in key_schema:
1✔
483
        if key["KeyType"] == "HASH":
1✔
484
            hash_key = key["AttributeName"]
1✔
485
        if key["KeyType"] == "RANGE":
1✔
486
            range_key = key["AttributeName"]
1✔
487
    return hash_key, range_key
1✔
488

489

490
class ExpiredItemsWorker:
1✔
491
    """A worker that periodically computes and deletes expired items from DynamoDB tables"""
492

493
    def __init__(self) -> None:
1✔
494
        super().__init__()
1✔
495
        self.scheduler = Scheduler()
1✔
496
        self.thread: FuncThread | None = None
1✔
497
        self.mutex = threading.RLock()
1✔
498

499
    def start(self):
1✔
500
        with self.mutex:
×
501
            if self.thread:
×
502
                return
×
503

504
            self.scheduler = Scheduler()
×
505
            self.scheduler.schedule(
×
506
                delete_expired_items, period=60 * 60
507
            )  # the background process seems slow on AWS
508

509
            def _run(*_args):
×
510
                self.scheduler.run()
×
511

512
            self.thread = start_thread(_run, name="ddb-remove-expired-items")
×
513

514
    def stop(self):
1✔
515
        with self.mutex:
1✔
516
            if self.scheduler:
1✔
517
                self.scheduler.close()
1✔
518

519
            if self.thread:
1✔
520
                self.thread.stop()
×
521

522
            self.thread = None
1✔
523
            self.scheduler = None
1✔
524

525

526
class DynamoDBProvider(DynamodbApi, ServiceLifecycleHook):
1✔
527
    server: DynamodbServer
1✔
528
    """The instance of the server managing the instance of DynamoDB local"""
1✔
529
    asset_directory = f"{config.dirs.data}/dynamodb"
1✔
530
    """The directory that contains the .db files saved by DynamoDB Local"""
1✔
531
    tmp_asset_directory = f"{config.dirs.tmp}/dynamodb"
1✔
532
    """Temporary directory for the .db files saved by DynamoDB Local when MANUAL snapshot persistence is enabled"""
1✔
533

534
    def __init__(self):
1✔
535
        self.server = self._new_dynamodb_server()
1✔
536
        self._expired_items_worker = ExpiredItemsWorker()
1✔
537
        self._router_rules = []
1✔
538
        self._event_forwarder = EventForwarder()
1✔
539

540
    def on_before_start(self):
1✔
541
        # We must copy back whatever state is saved to the temporary location to avoid to start always from a blank
542
        # state. See the `on_before_state_save` hook.
543
        if is_persistence_enabled() and config.SNAPSHOT_SAVE_STRATEGY == "MANUAL":
1✔
544
            if os.path.exists(self.asset_directory):
×
545
                LOG.debug("Copying %s to %s", self.tmp_asset_directory, self.asset_directory)
×
546
                cp_r(self.asset_directory, self.tmp_asset_directory, rm_dest_on_conflict=True)
×
547

548
        self.server.start_dynamodb()
1✔
549
        if config.DYNAMODB_REMOVE_EXPIRED_ITEMS:
1✔
550
            self._expired_items_worker.start()
×
551
        self._router_rules = ROUTER.add(DynamoDBDeveloperEndpoints())
1✔
552

553
    def on_before_stop(self):
1✔
554
        self._expired_items_worker.stop()
1✔
555
        ROUTER.remove(self._router_rules)
1✔
556
        self._event_forwarder.shutdown()
1✔
557

558
    def accept_state_visitor(self, visitor: StateVisitor):
1✔
559
        visitor.visit(dynamodb_stores)
×
560
        visitor.visit(dynamodbstreams_stores)
×
561
        visitor.visit(AssetDirectory(self.service, os.path.join(config.dirs.data, self.service)))
×
562

563
    def on_before_state_reset(self):
1✔
564
        self.server.stop_dynamodb()
×
565
        rm_rf(self.tmp_asset_directory)
×
566

567
    def on_before_state_load(self):
1✔
568
        self.server.stop_dynamodb()
×
569

570
    def on_after_state_reset(self):
1✔
571
        self.server.start_dynamodb()
×
572

573
    @staticmethod
1✔
574
    def _new_dynamodb_server() -> DynamodbServer:
1✔
575
        return DynamodbServer.get()
1✔
576

577
    def on_after_state_load(self):
1✔
578
        self.server.start_dynamodb()
×
579

580
    def on_before_state_save(self) -> None:
1✔
581
        # When the save strategy is MANUAL, we do not save the DB path to the usual ``confid.dirs.data`` folder.
582
        # With the MANUAL strategy, we want to take a snapshot on-demand but this is not possible if the DB files
583
        # are already in ``config.dirs.data``. For instance, the set of operation below will result in both tables
584
        # being implicitly saved.
585
        # - awslocal dynamodb create-table table1
586
        # - curl -X POST http://localhost:4566/_localstack/state/save
587
        # - awslocal dynamodb create-table table2
588
        # To avoid this problem, we start the DDBLocal server in a temporary directory that is then copied over
589
        # ``config.dirs.data`` when the save needs to be saved.
590
        # The ideal solution to the problem would be to always start the server in memory and have a dump capability.
591
        if is_persistence_enabled() and config.SNAPSHOT_SAVE_STRATEGY == "MANUAL":
×
592
            LOG.debug("Copying %s to %s", self.tmp_asset_directory, self.asset_directory)
×
593
            cp_r(self.tmp_asset_directory, self.asset_directory, rm_dest_on_conflict=True)
×
594

595
    def on_after_init(self):
1✔
596
        # add response processor specific to ddblocal
597
        handlers.modify_service_response.append(self.service, modify_ddblocal_arns)
1✔
598

599
        # routes for the shell ui
600
        ROUTER.add(
1✔
601
            path="/shell",
602
            endpoint=self.handle_shell_ui_redirect,
603
            methods=["GET"],
604
        )
605
        ROUTER.add(
1✔
606
            path="/shell/<regex('.*'):req_path>",
607
            endpoint=self.handle_shell_ui_request,
608
        )
609

610
    def _forward_request(
1✔
611
        self,
612
        context: RequestContext,
613
        region: str | None,
614
        service_request: ServiceRequest | None = None,
615
    ) -> ServiceResponse:
616
        """
617
        Modify the context region and then forward request to DynamoDB Local.
618

619
        This is used for operations impacted by global tables. In LocalStack, a single copy of global table
620
        is kept, and any requests to replicated tables are forwarded to this original table.
621
        """
622
        if region:
1✔
623
            with modify_context_region(context, region):
1✔
624
                return self.forward_request(context, service_request=service_request)
1✔
625
        return self.forward_request(context, service_request=service_request)
×
626

627
    def forward_request(
1✔
628
        self, context: RequestContext, service_request: ServiceRequest = None
629
    ) -> ServiceResponse:
630
        """
631
        Forward a request to DynamoDB Local.
632
        """
633
        self.check_provisioned_throughput(context.operation.name)
1✔
634
        self.prepare_request_headers(
1✔
635
            context.request.headers, account_id=context.account_id, region_name=context.region
636
        )
637
        return self.server.proxy(context, service_request)
1✔
638

639
    def get_forward_url(self, account_id: str, region_name: str) -> str:
1✔
640
        """Return the URL of the backend DynamoDBLocal server to forward requests to"""
641
        return self.server.url
×
642

643
    def handle_shell_ui_redirect(self, request: werkzeug.Request) -> Response:
1✔
644
        headers = {"Refresh": f"0; url={config.external_service_url()}/shell/index.html"}
×
645
        return Response("", headers=headers)
×
646

647
    def handle_shell_ui_request(self, request: werkzeug.Request, req_path: str) -> Response:
1✔
648
        # TODO: "DynamoDB Local Web Shell was deprecated with version 1.16.X and is not available any
649
        #  longer from 1.17.X to latest. There are no immediate plans for a new Web Shell to be introduced."
650
        #  -> keeping this for now, to allow configuring custom installs; should consider removing it in the future
651
        # https://repost.aws/questions/QUHyIzoEDqQ3iOKlUEp1LPWQ#ANdBm9Nz9TRf6VqR3jZtcA1g
652
        req_path = f"/{req_path}" if not req_path.startswith("/") else req_path
×
653
        account_id = extract_account_id_from_headers(request.headers)
×
654
        region_name = extract_region_from_headers(request.headers)
×
655
        url = f"{self.get_forward_url(account_id, region_name)}/shell{req_path}"
×
656
        result = requests.request(
×
657
            method=request.method, url=url, headers=request.headers, data=request.data
658
        )
659
        return Response(result.content, headers=dict(result.headers), status=result.status_code)
×
660

661
    #
662
    # Table ops
663
    #
664

665
    @handler("CreateTable", expand=False)
1✔
666
    def create_table(
1✔
667
        self,
668
        context: RequestContext,
669
        create_table_input: CreateTableInput,
670
    ) -> CreateTableOutput:
671
        table_name = create_table_input["TableName"]
1✔
672

673
        # Return this specific error message to keep parity with AWS
674
        if self.table_exists(context.account_id, context.region, table_name):
1✔
675
            raise ResourceInUseException(f"Table already exists: {table_name}")
1✔
676

677
        billing_mode = create_table_input.get("BillingMode")
1✔
678
        provisioned_throughput = create_table_input.get("ProvisionedThroughput")
1✔
679
        if billing_mode == BillingMode.PAY_PER_REQUEST and provisioned_throughput is not None:
1✔
680
            raise ValidationException(
1✔
681
                "One or more parameter values were invalid: Neither ReadCapacityUnits nor WriteCapacityUnits can be "
682
                "specified when BillingMode is PAY_PER_REQUEST"
683
            )
684

685
        result = self.forward_request(context)
1✔
686

687
        table_description = result["TableDescription"]
1✔
688
        table_description["TableArn"] = table_arn = self.fix_table_arn(
1✔
689
            context.account_id, context.region, table_description["TableArn"]
690
        )
691

692
        backend = get_store(context.account_id, context.region)
1✔
693
        backend.table_definitions[table_name] = table_definitions = dict(create_table_input)
1✔
694
        backend.TABLE_REGION[table_name] = context.region
1✔
695

696
        if "TableId" not in table_definitions:
1✔
697
            table_definitions["TableId"] = long_uid()
1✔
698

699
        if "SSESpecification" in table_definitions:
1✔
700
            sse_specification = table_definitions.pop("SSESpecification")
1✔
701
            table_definitions["SSEDescription"] = SSEUtils.get_sse_description(
1✔
702
                context.account_id, context.region, sse_specification
703
            )
704

705
        if table_definitions:
1✔
706
            table_content = result.get("Table", {})
1✔
707
            table_content.update(table_definitions)
1✔
708
            table_description.update(table_content)
1✔
709

710
        if "StreamSpecification" in table_definitions:
1✔
711
            create_dynamodb_stream(
1✔
712
                context.account_id,
713
                context.region,
714
                table_definitions,
715
                table_description.get("LatestStreamLabel"),
716
            )
717

718
        if "TableClass" in table_definitions:
1✔
719
            table_class = table_description.pop("TableClass", None) or table_definitions.pop(
1✔
720
                "TableClass"
721
            )
722
            table_description["TableClassSummary"] = {"TableClass": table_class}
1✔
723

724
        if "GlobalSecondaryIndexes" in table_description:
1✔
725
            gsis = copy.deepcopy(table_description["GlobalSecondaryIndexes"])
1✔
726
            # update the different values, as DynamoDB-local v2 has a regression around GSI and does not return anything
727
            # anymore
728
            for gsi in gsis:
1✔
729
                index_name = gsi.get("IndexName", "")
1✔
730
                gsi.update(
1✔
731
                    {
732
                        "IndexArn": f"{table_arn}/index/{index_name}",
733
                        "IndexSizeBytes": 0,
734
                        "IndexStatus": "ACTIVE",
735
                        "ItemCount": 0,
736
                    }
737
                )
738
                gsi_provisioned_throughput = gsi.setdefault("ProvisionedThroughput", {})
1✔
739
                gsi_provisioned_throughput["NumberOfDecreasesToday"] = 0
1✔
740

741
                if billing_mode == BillingMode.PAY_PER_REQUEST:
1✔
742
                    gsi_provisioned_throughput["ReadCapacityUnits"] = 0
1✔
743
                    gsi_provisioned_throughput["WriteCapacityUnits"] = 0
1✔
744

745
            table_description["GlobalSecondaryIndexes"] = gsis
1✔
746

747
        if "ProvisionedThroughput" in table_description:
1✔
748
            if "NumberOfDecreasesToday" not in table_description["ProvisionedThroughput"]:
1✔
749
                table_description["ProvisionedThroughput"]["NumberOfDecreasesToday"] = 0
1✔
750

751
        if "WarmThroughput" in table_description:
1✔
752
            table_description["WarmThroughput"]["Status"] = "UPDATING"
1✔
753

754
        tags = table_definitions.pop("Tags", [])
1✔
755
        if tags:
1✔
756
            get_store(context.account_id, context.region).TABLE_TAGS[table_arn] = {
1✔
757
                tag["Key"]: tag["Value"] for tag in tags
758
            }
759

760
        # remove invalid attributes from result
761
        table_description.pop("Tags", None)
1✔
762
        table_description.pop("BillingMode", None)
1✔
763

764
        return result
1✔
765

766
    def delete_table(
1✔
767
        self, context: RequestContext, table_name: TableName, **kwargs
768
    ) -> DeleteTableOutput:
769
        global_table_region = self.get_global_table_region(context, table_name)
1✔
770

771
        self.ensure_table_exists(
1✔
772
            context.account_id,
773
            global_table_region,
774
            table_name,
775
            error_message=f"Requested resource not found: Table: {table_name} not found",
776
        )
777

778
        # Limitation note: On AWS, for a replicated table, if the source table is deleted, the replicated tables continue to exist.
779
        # This is not the case for LocalStack, where all replicated tables will also be removed if source is deleted.
780

781
        result = self._forward_request(context=context, region=global_table_region)
1✔
782

783
        table_arn = result.get("TableDescription", {}).get("TableArn")
1✔
784
        table_arn = self.fix_table_arn(context.account_id, context.region, table_arn)
1✔
785
        dynamodbstreams_api.delete_streams(context.account_id, context.region, table_arn)
1✔
786

787
        store = get_store(context.account_id, context.region)
1✔
788
        store.TABLE_TAGS.pop(table_arn, None)
1✔
789
        store.REPLICAS.pop(table_name, None)
1✔
790

791
        return result
1✔
792

793
    def describe_table(
1✔
794
        self, context: RequestContext, table_name: TableName, **kwargs
795
    ) -> DescribeTableOutput:
796
        global_table_region = self.get_global_table_region(context, table_name)
1✔
797

798
        result = self._forward_request(context=context, region=global_table_region)
1✔
799
        table_description: TableDescription = result["Table"]
1✔
800

801
        # Update table properties from LocalStack stores
802
        if table_props := get_store(context.account_id, context.region).table_properties.get(
1✔
803
            table_name
804
        ):
805
            table_description.update(table_props)
1✔
806

807
        store = get_store(context.account_id, context.region)
1✔
808

809
        # Update replication details
810
        replicas: dict[RegionName, ReplicaDescription] = store.REPLICAS.get(table_name, {})
1✔
811

812
        replica_description_list = []
1✔
813

814
        if global_table_region != context.region:
1✔
815
            replica_description_list.append(
1✔
816
                ReplicaDescription(
817
                    RegionName=global_table_region, ReplicaStatus=ReplicaStatus.ACTIVE
818
                )
819
            )
820

821
        for replica_region, replica_description in replicas.items():
1✔
822
            # The replica in the region being queried must not be returned
823
            if replica_region != context.region:
1✔
824
                replica_description_list.append(replica_description)
1✔
825

826
        if replica_description_list:
1✔
827
            table_description.update({"Replicas": replica_description_list})
1✔
828

829
        # update only TableId and SSEDescription if present
830
        if table_definitions := store.table_definitions.get(table_name):
1✔
831
            for key in ["TableId", "SSEDescription"]:
1✔
832
                if table_definitions.get(key):
1✔
833
                    table_description[key] = table_definitions[key]
1✔
834
            if "TableClass" in table_definitions:
1✔
835
                table_description["TableClassSummary"] = {
1✔
836
                    "TableClass": table_definitions["TableClass"]
837
                }
838
            if warm_throughput := table_definitions.get("WarmThroughput"):
1✔
839
                table_description["WarmThroughput"] = warm_throughput.copy()
1✔
840
                table_description["WarmThroughput"].setdefault("Status", "ACTIVE")
1✔
841

842
        if "GlobalSecondaryIndexes" in table_description:
1✔
843
            for gsi in table_description["GlobalSecondaryIndexes"]:
1✔
844
                default_values = {
1✔
845
                    "NumberOfDecreasesToday": 0,
846
                    "ReadCapacityUnits": 0,
847
                    "WriteCapacityUnits": 0,
848
                }
849
                # even if the billing mode is PAY_PER_REQUEST, AWS returns the Read and Write Capacity Units
850
                # Terraform depends on this parity for update operations
851
                gsi["ProvisionedThroughput"] = default_values | gsi.get("ProvisionedThroughput", {})
1✔
852

853
        # Set defaults for warm throughput
854
        if "WarmThroughput" not in table_description:
1✔
855
            billing_mode = table_definitions.get("BillingMode") if table_definitions else None
1✔
856
            table_description["WarmThroughput"] = {
1✔
857
                "ReadUnitsPerSecond": 12000 if billing_mode == "PAY_PER_REQUEST" else 5,
858
                "WriteUnitsPerSecond": 4000 if billing_mode == "PAY_PER_REQUEST" else 5,
859
            }
860
        table_description["WarmThroughput"]["Status"] = (
1✔
861
            table_description.get("TableStatus") or "ACTIVE"
862
        )
863

864
        return DescribeTableOutput(
1✔
865
            Table=select_from_typed_dict(TableDescription, table_description)
866
        )
867

868
    @handler("UpdateTable", expand=False)
1✔
869
    def update_table(
1✔
870
        self, context: RequestContext, update_table_input: UpdateTableInput
871
    ) -> UpdateTableOutput:
872
        table_name = update_table_input["TableName"]
1✔
873
        global_table_region = self.get_global_table_region(context, table_name)
1✔
874

875
        try:
1✔
876
            result = self._forward_request(context=context, region=global_table_region)
1✔
877
        except CommonServiceException as exc:
1✔
878
            # DynamoDBLocal refuses to update certain table params and raises.
879
            # But we still need to update this info in LocalStack stores
880
            if not (exc.code == "ValidationException" and exc.message == "Nothing to update"):
1✔
UNCOV
881
                raise
×
882

883
            if table_class := update_table_input.get("TableClass"):
1✔
884
                table_definitions = get_store(
1✔
885
                    context.account_id, context.region
886
                ).table_definitions.setdefault(table_name, {})
887
                table_definitions["TableClass"] = table_class
1✔
888

889
            if replica_updates := update_table_input.get("ReplicaUpdates"):
1✔
890
                store = get_store(context.account_id, global_table_region)
1✔
891

892
                # Dict with source region to set of replicated regions
893
                replicas: dict[RegionName, ReplicaDescription] = store.REPLICAS.get(table_name, {})
1✔
894

895
                for replica_update in replica_updates:
1✔
896
                    for key, details in replica_update.items():
1✔
897
                        # Replicated region
898
                        target_region = details.get("RegionName")
1✔
899

900
                        # Check if replicated region is valid
901
                        if target_region not in get_valid_regions_for_service("dynamodb"):
1✔
UNCOV
902
                            raise ValidationException(f"Region {target_region} is not supported")
×
903

904
                        match key:
1✔
905
                            case "Create":
1✔
906
                                if target_region in replicas:
1✔
UNCOV
907
                                    raise ValidationException(
×
908
                                        f"Failed to create a the new replica of table with name: '{table_name}' because one or more replicas already existed as tables."
909
                                    )
910
                                replicas[target_region] = ReplicaDescription(
1✔
911
                                    RegionName=target_region,
912
                                    KMSMasterKeyId=details.get("KMSMasterKeyId"),
913
                                    ProvisionedThroughputOverride=details.get(
914
                                        "ProvisionedThroughputOverride"
915
                                    ),
916
                                    GlobalSecondaryIndexes=details.get("GlobalSecondaryIndexes"),
917
                                    ReplicaStatus=ReplicaStatus.ACTIVE,
918
                                )
919
                            case "Delete":
1✔
920
                                try:
1✔
921
                                    replicas.pop(target_region)
1✔
922
                                except KeyError:
1✔
923
                                    raise ValidationException(
1✔
924
                                        "Update global table operation failed because one or more replicas were not part of the global table."
925
                                    )
926

927
                store.REPLICAS[table_name] = replicas
1✔
928

929
            # update response content
930
            SchemaExtractor.invalidate_table_schema(
1✔
931
                table_name, context.account_id, global_table_region
932
            )
933

934
            schema = SchemaExtractor.get_table_schema(
1✔
935
                table_name, context.account_id, global_table_region
936
            )
937

938
            if sse_specification_input := update_table_input.get("SSESpecification"):
1✔
939
                # If SSESpecification is changed, update store and return the 'UPDATING' status in the response
940
                table_definition = get_store(
1✔
941
                    context.account_id, context.region
942
                ).table_definitions.setdefault(table_name, {})
943
                if not sse_specification_input["Enabled"]:
1✔
944
                    table_definition.pop("SSEDescription", None)
1✔
945
                    schema["Table"]["SSEDescription"]["Status"] = "UPDATING"
1✔
946

947
            return UpdateTableOutput(TableDescription=schema["Table"])
1✔
948

949
        SchemaExtractor.invalidate_table_schema(table_name, context.account_id, global_table_region)
1✔
950

951
        schema = SchemaExtractor.get_table_schema(
1✔
952
            table_name, context.account_id, global_table_region
953
        )
954

955
        # TODO: DDB streams must also be created for replicas
956
        if update_table_input.get("StreamSpecification"):
1✔
957
            create_dynamodb_stream(
1✔
958
                context.account_id,
959
                context.region,
960
                update_table_input,
961
                result["TableDescription"].get("LatestStreamLabel"),
962
            )
963

964
        return UpdateTableOutput(TableDescription=schema["Table"])
1✔
965

966
    def list_tables(
1✔
967
        self,
968
        context: RequestContext,
969
        exclusive_start_table_name: TableName = None,
970
        limit: ListTablesInputLimit = None,
971
        **kwargs,
972
    ) -> ListTablesOutput:
973
        response = self.forward_request(context)
1✔
974

975
        # Add replicated tables
976
        replicas = get_store(context.account_id, context.region).REPLICAS
1✔
977
        for replicated_table, replications in replicas.items():
1✔
978
            for replica_region, replica_description in replications.items():
1✔
979
                if context.region == replica_region:
1✔
980
                    response["TableNames"].append(replicated_table)
1✔
981

982
        return response
1✔
983

984
    #
985
    # Contributor Insights
986
    #
987

988
    @handler("DescribeContributorInsights", expand=False)
1✔
989
    def describe_contributor_insights(
1✔
990
        self,
991
        context: RequestContext,
992
        describe_contributor_insights_input: DescribeContributorInsightsInput,
993
    ) -> DescribeContributorInsightsOutput:
994
        return DescribeContributorInsightsOutput(
1✔
995
            TableName=describe_contributor_insights_input["TableName"],
996
            IndexName=describe_contributor_insights_input.get("IndexName"),
997
            ContributorInsightsStatus="DISABLED",
998
        )
999

1000
    #
1001
    # Item ops
1002
    #
1003

1004
    @handler("PutItem", expand=False)
1✔
1005
    def put_item(self, context: RequestContext, put_item_input: PutItemInput) -> PutItemOutput:
1✔
1006
        table_name = put_item_input["TableName"]
1✔
1007
        global_table_region = self.get_global_table_region(context, table_name)
1✔
1008

1009
        has_return_values = put_item_input.get("ReturnValues") == "ALL_OLD"
1✔
1010
        stream_type = get_table_stream_type(context.account_id, context.region, table_name)
1✔
1011

1012
        # if the request doesn't ask for ReturnValues and we have stream enabled, we need to modify the request to
1013
        # force DDBLocal to return those values
1014
        if stream_type and not has_return_values:
1✔
1015
            service_req = copy.copy(context.service_request)
1✔
1016
            service_req["ReturnValues"] = "ALL_OLD"
1✔
1017
            result = self._forward_request(
1✔
1018
                context=context, region=global_table_region, service_request=service_req
1019
            )
1020
        else:
1021
            result = self._forward_request(context=context, region=global_table_region)
1✔
1022

1023
        # Since this operation makes use of global table region, we need to use the same region for all
1024
        # calls made via the inter-service client. This is taken care of by passing the account ID and
1025
        # region, e.g. when getting the stream spec
1026

1027
        # Get stream specifications details for the table
1028
        if stream_type:
1✔
1029
            item = put_item_input["Item"]
1✔
1030
            # prepare record keys
1031
            keys = SchemaExtractor.extract_keys(
1✔
1032
                item=item,
1033
                table_name=table_name,
1034
                account_id=context.account_id,
1035
                region_name=global_table_region,
1036
            )
1037
            # because we modified the request, we will always have the ReturnValues if we have streams enabled
1038
            if has_return_values:
1✔
UNCOV
1039
                existing_item = result.get("Attributes")
×
1040
            else:
1041
                # remove the ReturnValues if the client didn't ask for it
1042
                existing_item = result.pop("Attributes", None)
1✔
1043

1044
            if existing_item == item:
1✔
1045
                return result
1✔
1046

1047
            # create record
1048
            record = self.get_record_template(
1✔
1049
                context.region,
1050
            )
1051
            record["eventName"] = "INSERT" if not existing_item else "MODIFY"
1✔
1052
            record["dynamodb"]["Keys"] = keys
1✔
1053
            record["dynamodb"]["SizeBytes"] = _get_size_bytes(item)
1✔
1054

1055
            if stream_type.needs_new_image:
1✔
1056
                record["dynamodb"]["NewImage"] = item
1✔
1057
            if stream_type.stream_view_type:
1✔
1058
                record["dynamodb"]["StreamViewType"] = stream_type.stream_view_type
1✔
1059
            if existing_item and stream_type.needs_old_image:
1✔
1060
                record["dynamodb"]["OldImage"] = existing_item
1✔
1061

1062
            records_map = {
1✔
1063
                table_name: TableRecords(records=[record], table_stream_type=stream_type)
1064
            }
1065
            self.forward_stream_records(context.account_id, context.region, records_map)
1✔
1066
        return result
1✔
1067

1068
    @handler("DeleteItem", expand=False)
1✔
1069
    def delete_item(
1✔
1070
        self,
1071
        context: RequestContext,
1072
        delete_item_input: DeleteItemInput,
1073
    ) -> DeleteItemOutput:
1074
        table_name = delete_item_input["TableName"]
1✔
1075
        global_table_region = self.get_global_table_region(context, table_name)
1✔
1076

1077
        has_return_values = delete_item_input.get("ReturnValues") == "ALL_OLD"
1✔
1078
        stream_type = get_table_stream_type(context.account_id, context.region, table_name)
1✔
1079

1080
        # if the request doesn't ask for ReturnValues and we have stream enabled, we need to modify the request to
1081
        # force DDBLocal to return those values
1082
        if stream_type and not has_return_values:
1✔
1083
            service_req = copy.copy(context.service_request)
1✔
1084
            service_req["ReturnValues"] = "ALL_OLD"
1✔
1085
            result = self._forward_request(
1✔
1086
                context=context, region=global_table_region, service_request=service_req
1087
            )
1088
        else:
1089
            result = self._forward_request(context=context, region=global_table_region)
1✔
1090

1091
        # determine and forward stream record
1092
        if stream_type:
1✔
1093
            # because we modified the request, we will always have the ReturnValues if we have streams enabled
1094
            if has_return_values:
1✔
UNCOV
1095
                existing_item = result.get("Attributes")
×
1096
            else:
1097
                # remove the ReturnValues if the client didn't ask for it
1098
                existing_item = result.pop("Attributes", None)
1✔
1099

1100
            if not existing_item:
1✔
UNCOV
1101
                return result
×
1102

1103
            # create record
1104
            record = self.get_record_template(context.region)
1✔
1105
            record["eventName"] = "REMOVE"
1✔
1106
            record["dynamodb"]["Keys"] = delete_item_input["Key"]
1✔
1107
            record["dynamodb"]["SizeBytes"] = _get_size_bytes(existing_item)
1✔
1108

1109
            if stream_type.stream_view_type:
1✔
1110
                record["dynamodb"]["StreamViewType"] = stream_type.stream_view_type
1✔
1111
            if stream_type.needs_old_image:
1✔
1112
                record["dynamodb"]["OldImage"] = existing_item
1✔
1113

1114
            records_map = {
1✔
1115
                table_name: TableRecords(records=[record], table_stream_type=stream_type)
1116
            }
1117
            self.forward_stream_records(context.account_id, context.region, records_map)
1✔
1118

1119
        return result
1✔
1120

1121
    @handler("UpdateItem", expand=False)
1✔
1122
    def update_item(
1✔
1123
        self,
1124
        context: RequestContext,
1125
        update_item_input: UpdateItemInput,
1126
    ) -> UpdateItemOutput:
1127
        # TODO: UpdateItem is harder to use ReturnValues for Streams, because it needs the Before and After images.
1128
        table_name = update_item_input["TableName"]
1✔
1129
        global_table_region = self.get_global_table_region(context, table_name)
1✔
1130

1131
        existing_item = None
1✔
1132
        stream_type = get_table_stream_type(context.account_id, context.region, table_name)
1✔
1133

1134
        # even if we don't need the OldImage, we still need to fetch the existing item to know if the event is INSERT
1135
        # or MODIFY (UpdateItem will create the object if it doesn't exist, and you don't use a ConditionExpression)
1136
        if stream_type:
1✔
1137
            existing_item = ItemFinder.find_existing_item(
1✔
1138
                put_item=update_item_input,
1139
                table_name=table_name,
1140
                account_id=context.account_id,
1141
                region_name=context.region,
1142
                endpoint_url=self.server.url,
1143
            )
1144

1145
        result = self._forward_request(context=context, region=global_table_region)
1✔
1146

1147
        # construct and forward stream record
1148
        if stream_type:
1✔
1149
            updated_item = ItemFinder.find_existing_item(
1✔
1150
                put_item=update_item_input,
1151
                table_name=table_name,
1152
                account_id=context.account_id,
1153
                region_name=context.region,
1154
                endpoint_url=self.server.url,
1155
            )
1156
            if not updated_item or updated_item == existing_item:
1✔
UNCOV
1157
                return result
×
1158

1159
            record = self.get_record_template(context.region)
1✔
1160
            record["eventName"] = "INSERT" if not existing_item else "MODIFY"
1✔
1161
            record["dynamodb"]["Keys"] = update_item_input["Key"]
1✔
1162
            record["dynamodb"]["SizeBytes"] = _get_size_bytes(updated_item)
1✔
1163

1164
            if stream_type.stream_view_type:
1✔
1165
                record["dynamodb"]["StreamViewType"] = stream_type.stream_view_type
1✔
1166
            if existing_item and stream_type.needs_old_image:
1✔
1167
                record["dynamodb"]["OldImage"] = existing_item
1✔
1168
            if stream_type.needs_new_image:
1✔
1169
                record["dynamodb"]["NewImage"] = updated_item
1✔
1170

1171
            records_map = {
1✔
1172
                table_name: TableRecords(records=[record], table_stream_type=stream_type)
1173
            }
1174
            self.forward_stream_records(context.account_id, context.region, records_map)
1✔
1175

1176
        return result
1✔
1177

1178
    @handler("GetItem", expand=False)
1✔
1179
    def get_item(self, context: RequestContext, get_item_input: GetItemInput) -> GetItemOutput:
1✔
1180
        table_name = get_item_input["TableName"]
1✔
1181
        global_table_region = self.get_global_table_region(context, table_name)
1✔
1182
        result = self._forward_request(context=context, region=global_table_region)
1✔
1183
        self.fix_consumed_capacity(get_item_input, result)
1✔
1184
        return result
1✔
1185

1186
    #
1187
    # Queries
1188
    #
1189

1190
    @handler("Query", expand=False)
1✔
1191
    def query(self, context: RequestContext, query_input: QueryInput) -> QueryOutput:
1✔
1192
        index_name = query_input.get("IndexName")
1✔
1193
        if index_name:
1✔
1194
            if not is_index_query_valid(context.account_id, context.region, query_input):
1✔
1195
                raise ValidationException(
1✔
1196
                    "One or more parameter values were invalid: Select type ALL_ATTRIBUTES "
1197
                    "is not supported for global secondary index id-index because its projection "
1198
                    "type is not ALL",
1199
                )
1200

1201
        table_name = query_input["TableName"]
1✔
1202
        global_table_region = self.get_global_table_region(context, table_name)
1✔
1203
        result = self._forward_request(context=context, region=global_table_region)
1✔
1204
        self.fix_consumed_capacity(query_input, result)
1✔
1205
        return result
1✔
1206

1207
    @handler("Scan", expand=False)
1✔
1208
    def scan(self, context: RequestContext, scan_input: ScanInput) -> ScanOutput:
1✔
1209
        table_name = scan_input["TableName"]
1✔
1210
        global_table_region = self.get_global_table_region(context, table_name)
1✔
1211
        result = self._forward_request(context=context, region=global_table_region)
1✔
1212
        return result
1✔
1213

1214
    #
1215
    # Batch ops
1216
    #
1217

1218
    @handler("BatchWriteItem", expand=False)
1✔
1219
    def batch_write_item(
1✔
1220
        self,
1221
        context: RequestContext,
1222
        batch_write_item_input: BatchWriteItemInput,
1223
    ) -> BatchWriteItemOutput:
1224
        # TODO: add global table support
1225
        existing_items = {}
1✔
1226
        existing_items_to_fetch: BatchWriteItemRequestMap = {}
1✔
1227
        # UnprocessedItems should have the same format as RequestItems
1228
        unprocessed_items = {}
1✔
1229
        request_items = batch_write_item_input["RequestItems"]
1✔
1230

1231
        tables_stream_type: dict[TableName, TableStreamType] = {}
1✔
1232

1233
        for table_name, items in sorted(request_items.items(), key=itemgetter(0)):
1✔
1234
            if stream_type := get_table_stream_type(context.account_id, context.region, table_name):
1✔
1235
                tables_stream_type[table_name] = stream_type
1✔
1236

1237
            for request in items:
1✔
1238
                request: WriteRequest
1239
                for key, inner_request in request.items():
1✔
1240
                    inner_request: PutRequest | DeleteRequest
1241
                    if self.should_throttle("BatchWriteItem"):
1✔
1242
                        unprocessed_items_for_table = unprocessed_items.setdefault(table_name, [])
×
UNCOV
1243
                        unprocessed_items_for_table.append(request)
×
1244

1245
                    elif stream_type:
1✔
1246
                        existing_items_to_fetch_for_table = existing_items_to_fetch.setdefault(
1✔
1247
                            table_name, []
1248
                        )
1249
                        existing_items_to_fetch_for_table.append(inner_request)
1✔
1250

1251
        if existing_items_to_fetch:
1✔
1252
            existing_items = ItemFinder.find_existing_items(
1✔
1253
                put_items_per_table=existing_items_to_fetch,
1254
                account_id=context.account_id,
1255
                region_name=context.region,
1256
                endpoint_url=self.server.url,
1257
            )
1258

1259
        try:
1✔
1260
            result = self.forward_request(context)
1✔
1261
        except CommonServiceException as e:
1✔
1262
            # TODO: validate if DynamoDB still raises `One of the required keys was not given a value`
1263
            # for now, replace with the schema error validation
1264
            if e.message == "One of the required keys was not given a value":
1✔
1265
                raise ValidationException("The provided key element does not match the schema")
1✔
UNCOV
1266
            raise e
×
1267

1268
        # determine and forward stream records
1269
        if tables_stream_type:
1✔
1270
            records_map = self.prepare_batch_write_item_records(
1✔
1271
                account_id=context.account_id,
1272
                region_name=context.region,
1273
                tables_stream_type=tables_stream_type,
1274
                request_items=request_items,
1275
                existing_items=existing_items,
1276
            )
1277
            self.forward_stream_records(context.account_id, context.region, records_map)
1✔
1278

1279
        # TODO: should unprocessed item which have mutated by `prepare_batch_write_item_records` be returned
1280
        for table_name, unprocessed_items_in_table in unprocessed_items.items():
1✔
UNCOV
1281
            unprocessed: dict = result["UnprocessedItems"]
×
UNCOV
1282
            result_unprocessed_table = unprocessed.setdefault(table_name, [])
×
1283

1284
            # add the Unprocessed items to the response
1285
            # TODO: check before if the same request has not been Unprocessed by DDB local already?
1286
            # those might actually have been processed? shouldn't we remove them from the proxied request?
UNCOV
1287
            for request in unprocessed_items_in_table:
×
UNCOV
1288
                result_unprocessed_table.append(request)
×
1289

1290
            # remove any table entry if it's empty
UNCOV
1291
            result["UnprocessedItems"] = {k: v for k, v in unprocessed.items() if v}
×
1292

1293
        return result
1✔
1294

1295
    @handler("BatchGetItem")
1✔
1296
    def batch_get_item(
1✔
1297
        self,
1298
        context: RequestContext,
1299
        request_items: BatchGetRequestMap,
1300
        return_consumed_capacity: ReturnConsumedCapacity = None,
1301
        **kwargs,
1302
    ) -> BatchGetItemOutput:
1303
        # TODO: add global table support
1304
        return self.forward_request(context)
1✔
1305

1306
    #
1307
    # Transactions
1308
    #
1309

1310
    @handler("TransactWriteItems", expand=False)
1✔
1311
    def transact_write_items(
1✔
1312
        self,
1313
        context: RequestContext,
1314
        transact_write_items_input: TransactWriteItemsInput,
1315
    ) -> TransactWriteItemsOutput:
1316
        # TODO: add global table support
1317
        existing_items = {}
1✔
1318
        existing_items_to_fetch: dict[str, list[Put | Update | Delete]] = {}
1✔
1319
        updated_items_to_fetch: dict[str, list[Update]] = {}
1✔
1320
        transact_items = transact_write_items_input["TransactItems"]
1✔
1321
        tables_stream_type: dict[TableName, TableStreamType] = {}
1✔
1322
        no_stream_tables = set()
1✔
1323

1324
        for item in transact_items:
1✔
1325
            item: TransactWriteItem
1326
            for key in ["Put", "Update", "Delete"]:
1✔
1327
                inner_item: Put | Delete | Update = item.get(key)
1✔
1328
                if inner_item:
1✔
1329
                    table_name = inner_item["TableName"]
1✔
1330
                    # if we've seen the table already and it does not have streams, skip
1331
                    if table_name in no_stream_tables:
1✔
1332
                        continue
1✔
1333

1334
                    # if we have not seen the table, fetch its streaming status
1335
                    if table_name not in tables_stream_type:
1✔
1336
                        if stream_type := get_table_stream_type(
1✔
1337
                            context.account_id, context.region, table_name
1338
                        ):
1339
                            tables_stream_type[table_name] = stream_type
1✔
1340
                        else:
1341
                            # no stream,
1342
                            no_stream_tables.add(table_name)
1✔
1343
                            continue
1✔
1344

1345
                    existing_items_to_fetch_for_table = existing_items_to_fetch.setdefault(
1✔
1346
                        table_name, []
1347
                    )
1348
                    existing_items_to_fetch_for_table.append(inner_item)
1✔
1349
                    if key == "Update":
1✔
1350
                        updated_items_to_fetch_for_table = updated_items_to_fetch.setdefault(
1✔
1351
                            table_name, []
1352
                        )
1353
                        updated_items_to_fetch_for_table.append(inner_item)
1✔
1354

1355
                    continue
1✔
1356

1357
        if existing_items_to_fetch:
1✔
1358
            existing_items = ItemFinder.find_existing_items(
1✔
1359
                put_items_per_table=existing_items_to_fetch,
1360
                account_id=context.account_id,
1361
                region_name=context.region,
1362
                endpoint_url=self.server.url,
1363
            )
1364

1365
        client_token: str | None = transact_write_items_input.get("ClientRequestToken")
1✔
1366

1367
        if client_token:
1✔
1368
            # we sort the payload since identical payload but with different order could cause
1369
            # IdempotentParameterMismatchException error if a client token is provided
1370
            context.request.data = to_bytes(canonical_json(json.loads(context.request.data)))
1✔
1371

1372
        result = self.forward_request(context)
1✔
1373

1374
        # determine and forward stream records
1375
        if tables_stream_type:
1✔
1376
            updated_items = (
1✔
1377
                ItemFinder.find_existing_items(
1378
                    put_items_per_table=existing_items_to_fetch,
1379
                    account_id=context.account_id,
1380
                    region_name=context.region,
1381
                    endpoint_url=self.server.url,
1382
                )
1383
                if updated_items_to_fetch
1384
                else {}
1385
            )
1386

1387
            records_map = self.prepare_transact_write_item_records(
1✔
1388
                account_id=context.account_id,
1389
                region_name=context.region,
1390
                transact_items=transact_items,
1391
                existing_items=existing_items,
1392
                updated_items=updated_items,
1393
                tables_stream_type=tables_stream_type,
1394
            )
1395
            self.forward_stream_records(context.account_id, context.region, records_map)
1✔
1396

1397
        return result
1✔
1398

1399
    @handler("TransactGetItems", expand=False)
1✔
1400
    def transact_get_items(
1✔
1401
        self,
1402
        context: RequestContext,
1403
        transact_items: TransactGetItemList,
1404
        return_consumed_capacity: ReturnConsumedCapacity = None,
1405
    ) -> TransactGetItemsOutput:
1406
        return self.forward_request(context)
1✔
1407

1408
    @handler("ExecuteTransaction", expand=False)
1✔
1409
    def execute_transaction(
1✔
1410
        self, context: RequestContext, execute_transaction_input: ExecuteTransactionInput
1411
    ) -> ExecuteTransactionOutput:
1412
        result = self.forward_request(context)
1✔
1413
        return result
1✔
1414

1415
    @handler("ExecuteStatement", expand=False)
1✔
1416
    def execute_statement(
1✔
1417
        self,
1418
        context: RequestContext,
1419
        execute_statement_input: ExecuteStatementInput,
1420
    ) -> ExecuteStatementOutput:
1421
        # TODO: this operation is still really slow with streams enabled
1422
        #  find a way to make it better, same way as the other operations, by using returnvalues
1423
        # see https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-reference.update.html
1424
        statement = execute_statement_input["Statement"]
1✔
1425
        # We found out that 'Parameters' can be an empty list when the request comes from the AWS JS client.
1426
        if execute_statement_input.get("Parameters", None) == []:  # noqa
1✔
1427
            raise ValidationException(
1✔
1428
                "1 validation error detected: Value '[]' at 'parameters' failed to satisfy constraint: Member must have length greater than or equal to 1"
1429
            )
1430
        table_name = extract_table_name_from_partiql_update(statement)
1✔
1431
        existing_items = None
1✔
1432
        stream_type = table_name and get_table_stream_type(
1✔
1433
            context.account_id, context.region, table_name
1434
        )
1435
        if stream_type:
1✔
1436
            # Note: fetching the entire list of items is hugely inefficient, especially for larger tables
1437
            # TODO: find a mechanism to hook into the PartiQL update mechanism of DynamoDB Local directly!
1438
            existing_items = ItemFinder.list_existing_items_for_statement(
1✔
1439
                partiql_statement=statement,
1440
                account_id=context.account_id,
1441
                region_name=context.region,
1442
                endpoint_url=self.server.url,
1443
            )
1444

1445
        result = self.forward_request(context)
1✔
1446

1447
        # construct and forward stream record
1448
        if stream_type:
1✔
1449
            records = get_updated_records(
1✔
1450
                account_id=context.account_id,
1451
                region_name=context.region,
1452
                table_name=table_name,
1453
                existing_items=existing_items,
1454
                server_url=self.server.url,
1455
                table_stream_type=stream_type,
1456
            )
1457
            self.forward_stream_records(context.account_id, context.region, records)
1✔
1458

1459
        return result
1✔
1460

1461
    #
1462
    # Tags
1463
    #
1464

1465
    def tag_resource(
1✔
1466
        self, context: RequestContext, resource_arn: ResourceArnString, tags: TagList, **kwargs
1467
    ) -> None:
1468
        table_tags = get_store(context.account_id, context.region).TABLE_TAGS
1✔
1469
        if resource_arn not in table_tags:
1✔
UNCOV
1470
            table_tags[resource_arn] = {}
×
1471
        table_tags[resource_arn].update({tag["Key"]: tag["Value"] for tag in tags})
1✔
1472

1473
    def untag_resource(
1✔
1474
        self,
1475
        context: RequestContext,
1476
        resource_arn: ResourceArnString,
1477
        tag_keys: TagKeyList,
1478
        **kwargs,
1479
    ) -> None:
1480
        for tag_key in tag_keys or []:
1✔
1481
            get_store(context.account_id, context.region).TABLE_TAGS.get(resource_arn, {}).pop(
1✔
1482
                tag_key, None
1483
            )
1484

1485
    def list_tags_of_resource(
1✔
1486
        self,
1487
        context: RequestContext,
1488
        resource_arn: ResourceArnString,
1489
        next_token: NextTokenString = None,
1490
        **kwargs,
1491
    ) -> ListTagsOfResourceOutput:
1492
        result = [
1✔
1493
            {"Key": k, "Value": v}
1494
            for k, v in get_store(context.account_id, context.region)
1495
            .TABLE_TAGS.get(resource_arn, {})
1496
            .items()
1497
        ]
1498
        return ListTagsOfResourceOutput(Tags=result)
1✔
1499

1500
    #
1501
    # TTLs
1502
    #
1503

1504
    def describe_time_to_live(
1✔
1505
        self, context: RequestContext, table_name: TableName, **kwargs
1506
    ) -> DescribeTimeToLiveOutput:
1507
        if not self.table_exists(context.account_id, context.region, table_name):
1✔
1508
            raise ResourceNotFoundException(
1✔
1509
                f"Requested resource not found: Table: {table_name} not found"
1510
            )
1511

1512
        backend = get_store(context.account_id, context.region)
1✔
1513
        ttl_spec = backend.ttl_specifications.get(table_name)
1✔
1514

1515
        result = {"TimeToLiveStatus": "DISABLED"}
1✔
1516
        if ttl_spec:
1✔
1517
            if ttl_spec.get("Enabled"):
1✔
1518
                ttl_status = "ENABLED"
1✔
1519
            else:
1520
                ttl_status = "DISABLED"
1✔
1521
            result = {
1✔
1522
                "AttributeName": ttl_spec.get("AttributeName"),
1523
                "TimeToLiveStatus": ttl_status,
1524
            }
1525

1526
        return DescribeTimeToLiveOutput(TimeToLiveDescription=result)
1✔
1527

1528
    def update_time_to_live(
1✔
1529
        self,
1530
        context: RequestContext,
1531
        table_name: TableName,
1532
        time_to_live_specification: TimeToLiveSpecification,
1533
        **kwargs,
1534
    ) -> UpdateTimeToLiveOutput:
1535
        if not self.table_exists(context.account_id, context.region, table_name):
1✔
1536
            raise ResourceNotFoundException(
1✔
1537
                f"Requested resource not found: Table: {table_name} not found"
1538
            )
1539

1540
        # TODO: TTL status is maintained/mocked but no real expiry is happening for items
1541
        backend = get_store(context.account_id, context.region)
1✔
1542
        backend.ttl_specifications[table_name] = time_to_live_specification
1✔
1543
        return UpdateTimeToLiveOutput(TimeToLiveSpecification=time_to_live_specification)
1✔
1544

1545
    #
1546
    # Global tables
1547
    #
1548

1549
    def create_global_table(
1✔
1550
        self,
1551
        context: RequestContext,
1552
        global_table_name: TableName,
1553
        replication_group: ReplicaList,
1554
        **kwargs,
1555
    ) -> CreateGlobalTableOutput:
1556
        global_tables: dict = get_store(context.account_id, context.region).GLOBAL_TABLES
1✔
1557
        if global_table_name in global_tables:
1✔
1558
            raise GlobalTableAlreadyExistsException("Global table with this name already exists")
1✔
1559
        replication_group = [grp.copy() for grp in replication_group or []]
1✔
1560
        data = {"GlobalTableName": global_table_name, "ReplicationGroup": replication_group}
1✔
1561
        global_tables[global_table_name] = data
1✔
1562
        for group in replication_group:
1✔
1563
            group["ReplicaStatus"] = "ACTIVE"
1✔
1564
            group["ReplicaStatusDescription"] = "Replica active"
1✔
1565
        return CreateGlobalTableOutput(GlobalTableDescription=data)
1✔
1566

1567
    def describe_global_table(
1✔
1568
        self, context: RequestContext, global_table_name: TableName, **kwargs
1569
    ) -> DescribeGlobalTableOutput:
1570
        details = get_store(context.account_id, context.region).GLOBAL_TABLES.get(global_table_name)
1✔
1571
        if not details:
1✔
1572
            raise GlobalTableNotFoundException("Global table with this name does not exist")
1✔
1573
        return DescribeGlobalTableOutput(GlobalTableDescription=details)
1✔
1574

1575
    def list_global_tables(
1✔
1576
        self,
1577
        context: RequestContext,
1578
        exclusive_start_global_table_name: TableName = None,
1579
        limit: PositiveIntegerObject = None,
1580
        region_name: RegionName = None,
1581
        **kwargs,
1582
    ) -> ListGlobalTablesOutput:
1583
        # TODO: add paging support
UNCOV
1584
        result = [
×
1585
            select_attributes(tab, ["GlobalTableName", "ReplicationGroup"])
1586
            for tab in get_store(context.account_id, context.region).GLOBAL_TABLES.values()
1587
        ]
UNCOV
1588
        return ListGlobalTablesOutput(GlobalTables=result)
×
1589

1590
    def update_global_table(
1✔
1591
        self,
1592
        context: RequestContext,
1593
        global_table_name: TableName,
1594
        replica_updates: ReplicaUpdateList,
1595
        **kwargs,
1596
    ) -> UpdateGlobalTableOutput:
1597
        details = get_store(context.account_id, context.region).GLOBAL_TABLES.get(global_table_name)
1✔
1598
        if not details:
1✔
UNCOV
1599
            raise GlobalTableNotFoundException("Global table with this name does not exist")
×
1600
        for update in replica_updates or []:
1✔
1601
            repl_group = details["ReplicationGroup"]
1✔
1602
            # delete existing
1603
            delete = update.get("Delete")
1✔
1604
            if delete:
1✔
1605
                details["ReplicationGroup"] = [
1✔
1606
                    g for g in repl_group if g["RegionName"] != delete["RegionName"]
1607
                ]
1608
            # create new
1609
            create = update.get("Create")
1✔
1610
            if create:
1✔
1611
                exists = [g for g in repl_group if g["RegionName"] == create["RegionName"]]
1✔
1612
                if exists:
1✔
UNCOV
1613
                    continue
×
1614
                new_group = {
1✔
1615
                    "RegionName": create["RegionName"],
1616
                    "ReplicaStatus": "ACTIVE",
1617
                    "ReplicaStatusDescription": "Replica active",
1618
                }
1619
                details["ReplicationGroup"].append(new_group)
1✔
1620
        return UpdateGlobalTableOutput(GlobalTableDescription=details)
1✔
1621

1622
    #
1623
    # Kinesis Streaming
1624
    #
1625

1626
    def enable_kinesis_streaming_destination(
1✔
1627
        self,
1628
        context: RequestContext,
1629
        table_name: TableName,
1630
        stream_arn: StreamArn,
1631
        enable_kinesis_streaming_configuration: EnableKinesisStreamingConfiguration = None,
1632
        **kwargs,
1633
    ) -> KinesisStreamingDestinationOutput:
1634
        self.ensure_table_exists(
1✔
1635
            context.account_id,
1636
            context.region,
1637
            table_name,
1638
            error_message=f"Requested resource not found: Table: {table_name} not found",
1639
        )
1640

1641
        # TODO: Use the time precision in config if set
1642
        enable_kinesis_streaming_configuration = enable_kinesis_streaming_configuration or {}
1✔
1643

1644
        stream = self._event_forwarder.is_kinesis_stream_exists(stream_arn=stream_arn)
1✔
1645
        if not stream:
1✔
UNCOV
1646
            raise ValidationException("User does not have a permission to use kinesis stream")
×
1647

1648
        table_def = get_store(context.account_id, context.region).table_definitions.setdefault(
1✔
1649
            table_name, {}
1650
        )
1651

1652
        dest_status = table_def.get("KinesisDataStreamDestinationStatus")
1✔
1653
        if dest_status not in ["DISABLED", "ENABLE_FAILED", None]:
1✔
UNCOV
1654
            raise ValidationException(
×
1655
                "Table is not in a valid state to enable Kinesis Streaming "
1656
                "Destination:EnableKinesisStreamingDestination must be DISABLED or ENABLE_FAILED "
1657
                "to perform ENABLE operation."
1658
            )
1659

1660
        table_def.setdefault("KinesisDataStreamDestinations", [])
1✔
1661

1662
        # remove the stream destination if already present
1663
        table_def["KinesisDataStreamDestinations"] = [
1✔
1664
            t for t in table_def["KinesisDataStreamDestinations"] if t["StreamArn"] != stream_arn
1665
        ]
1666
        # append the active stream destination at the end of the list
1667
        table_def["KinesisDataStreamDestinations"].append(
1✔
1668
            {
1669
                "DestinationStatus": DestinationStatus.ACTIVE,
1670
                "DestinationStatusDescription": "Stream is active",
1671
                "StreamArn": stream_arn,
1672
                "ApproximateCreationDateTimePrecision": ApproximateCreationDateTimePrecision.MILLISECOND,
1673
            }
1674
        )
1675
        table_def["KinesisDataStreamDestinationStatus"] = DestinationStatus.ACTIVE
1✔
1676
        return KinesisStreamingDestinationOutput(
1✔
1677
            DestinationStatus=DestinationStatus.ENABLING,
1678
            StreamArn=stream_arn,
1679
            TableName=table_name,
1680
            EnableKinesisStreamingConfiguration=enable_kinesis_streaming_configuration,
1681
        )
1682

1683
    def disable_kinesis_streaming_destination(
1✔
1684
        self,
1685
        context: RequestContext,
1686
        table_name: TableName,
1687
        stream_arn: StreamArn,
1688
        enable_kinesis_streaming_configuration: EnableKinesisStreamingConfiguration = None,
1689
        **kwargs,
1690
    ) -> KinesisStreamingDestinationOutput:
1691
        self.ensure_table_exists(
1✔
1692
            context.account_id,
1693
            context.region,
1694
            table_name,
1695
            error_message=f"Requested resource not found: Table: {table_name} not found",
1696
        )
1697

1698
        # TODO: Must raise if invoked before KinesisStreamingDestination is ACTIVE
1699

1700
        stream = self._event_forwarder.is_kinesis_stream_exists(stream_arn=stream_arn)
1✔
1701
        if not stream:
1✔
1702
            raise ValidationException(
×
1703
                "User does not have a permission to use kinesis stream",
1704
            )
1705

1706
        table_def = get_store(context.account_id, context.region).table_definitions.setdefault(
1✔
1707
            table_name, {}
1708
        )
1709

1710
        stream_destinations = table_def.get("KinesisDataStreamDestinations")
1✔
1711
        if stream_destinations:
1✔
1712
            if table_def["KinesisDataStreamDestinationStatus"] == DestinationStatus.ACTIVE:
1✔
1713
                for dest in stream_destinations:
1✔
1714
                    if (
1✔
1715
                        dest["StreamArn"] == stream_arn
1716
                        and dest["DestinationStatus"] == DestinationStatus.ACTIVE
1717
                    ):
1718
                        dest["DestinationStatus"] = DestinationStatus.DISABLED
1✔
1719
                        dest["DestinationStatusDescription"] = ("Stream is disabled",)
1✔
1720
                        table_def["KinesisDataStreamDestinationStatus"] = DestinationStatus.DISABLED
1✔
1721
                        return KinesisStreamingDestinationOutput(
1✔
1722
                            DestinationStatus=DestinationStatus.DISABLING,
1723
                            StreamArn=stream_arn,
1724
                            TableName=table_name,
1725
                        )
UNCOV
1726
        raise ValidationException(
×
1727
            "Table is not in a valid state to disable Kinesis Streaming Destination:"
1728
            "DisableKinesisStreamingDestination must be ACTIVE to perform DISABLE operation."
1729
        )
1730

1731
    def describe_kinesis_streaming_destination(
1✔
1732
        self, context: RequestContext, table_name: TableName, **kwargs
1733
    ) -> DescribeKinesisStreamingDestinationOutput:
1734
        self.ensure_table_exists(context.account_id, context.region, table_name)
1✔
1735

1736
        table_def = (
1✔
1737
            get_store(context.account_id, context.region).table_definitions.get(table_name) or {}
1738
        )
1739

1740
        stream_destinations = table_def.get("KinesisDataStreamDestinations") or []
1✔
1741
        stream_destinations = copy.deepcopy(stream_destinations)
1✔
1742

1743
        for destination in stream_destinations:
1✔
1744
            destination.pop("ApproximateCreationDateTimePrecision", None)
1✔
1745
            destination.pop("DestinationStatusDescription", None)
1✔
1746

1747
        return DescribeKinesisStreamingDestinationOutput(
1✔
1748
            KinesisDataStreamDestinations=stream_destinations,
1749
            TableName=table_name,
1750
        )
1751

1752
    def update_kinesis_streaming_destination(
1✔
1753
        self,
1754
        context: RequestContext,
1755
        table_name: TableArn,
1756
        stream_arn: StreamArn,
1757
        update_kinesis_streaming_configuration: UpdateKinesisStreamingConfiguration | None = None,
1758
        **kwargs,
1759
    ) -> UpdateKinesisStreamingDestinationOutput:
1760
        self.ensure_table_exists(context.account_id, context.region, table_name)
1✔
1761

1762
        if not update_kinesis_streaming_configuration:
1✔
1763
            raise ValidationException(
1✔
1764
                "Streaming destination cannot be updated with given parameters: "
1765
                "UpdateKinesisStreamingConfiguration cannot be null or contain only null values"
1766
            )
1767

1768
        time_precision = update_kinesis_streaming_configuration.get(
1✔
1769
            "ApproximateCreationDateTimePrecision"
1770
        )
1771
        if time_precision not in (
1✔
1772
            ApproximateCreationDateTimePrecision.MILLISECOND,
1773
            ApproximateCreationDateTimePrecision.MICROSECOND,
1774
        ):
1775
            raise ValidationException(
1✔
1776
                f"1 validation error detected: Value '{time_precision}' at "
1777
                "'updateKinesisStreamingConfiguration.approximateCreationDateTimePrecision' failed to satisfy constraint: "
1778
                "Member must satisfy enum value set: [MILLISECOND, MICROSECOND]"
1779
            )
1780

1781
        store = get_store(context.account_id, context.region)
1✔
1782

1783
        table_def = store.table_definitions.get(table_name) or {}
1✔
1784
        table_def.setdefault("KinesisDataStreamDestinations", [])
1✔
1785

1786
        table_id = table_def["TableId"]
1✔
1787

1788
        destination = None
1✔
1789
        for stream in table_def["KinesisDataStreamDestinations"]:
1✔
1790
            if stream["StreamArn"] == stream_arn:
1✔
1791
                destination = stream
1✔
1792

1793
        if destination is None:
1✔
1794
            raise ValidationException(
1✔
1795
                "Table is not in a valid state to enable Kinesis Streaming Destination: "
1796
                f"No streaming destination with streamArn: {stream_arn} found for table with tableName: {table_name}"
1797
            )
1798

1799
        if (
1✔
1800
            existing_precision := destination["ApproximateCreationDateTimePrecision"]
1801
        ) == update_kinesis_streaming_configuration["ApproximateCreationDateTimePrecision"]:
1802
            raise ValidationException(
1✔
1803
                f"Invalid Request: Precision is already set to the desired value of {existing_precision} "
1804
                f"for tableId: {table_id}, kdsArn: {stream_arn}"
1805
            )
1806

1807
        destination["ApproximateCreationDateTimePrecision"] = time_precision
1✔
1808

1809
        return UpdateKinesisStreamingDestinationOutput(
1✔
1810
            TableName=table_name,
1811
            StreamArn=stream_arn,
1812
            DestinationStatus=DestinationStatus.UPDATING,
1813
            UpdateKinesisStreamingConfiguration=UpdateKinesisStreamingConfiguration(
1814
                ApproximateCreationDateTimePrecision=time_precision,
1815
            ),
1816
        )
1817

1818
    #
1819
    # Continuous Backups
1820
    #
1821

1822
    def describe_continuous_backups(
1✔
1823
        self, context: RequestContext, table_name: TableName, **kwargs
1824
    ) -> DescribeContinuousBackupsOutput:
1825
        self.get_global_table_region(context, table_name)
1✔
1826
        store = get_store(context.account_id, context.region)
1✔
1827
        continuous_backup_description = (
1✔
1828
            store.table_properties.get(table_name, {}).get("ContinuousBackupsDescription")
1829
        ) or ContinuousBackupsDescription(
1830
            ContinuousBackupsStatus=ContinuousBackupsStatus.ENABLED,
1831
            PointInTimeRecoveryDescription=PointInTimeRecoveryDescription(
1832
                PointInTimeRecoveryStatus=PointInTimeRecoveryStatus.DISABLED
1833
            ),
1834
        )
1835

1836
        return DescribeContinuousBackupsOutput(
1✔
1837
            ContinuousBackupsDescription=continuous_backup_description
1838
        )
1839

1840
    def update_continuous_backups(
1✔
1841
        self,
1842
        context: RequestContext,
1843
        table_name: TableName,
1844
        point_in_time_recovery_specification: PointInTimeRecoverySpecification,
1845
        **kwargs,
1846
    ) -> UpdateContinuousBackupsOutput:
1847
        self.get_global_table_region(context, table_name)
1✔
1848

1849
        store = get_store(context.account_id, context.region)
1✔
1850
        pit_recovery_status = (
1✔
1851
            PointInTimeRecoveryStatus.ENABLED
1852
            if point_in_time_recovery_specification["PointInTimeRecoveryEnabled"]
1853
            else PointInTimeRecoveryStatus.DISABLED
1854
        )
1855
        continuous_backup_description = ContinuousBackupsDescription(
1✔
1856
            ContinuousBackupsStatus=ContinuousBackupsStatus.ENABLED,
1857
            PointInTimeRecoveryDescription=PointInTimeRecoveryDescription(
1858
                PointInTimeRecoveryStatus=pit_recovery_status
1859
            ),
1860
        )
1861
        table_props = store.table_properties.setdefault(table_name, {})
1✔
1862
        table_props["ContinuousBackupsDescription"] = continuous_backup_description
1✔
1863

1864
        return UpdateContinuousBackupsOutput(
1✔
1865
            ContinuousBackupsDescription=continuous_backup_description
1866
        )
1867

1868
    #
1869
    # Helpers
1870
    #
1871

1872
    @staticmethod
1✔
1873
    def ddb_region_name(region_name: str) -> str:
1✔
1874
        """Map `local` or `localhost` region to the us-east-1 region. These values are used by NoSQL Workbench."""
1875
        # TODO: could this be somehow moved into the request handler chain?
1876
        if region_name in ("local", "localhost"):
1✔
UNCOV
1877
            region_name = AWS_REGION_US_EAST_1
×
1878

1879
        return region_name
1✔
1880

1881
    @staticmethod
1✔
1882
    def table_exists(account_id: str, region_name: str, table_name: str) -> bool:
1✔
1883
        region_name = DynamoDBProvider.ddb_region_name(region_name)
1✔
1884

1885
        client = connect_to(
1✔
1886
            aws_access_key_id=account_id,
1887
            aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY,
1888
            region_name=region_name,
1889
        ).dynamodb
1890
        return dynamodb_table_exists(table_name, client)
1✔
1891

1892
    @staticmethod
1✔
1893
    def ensure_table_exists(
1✔
1894
        account_id: str,
1895
        region_name: str,
1896
        table_name: str,
1897
        error_message: str = "Cannot do operations on a non-existent table",
1898
    ):
1899
        """
1900
        Raise ResourceNotFoundException if the given table does not exist.
1901

1902
        :param account_id: account id
1903
        :param region_name: region name
1904
        :param table_name: table name
1905
        :raise: ResourceNotFoundException if table does not exist in DynamoDB Local
1906
        """
1907
        if not DynamoDBProvider.table_exists(account_id, region_name, table_name):
1✔
1908
            raise ResourceNotFoundException(error_message)
1✔
1909

1910
    @staticmethod
1✔
1911
    def get_global_table_region(context: RequestContext, table_name: str) -> str:
1✔
1912
        """
1913
        Return the table region considering that it might be a replicated table.
1914

1915
        Replication in LocalStack works by keeping a single copy of a table and forwarding
1916
        requests to the region where this table exists.
1917

1918
        This method does not check whether the table actually exists in DDBLocal.
1919

1920
        :param context: request context
1921
        :param table_name: table name
1922
        :return: region
1923
        """
1924
        store = get_store(context.account_id, context.region)
1✔
1925

1926
        table_region = store.TABLE_REGION.get(table_name)
1✔
1927
        replicated_at = store.REPLICAS.get(table_name, {}).keys()
1✔
1928

1929
        if context.region == table_region or context.region in replicated_at:
1✔
1930
            return table_region
1✔
1931

1932
        return context.region
1✔
1933

1934
    @staticmethod
1✔
1935
    def prepare_request_headers(headers: dict, account_id: str, region_name: str):
1✔
1936
        """
1937
        Modify the Credentials field of Authorization header to achieve namespacing in DynamoDBLocal.
1938
        """
1939
        region_name = DynamoDBProvider.ddb_region_name(region_name)
1✔
1940
        key = get_ddb_access_key(account_id, region_name)
1✔
1941

1942
        # DynamoDBLocal namespaces based on the value of Credentials
1943
        # Since we want to namespace by both account ID and region, use an aggregate key
1944
        # We also replace the region to keep compatibility with NoSQL Workbench
1945
        headers["Authorization"] = re.sub(
1✔
1946
            AUTH_CREDENTIAL_REGEX,
1947
            rf"Credential={key}/\2/{region_name}/\4/",
1948
            headers.get("Authorization") or "",
1949
            flags=re.IGNORECASE,
1950
        )
1951

1952
    def fix_consumed_capacity(self, request: dict, result: dict):
1✔
1953
        # make sure we append 'ConsumedCapacity', which is properly
1954
        # returned by dynalite, but not by AWS's DynamoDBLocal
1955
        table_name = request.get("TableName")
1✔
1956
        return_cap = request.get("ReturnConsumedCapacity")
1✔
1957
        if "ConsumedCapacity" not in result and return_cap in ["TOTAL", "INDEXES"]:
1✔
UNCOV
1958
            request["ConsumedCapacity"] = {
×
1959
                "TableName": table_name,
1960
                "CapacityUnits": 5,  # TODO hardcoded
1961
                "ReadCapacityUnits": 2,
1962
                "WriteCapacityUnits": 3,
1963
            }
1964

1965
    def fix_table_arn(self, account_id: str, region_name: str, arn: str) -> str:
1✔
1966
        """
1967
        Set the correct account ID and region in ARNs returned by DynamoDB Local.
1968
        """
1969
        partition = get_partition(region_name)
1✔
1970
        return (
1✔
1971
            arn.replace("arn:aws:", f"arn:{partition}:")
1972
            .replace(":ddblocal:", f":{region_name}:")
1973
            .replace(":000000000000:", f":{account_id}:")
1974
        )
1975

1976
    def prepare_transact_write_item_records(
1✔
1977
        self,
1978
        account_id: str,
1979
        region_name: str,
1980
        transact_items: TransactWriteItemList,
1981
        existing_items: BatchGetResponseMap,
1982
        updated_items: BatchGetResponseMap,
1983
        tables_stream_type: dict[TableName, TableStreamType],
1984
    ) -> RecordsMap:
1985
        records_only_map: dict[TableName, StreamRecords] = defaultdict(list)
1✔
1986

1987
        for request in transact_items:
1✔
1988
            record = self.get_record_template(region_name)
1✔
1989
            match request:
1✔
1990
                case {"Put": {"TableName": table_name, "Item": new_item}}:
1✔
1991
                    if not (stream_type := tables_stream_type.get(table_name)):
1✔
1992
                        continue
1✔
1993
                    keys = SchemaExtractor.extract_keys(
1✔
1994
                        item=new_item,
1995
                        table_name=table_name,
1996
                        account_id=account_id,
1997
                        region_name=region_name,
1998
                    )
1999
                    existing_item = find_item_for_keys_values_in_batch(
1✔
2000
                        table_name, keys, existing_items
2001
                    )
2002
                    if existing_item == new_item:
1✔
2003
                        continue
1✔
2004

2005
                    if stream_type.stream_view_type:
1✔
2006
                        record["dynamodb"]["StreamViewType"] = stream_type.stream_view_type
1✔
2007

2008
                    record["eventID"] = short_uid()
1✔
2009
                    record["eventName"] = "INSERT" if not existing_item else "MODIFY"
1✔
2010
                    record["dynamodb"]["Keys"] = keys
1✔
2011
                    if stream_type.needs_new_image:
1✔
2012
                        record["dynamodb"]["NewImage"] = new_item
1✔
2013
                    if existing_item and stream_type.needs_old_image:
1✔
2014
                        record["dynamodb"]["OldImage"] = existing_item
1✔
2015

2016
                    record_item = de_dynamize_record(new_item)
1✔
2017
                    record["dynamodb"]["SizeBytes"] = _get_size_bytes(record_item)
1✔
2018
                    records_only_map[table_name].append(record)
1✔
2019
                    continue
1✔
2020

2021
                case {"Update": {"TableName": table_name, "Key": keys}}:
1✔
2022
                    if not (stream_type := tables_stream_type.get(table_name)):
1✔
UNCOV
2023
                        continue
×
2024
                    updated_item = find_item_for_keys_values_in_batch(
1✔
2025
                        table_name, keys, updated_items
2026
                    )
2027
                    if not updated_item:
1✔
UNCOV
2028
                        continue
×
2029

2030
                    existing_item = find_item_for_keys_values_in_batch(
1✔
2031
                        table_name, keys, existing_items
2032
                    )
2033
                    if existing_item == updated_item:
1✔
2034
                        # if the item is the same as the previous version, AWS does not send an event
2035
                        continue
1✔
2036

2037
                    if stream_type.stream_view_type:
1✔
2038
                        record["dynamodb"]["StreamViewType"] = stream_type.stream_view_type
1✔
2039

2040
                    record["eventID"] = short_uid()
1✔
2041
                    record["eventName"] = "MODIFY" if existing_item else "INSERT"
1✔
2042
                    record["dynamodb"]["Keys"] = keys
1✔
2043

2044
                    if existing_item and stream_type.needs_old_image:
1✔
2045
                        record["dynamodb"]["OldImage"] = existing_item
1✔
2046
                    if stream_type.needs_new_image:
1✔
2047
                        record["dynamodb"]["NewImage"] = updated_item
1✔
2048

2049
                    record["dynamodb"]["SizeBytes"] = _get_size_bytes(updated_item)
1✔
2050
                    records_only_map[table_name].append(record)
1✔
2051
                    continue
1✔
2052

2053
                case {"Delete": {"TableName": table_name, "Key": keys}}:
1✔
2054
                    if not (stream_type := tables_stream_type.get(table_name)):
1✔
UNCOV
2055
                        continue
×
2056

2057
                    existing_item = find_item_for_keys_values_in_batch(
1✔
2058
                        table_name, keys, existing_items
2059
                    )
2060
                    if not existing_item:
1✔
UNCOV
2061
                        continue
×
2062

2063
                    if stream_type.stream_view_type:
1✔
2064
                        record["dynamodb"]["StreamViewType"] = stream_type.stream_view_type
1✔
2065

2066
                    record["eventID"] = short_uid()
1✔
2067
                    record["eventName"] = "REMOVE"
1✔
2068
                    record["dynamodb"]["Keys"] = keys
1✔
2069
                    if stream_type.needs_old_image:
1✔
2070
                        record["dynamodb"]["OldImage"] = existing_item
1✔
2071
                    record_item = de_dynamize_record(existing_item)
1✔
2072
                    record["dynamodb"]["SizeBytes"] = _get_size_bytes(record_item)
1✔
2073

2074
                    records_only_map[table_name].append(record)
1✔
2075
                    continue
1✔
2076

2077
        records_map = {
1✔
2078
            table_name: TableRecords(
2079
                records=records, table_stream_type=tables_stream_type[table_name]
2080
            )
2081
            for table_name, records in records_only_map.items()
2082
        }
2083

2084
        return records_map
1✔
2085

2086
    def batch_execute_statement(
1✔
2087
        self,
2088
        context: RequestContext,
2089
        statements: PartiQLBatchRequest,
2090
        return_consumed_capacity: ReturnConsumedCapacity = None,
2091
        **kwargs,
2092
    ) -> BatchExecuteStatementOutput:
2093
        result = self.forward_request(context)
1✔
2094
        return result
1✔
2095

2096
    def prepare_batch_write_item_records(
1✔
2097
        self,
2098
        account_id: str,
2099
        region_name: str,
2100
        tables_stream_type: dict[TableName, TableStreamType],
2101
        request_items: BatchWriteItemRequestMap,
2102
        existing_items: BatchGetResponseMap,
2103
    ) -> RecordsMap:
2104
        records_map: RecordsMap = {}
1✔
2105

2106
        # only iterate over tables with streams
2107
        for table_name, stream_type in tables_stream_type.items():
1✔
2108
            existing_items_for_table_unordered = existing_items.get(table_name, [])
1✔
2109
            table_records: StreamRecords = []
1✔
2110

2111
            def find_existing_item_for_keys_values(item_keys: dict) -> AttributeMap | None:
1✔
2112
                """
2113
                This function looks up in the existing items for the provided item keys subset. If present, returns the
2114
                full item.
2115
                :param item_keys: the request item keys
2116
                :return:
2117
                """
2118
                keys_items = item_keys.items()
1✔
2119
                for item in existing_items_for_table_unordered:
1✔
2120
                    if keys_items <= item.items():
1✔
2121
                        return item
1✔
2122

2123
            for write_request in request_items[table_name]:
1✔
2124
                record = self.get_record_template(
1✔
2125
                    region_name,
2126
                    stream_view_type=stream_type.stream_view_type,
2127
                )
2128
                match write_request:
1✔
2129
                    case {"PutRequest": request}:
1✔
2130
                        keys = SchemaExtractor.extract_keys(
1✔
2131
                            item=request["Item"],
2132
                            table_name=table_name,
2133
                            account_id=account_id,
2134
                            region_name=region_name,
2135
                        )
2136
                        # we need to find if there was an existing item even if we don't need it for `OldImage`, because
2137
                        # of the `eventName`
2138
                        existing_item = find_existing_item_for_keys_values(keys)
1✔
2139
                        if existing_item == request["Item"]:
1✔
2140
                            # if the item is the same as the previous version, AWS does not send an event
2141
                            continue
1✔
2142
                        record["eventID"] = short_uid()
1✔
2143
                        record["dynamodb"]["SizeBytes"] = _get_size_bytes(request["Item"])
1✔
2144
                        record["eventName"] = "INSERT" if not existing_item else "MODIFY"
1✔
2145
                        record["dynamodb"]["Keys"] = keys
1✔
2146

2147
                        if stream_type.needs_new_image:
1✔
2148
                            record["dynamodb"]["NewImage"] = request["Item"]
1✔
2149
                        if existing_item and stream_type.needs_old_image:
1✔
2150
                            record["dynamodb"]["OldImage"] = existing_item
1✔
2151

2152
                        table_records.append(record)
1✔
2153
                        continue
1✔
2154

2155
                    case {"DeleteRequest": request}:
1✔
2156
                        keys = request["Key"]
1✔
2157
                        if not (existing_item := find_existing_item_for_keys_values(keys)):
1✔
2158
                            continue
×
2159

2160
                        record["eventID"] = short_uid()
1✔
2161
                        record["eventName"] = "REMOVE"
1✔
2162
                        record["dynamodb"]["Keys"] = keys
1✔
2163
                        if stream_type.needs_old_image:
1✔
2164
                            record["dynamodb"]["OldImage"] = existing_item
1✔
2165
                        record["dynamodb"]["SizeBytes"] = _get_size_bytes(existing_item)
1✔
2166
                        table_records.append(record)
1✔
2167
                        continue
1✔
2168

2169
            records_map[table_name] = TableRecords(
1✔
2170
                records=table_records, table_stream_type=stream_type
2171
            )
2172

2173
        return records_map
1✔
2174

2175
    def forward_stream_records(
1✔
2176
        self,
2177
        account_id: str,
2178
        region_name: str,
2179
        records_map: RecordsMap,
2180
    ) -> None:
2181
        if not records_map:
1✔
UNCOV
2182
            return
×
2183

2184
        self._event_forwarder.forward_to_targets(
1✔
2185
            account_id, region_name, records_map, background=True
2186
        )
2187

2188
    @staticmethod
1✔
2189
    def get_record_template(region_name: str, stream_view_type: str | None = None) -> StreamRecord:
1✔
2190
        record = {
1✔
2191
            "eventID": short_uid(),
2192
            "eventVersion": "1.1",
2193
            "dynamodb": {
2194
                # expects nearest second rounded down
2195
                "ApproximateCreationDateTime": int(time.time()),
2196
                "SizeBytes": -1,
2197
            },
2198
            "awsRegion": region_name,
2199
            "eventSource": "aws:dynamodb",
2200
        }
2201
        if stream_view_type:
1✔
2202
            record["dynamodb"]["StreamViewType"] = stream_view_type
1✔
2203

2204
        return record
1✔
2205

2206
    def check_provisioned_throughput(self, action):
1✔
2207
        """
2208
        Check rate limiting for an API operation and raise an error if provisioned throughput is exceeded.
2209
        """
2210
        if self.should_throttle(action):
1✔
2211
            message = (
1✔
2212
                "The level of configured provisioned throughput for the table was exceeded. "
2213
                + "Consider increasing your provisioning level with the UpdateTable API"
2214
            )
2215
            raise ProvisionedThroughputExceededException(message)
1✔
2216

2217
    def action_should_throttle(self, action, actions):
1✔
2218
        throttled = [f"{ACTION_PREFIX}{a}" for a in actions]
1✔
2219
        return (action in throttled) or (action in actions)
1✔
2220

2221
    def should_throttle(self, action):
1✔
2222
        if (
1✔
2223
            not config.DYNAMODB_READ_ERROR_PROBABILITY
2224
            and not config.DYNAMODB_ERROR_PROBABILITY
2225
            and not config.DYNAMODB_WRITE_ERROR_PROBABILITY
2226
        ):
2227
            # early exit so we don't need to call random()
2228
            return False
1✔
2229

2230
        rand = random.random()
1✔
2231
        if rand < config.DYNAMODB_READ_ERROR_PROBABILITY and self.action_should_throttle(
1✔
2232
            action, READ_THROTTLED_ACTIONS
2233
        ):
2234
            return True
1✔
2235
        elif rand < config.DYNAMODB_WRITE_ERROR_PROBABILITY and self.action_should_throttle(
1✔
2236
            action, WRITE_THROTTLED_ACTIONS
2237
        ):
2238
            return True
1✔
2239
        elif rand < config.DYNAMODB_ERROR_PROBABILITY and self.action_should_throttle(
1✔
2240
            action, THROTTLED_ACTIONS
2241
        ):
2242
            return True
1✔
2243
        return False
1✔
2244

2245

2246
# ---
2247
# Misc. util functions
2248
# ---
2249

2250

2251
def _get_size_bytes(item: dict) -> int:
1✔
2252
    try:
1✔
2253
        size_bytes = len(json.dumps(item, separators=(",", ":")))
1✔
2254
    except TypeError:
1✔
2255
        size_bytes = len(str(item))
1✔
2256
    return size_bytes
1✔
2257

2258

2259
def get_global_secondary_index(account_id: str, region_name: str, table_name: str, index_name: str):
1✔
2260
    schema = SchemaExtractor.get_table_schema(table_name, account_id, region_name)
1✔
2261
    for index in schema["Table"].get("GlobalSecondaryIndexes", []):
1✔
2262
        if index["IndexName"] == index_name:
1✔
2263
            return index
1✔
UNCOV
2264
    raise ResourceNotFoundException("Index not found")
×
2265

2266

2267
def is_local_secondary_index(
1✔
2268
    account_id: str, region_name: str, table_name: str, index_name: str
2269
) -> bool:
2270
    schema = SchemaExtractor.get_table_schema(table_name, account_id, region_name)
1✔
2271
    for index in schema["Table"].get("LocalSecondaryIndexes", []):
1✔
2272
        if index["IndexName"] == index_name:
1✔
2273
            return True
1✔
2274
    return False
1✔
2275

2276

2277
def is_index_query_valid(account_id: str, region_name: str, query_data: dict) -> bool:
1✔
2278
    table_name = to_str(query_data["TableName"])
1✔
2279
    index_name = to_str(query_data["IndexName"])
1✔
2280
    if is_local_secondary_index(account_id, region_name, table_name, index_name):
1✔
2281
        return True
1✔
2282
    index_query_type = query_data.get("Select")
1✔
2283
    index = get_global_secondary_index(account_id, region_name, table_name, index_name)
1✔
2284
    index_projection_type = index.get("Projection").get("ProjectionType")
1✔
2285
    if index_query_type == "ALL_ATTRIBUTES" and index_projection_type != "ALL":
1✔
2286
        return False
1✔
2287
    return True
1✔
2288

2289

2290
def get_table_stream_type(
1✔
2291
    account_id: str, region_name: str, table_name_or_arn: str
2292
) -> TableStreamType | None:
2293
    """
2294
    :param account_id: the account id of the table
2295
    :param region_name: the region of the table
2296
    :param table_name_or_arn: the table name or ARN
2297
    :return: a TableStreamViewType object if the table has streams enabled. If not, return None
2298
    """
2299
    if not table_name_or_arn:
1✔
UNCOV
2300
        return
×
2301

2302
    table_name = table_name_or_arn.split(":table/")[-1]
1✔
2303

2304
    is_kinesis = False
1✔
2305
    stream_view_type = None
1✔
2306

2307
    if table_definition := get_store(account_id, region_name).table_definitions.get(table_name):
1✔
2308
        if table_definition.get("KinesisDataStreamDestinationStatus") == "ACTIVE":
1✔
2309
            is_kinesis = True
1✔
2310

2311
    table_arn = arns.dynamodb_table_arn(table_name, account_id=account_id, region_name=region_name)
1✔
2312

2313
    if (
1✔
2314
        stream := dynamodbstreams_api.get_stream_for_table(account_id, region_name, table_arn)
2315
    ) and stream["StreamStatus"] in (StreamStatus.ENABLING, StreamStatus.ENABLED):
2316
        stream_view_type = stream["StreamViewType"]
1✔
2317

2318
    if is_kinesis or stream_view_type:
1✔
2319
        return TableStreamType(stream_view_type, is_kinesis=is_kinesis)
1✔
2320

2321

2322
def get_updated_records(
1✔
2323
    account_id: str,
2324
    region_name: str,
2325
    table_name: str,
2326
    existing_items: list,
2327
    server_url: str,
2328
    table_stream_type: TableStreamType,
2329
) -> RecordsMap:
2330
    """
2331
    Determine the list of record updates, to be sent to a DDB stream after a PartiQL update operation.
2332

2333
    Note: This is currently a fairly expensive operation, as we need to retrieve the list of all items
2334
          from the table, and compare the items to the previously available. This is a limitation as
2335
          we're currently using the DynamoDB Local backend as a blackbox. In future, we should consider hooking
2336
          into the PartiQL query execution inside DynamoDB Local and directly extract the list of updated items.
2337
    """
2338
    result = []
1✔
2339

2340
    key_schema = SchemaExtractor.get_key_schema(table_name, account_id, region_name)
1✔
2341
    before = ItemSet(existing_items, key_schema=key_schema)
1✔
2342
    all_table_items = ItemFinder.get_all_table_items(
1✔
2343
        account_id=account_id,
2344
        region_name=region_name,
2345
        table_name=table_name,
2346
        endpoint_url=server_url,
2347
    )
2348
    after = ItemSet(all_table_items, key_schema=key_schema)
1✔
2349

2350
    def _add_record(item, comparison_set: ItemSet):
1✔
2351
        matching_item = comparison_set.find_item(item)
1✔
2352
        if matching_item == item:
1✔
2353
            return
×
2354

2355
        # determine event type
2356
        if comparison_set == after:
1✔
2357
            if matching_item:
1✔
2358
                return
1✔
2359
            event_name = "REMOVE"
1✔
2360
        else:
2361
            event_name = "INSERT" if not matching_item else "MODIFY"
1✔
2362

2363
        old_image = item if event_name == "REMOVE" else matching_item
1✔
2364
        new_image = matching_item if event_name == "REMOVE" else item
1✔
2365

2366
        # prepare record
2367
        keys = SchemaExtractor.extract_keys_for_schema(item=item, key_schema=key_schema)
1✔
2368

2369
        record = DynamoDBProvider.get_record_template(region_name)
1✔
2370
        record["eventName"] = event_name
1✔
2371
        record["dynamodb"]["Keys"] = keys
1✔
2372
        record["dynamodb"]["SizeBytes"] = _get_size_bytes(item)
1✔
2373

2374
        if table_stream_type.stream_view_type:
1✔
2375
            record["dynamodb"]["StreamViewType"] = table_stream_type.stream_view_type
1✔
2376
        if table_stream_type.needs_new_image:
1✔
UNCOV
2377
            record["dynamodb"]["NewImage"] = new_image
×
2378
        if old_image and table_stream_type.needs_old_image:
1✔
UNCOV
2379
            record["dynamodb"]["OldImage"] = old_image
×
2380

2381
        result.append(record)
1✔
2382

2383
    # loop over items in new item list (find INSERT/MODIFY events)
2384
    for item in after.items_list:
1✔
2385
        _add_record(item, before)
1✔
2386
    # loop over items in old item list (find REMOVE events)
2387
    for item in before.items_list:
1✔
2388
        _add_record(item, after)
1✔
2389

2390
    return {table_name: TableRecords(records=result, table_stream_type=table_stream_type)}
1✔
2391

2392

2393
def create_dynamodb_stream(account_id: str, region_name: str, data, latest_stream_label):
1✔
2394
    stream = data["StreamSpecification"]
1✔
2395
    enabled = stream.get("StreamEnabled")
1✔
2396

2397
    if enabled not in [False, "False"]:
1✔
2398
        table_name = data["TableName"]
1✔
2399
        view_type = stream["StreamViewType"]
1✔
2400

2401
        dynamodbstreams_api.add_dynamodb_stream(
1✔
2402
            account_id=account_id,
2403
            region_name=region_name,
2404
            table_name=table_name,
2405
            latest_stream_label=latest_stream_label,
2406
            view_type=view_type,
2407
            enabled=enabled,
2408
        )
2409

2410

2411
def dynamodb_get_table_stream_specification(account_id: str, region_name: str, table_name: str):
1✔
UNCOV
2412
    try:
×
UNCOV
2413
        table_schema = SchemaExtractor.get_table_schema(
×
2414
            table_name, account_id=account_id, region_name=region_name
2415
        )
UNCOV
2416
        return table_schema["Table"].get("StreamSpecification")
×
UNCOV
2417
    except Exception as e:
×
UNCOV
2418
        LOG.info(
×
2419
            "Unable to get stream specification for table %s: %s %s",
2420
            table_name,
2421
            e,
2422
            traceback.format_exc(),
2423
        )
UNCOV
2424
        raise e
×
2425

2426

2427
def find_item_for_keys_values_in_batch(
1✔
2428
    table_name: str, item_keys: dict, batch: BatchGetResponseMap
2429
) -> AttributeMap | None:
2430
    """
2431
    This function looks up in the existing items for the provided item keys subset. If present, returns the
2432
    full item.
2433
    :param table_name: the table name for the item
2434
    :param item_keys: the request item keys
2435
    :param batch: the values in which to look for the item
2436
    :return: a DynamoDB Item (AttributeMap)
2437
    """
2438
    keys = item_keys.items()
1✔
2439
    for item in batch.get(table_name, []):
1✔
2440
        if keys <= item.items():
1✔
2441
            return item
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc