Repository: getmoto/moto

Issue:
DynamoDb2.client.transact_write_items transactions on same item
transact_write_items does not allow several transactions on the same item

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#DynamoDB.Client.transact_write_items
> TransactWriteItems is a synchronous write operation that groups up to 25 action requests. These actions can target items in different tables, but not in different Amazon Web Services accounts or Regions, and no two actions can target the same item

moto should also fail the transaction in this case (currently one can perform two transactions on the same item)


Discussion:
Thanks @Guriido - marking it as an enhancement to implement this validation.

Patch:
diff --git a/moto/dynamodb2/exceptions.py b/moto/dynamodb2/exceptions.py
--- a/moto/dynamodb2/exceptions.py
+++ b/moto/dynamodb2/exceptions.py
@@ -174,6 +174,13 @@ def __init__(self, errors):
         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
--- a/moto/dynamodb2/models/__init__.py
+++ b/moto/dynamodb2/models/__init__.py
@@ -23,6 +23,7 @@
     TransactionCanceledException,
     EmptyKeyAttributeException,
     InvalidAttributeTypeError,
+    MultipleTransactionsException,
 )
 from moto.dynamodb2.models.utilities import bytesize
 from moto.dynamodb2.models.dynamo_type import DynamoType
@@ -1566,6 +1567,14 @@ def describe_time_to_live(self, table_name):
     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 @@ def transact_write_items(self, transact_items):
                     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 @@ def transact_write_items(self, transact_items):
                     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 @@ def transact_write_items(self, transact_items):
                     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 @@ def transact_write_items(self, transact_items):
                 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
--- a/moto/dynamodb2/responses.py
+++ b/moto/dynamodb2/responses.py
@@ -1127,6 +1127,9 @@ def transact_write_items(self):
         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)
 


Test patch:
diff --git a/tests/test_dynamodb2/exceptions/test_dynamodb_exceptions.py b/tests/test_dynamodb2/exceptions/test_dynamodb_exceptions.py
--- 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"
+    )