diff --git a/moto/dynamodb2/exceptions.py b/moto/dynamodb2/exceptions.py index 5e7d63b79..31514923f 100644 --- a/moto/dynamodb2/exceptions.py +++ b/moto/dynamodb2/exceptions.py @@ -198,6 +198,13 @@ class TransactionCanceledException(ValueError): super().__init__(msg) +class MultipleTransactionsException(MockValidationException): + msg = "Transaction request cannot include multiple operations on one item" + + def __init__(self): + super().__init__(self.msg) + + class EmptyKeyAttributeException(MockValidationException): empty_str_msg = "One or more parameter values were invalid: An AttributeValue may not contain an empty string" # AWS has a different message for empty index keys diff --git a/moto/dynamodb2/models/__init__.py b/moto/dynamodb2/models/__init__.py index adb51a1c7..6c03482c7 100644 --- a/moto/dynamodb2/models/__init__.py +++ b/moto/dynamodb2/models/__init__.py @@ -23,6 +23,7 @@ from moto.dynamodb2.exceptions import ( TransactionCanceledException, EmptyKeyAttributeException, InvalidAttributeTypeError, + MultipleTransactionsException, ) from moto.dynamodb2.models.utilities import bytesize from moto.dynamodb2.models.dynamo_type import DynamoType @@ -1566,6 +1567,14 @@ class DynamoDBBackend(BaseBackend): def transact_write_items(self, transact_items): # Create a backup in case any of the transactions fail original_table_state = copy.deepcopy(self.tables) + target_items = set() + + def check_unicity(table_name, key): + item = (str(table_name), str(key)) + if item in target_items: + raise MultipleTransactionsException() + target_items.add(item) + errors = [] for item in transact_items: try: @@ -1573,6 +1582,7 @@ class DynamoDBBackend(BaseBackend): item = item["ConditionCheck"] key = item["Key"] table_name = item["TableName"] + check_unicity(table_name, key) condition_expression = item.get("ConditionExpression", None) expression_attribute_names = item.get( "ExpressionAttributeNames", None @@ -1611,6 +1621,7 @@ class DynamoDBBackend(BaseBackend): item = item["Delete"] key = item["Key"] table_name = item["TableName"] + check_unicity(table_name, key) condition_expression = item.get("ConditionExpression", None) expression_attribute_names = item.get( "ExpressionAttributeNames", None @@ -1629,6 +1640,7 @@ class DynamoDBBackend(BaseBackend): item = item["Update"] key = item["Key"] table_name = item["TableName"] + check_unicity(table_name, key) update_expression = item["UpdateExpression"] condition_expression = item.get("ConditionExpression", None) expression_attribute_names = item.get( @@ -1648,6 +1660,10 @@ class DynamoDBBackend(BaseBackend): else: raise ValueError errors.append(None) + except MultipleTransactionsException: + # Rollback to the original state, and reraise the error + self.tables = original_table_state + raise MultipleTransactionsException() except Exception as e: # noqa: E722 Do not use bare except errors.append(type(e).__name__) if any(errors): diff --git a/moto/dynamodb2/responses.py b/moto/dynamodb2/responses.py index e1d669c58..56115ba4b 100644 --- a/moto/dynamodb2/responses.py +++ b/moto/dynamodb2/responses.py @@ -1127,6 +1127,9 @@ class DynamoHandler(BaseResponse): except TransactionCanceledException as e: er = "com.amazonaws.dynamodb.v20111205#TransactionCanceledException" return self.error(er, str(e)) + except MockValidationException as mve: + er = "com.amazonaws.dynamodb.v20111205#ValidationException" + return self.error(er, mve.exception_msg) response = {"ConsumedCapacity": [], "ItemCollectionMetrics": {}} return dynamo_json_dump(response) diff --git a/tests/test_dynamodb2/exceptions/test_dynamodb_exceptions.py b/tests/test_dynamodb2/exceptions/test_dynamodb_exceptions.py index 61b5e0354..7f0b623b9 100644 --- a/tests/test_dynamodb2/exceptions/test_dynamodb_exceptions.py +++ b/tests/test_dynamodb2/exceptions/test_dynamodb_exceptions.py @@ -1,12 +1,10 @@ import boto3 import pytest import sure # noqa # pylint: disable=unused-import - -from botocore.exceptions import ClientError from boto3.dynamodb.conditions import Key +from botocore.exceptions import ClientError from moto import mock_dynamodb2 - table_schema = { "KeySchema": [{"AttributeName": "partitionKey", "KeyType": "HASH"}], "GlobalSecondaryIndexes": [ @@ -466,3 +464,41 @@ def test_creating_table_with_0_global_indexes(): err["Message"].should.equal( "One or more parameter values were invalid: List of GlobalSecondaryIndexes is empty" ) + + +@mock_dynamodb2 +def test_multiple_transactions_on_same_item(): + table_schema = { + "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}], + "AttributeDefinitions": [{"AttributeName": "id", "AttributeType": "S"},], + } + dynamodb = boto3.client("dynamodb", region_name="us-east-1") + dynamodb.create_table( + TableName="test-table", BillingMode="PAY_PER_REQUEST", **table_schema + ) + # Insert an item + dynamodb.put_item(TableName="test-table", Item={"id": {"S": "foo"}}) + + def update_email_transact(email): + return { + "Update": { + "Key": {"id": {"S": "foo"}}, + "TableName": "test-table", + "UpdateExpression": "SET #e = :v", + "ExpressionAttributeNames": {"#e": "email_address"}, + "ExpressionAttributeValues": {":v": {"S": email}}, + } + } + + with pytest.raises(ClientError) as exc: + dynamodb.transact_write_items( + TransactItems=[ + update_email_transact("test1@moto.com"), + update_email_transact("test2@moto.com"), + ] + ) + err = exc.value.response["Error"] + err["Code"].should.equal("ValidationException") + err["Message"].should.equal( + "Transaction request cannot include multiple operations on one item" + )