Repository: getmoto/moto
Pull request: Improve DDB transaction validation
Solution for https://github.com/spulec/moto/issues/5496

Problem statement:
Dynamodb mock, missing validation for transactions (transact_write_items)
Hello,
creating transaction with conflicting operations seem to pass locally with dynamodb_mock. For example creating Add and Delete operation of the same item will fail on the stack but pass locally.


Code to reproduce.
```
#!/usr/bin/env python
import boto3
import pytest
from moto import mock_dynamodb



@pytest.fixture()
def mock_table():
    with mock_dynamodb():

        dynamodb = boto3.resource("dynamodb", region_name="eu-west-1")

        # Create the table
        dynamodb.create_table(
            TableName="test_table",
            KeySchema=[
                {"AttributeName": "pk", "KeyType": "HASH"},
                {"AttributeName": "sk", "KeyType": "RANGE"},
            ],
            AttributeDefinitions=[
                {"AttributeName": "pk", "AttributeType": "S"},
                {"AttributeName": "sk", "AttributeType": "S"},
            ],
            BillingMode="PAY_PER_REQUEST",
        )
        yield dynamodb

def main():
    dc = boto3.client("dynamodb")
    dc.transact_write_items(
        TransactItems = [
            {
                "Put": {
                    "Item": {"pk": {"S": 'test'}, "sk": {"S": 'test'}},
                    "TableName": "test_table",
                },
                "Delete": {
                    "Key": {"pk": {"S": 'test'}, "sk": {"S": 'test'}},
                    "TableName": "test_table",
                }
            }
        ]
    )

def test__main(mock_table):
    main()

if __name__ == "__main__":
    main()

```

If you run `pytest` locally it will pass, but if you run it on real dynamodb table you will get
```
Traceback (most recent call last):
  File "./test.py", line 50, in <module>
    main()
  File "./test.py", line 40, in main
    "TableName": "test_table",
  File "/home/luka/.virtualenvs/tg-scheduler/lib/python3.7/site-packages/botocore/client.py", line 514, in _api_call
    return self._make_api_call(operation_name, kwargs)
  File "/home/luka/.virtualenvs/tg-scheduler/lib/python3.7/site-packages/botocore/client.py", line 938, in _make_api_call
    raise error_class(parsed_response, operation_name)
botocore.exceptions.ClientError: An error occurred (ValidationException) when calling the TransactWriteItems operation: TransactItems can only contain one of Check, Put
, Update or Delete
```
Moto version: `moto==4.0.2 and boto3==1.24.54` but the same happens on `moto==3.0.7`.

Hints from issue discussion:
Thanks for raising this @LukaK - marking it as an enhancement to add this validation.
If you want to implement this yourself, PR's are always welcome!

Commit message: refactor: check double transactions in single dict

Patch to review:
diff --git a/moto/dynamodb/exceptions.py b/moto/dynamodb/exceptions.py
index 973a4721f79f..9f572764fb65 100644
--- a/moto/dynamodb/exceptions.py
+++ b/moto/dynamodb/exceptions.py
@@ -323,3 +323,12 @@ class InvalidConversion(JsonRESTError):
     def __init__(self):
         er = "SerializationException"
         super().__init__(er, "NUMBER_VALUE cannot be converted to String")
+
+
+class TransactWriteSingleOpException(MockValidationException):
+    there_can_be_only_one = (
+        "TransactItems can only contain one of Check, Put, Update or Delete"
+    )
+
+    def __init__(self):
+        super().__init__(self.there_can_be_only_one)
diff --git a/moto/dynamodb/models/__init__.py b/moto/dynamodb/models/__init__.py
index c0f5f4354d35..efab23b779ac 100644
--- a/moto/dynamodb/models/__init__.py
+++ b/moto/dynamodb/models/__init__.py
@@ -32,6 +32,7 @@
     StreamAlreadyEnabledException,
     MockValidationException,
     InvalidConversion,
+    TransactWriteSingleOpException,
 )
 from moto.dynamodb.models.utilities import bytesize
 from moto.dynamodb.models.dynamo_type import DynamoType
@@ -1655,7 +1656,17 @@ def check_unicity(table_name, key):
             target_items.add(item)
 
         errors = []  # [(Code, Message, Item), ..]
+        ddb_verbs = ["ConditionCheck", "Put", "Delete", "Update"]
         for item in transact_items:
+            # check transact writes are not performing multiple operations
+            # in the same item
+            for ddb_verb in ddb_verbs:
+                if ddb_verb in item:
+                    verb1 = ddb_verb
+                    for verb2 in ddb_verbs:
+                        if verb2 in item and verb2 != verb1:
+                            raise TransactWriteSingleOpException
+
             try:
                 if "ConditionCheck" in item:
                     item = item["ConditionCheck"]
@@ -1682,6 +1693,7 @@ def check_unicity(table_name, key):
                     item = item["Put"]
                     attrs = item["Item"]
                     table_name = item["TableName"]
+                    check_unicity(table_name, item)
                     condition_expression = item.get("ConditionExpression", None)
                     expression_attribute_names = item.get(
                         "ExpressionAttributeNames", None
diff --git a/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py b/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py
index 047d569b579e..a880ed1ab149 100644
--- a/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py
+++ b/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py
@@ -761,3 +761,42 @@ def test_query_begins_with_without_brackets():
         'Invalid KeyConditionExpression: Syntax error; token: "sk"'
     )
     err["Code"].should.equal("ValidationException")
+
+
+@mock_dynamodb
+def test_transact_write_items_multiple_operations_fail():
+
+    # Setup
+    table_schema = {
+        "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}],
+        "AttributeDefinitions": [{"AttributeName": "id", "AttributeType": "S"}],
+    }
+    dynamodb = boto3.client("dynamodb", region_name="us-east-1")
+    table_name = "test-table"
+    dynamodb.create_table(
+        TableName=table_name, BillingMode="PAY_PER_REQUEST", **table_schema
+    )
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        dynamodb.transact_write_items(
+            TransactItems=[
+                {
+                    "Put": {
+                        "Item": {"id": {"S": "test"}},
+                        "TableName": table_name,
+                    },
+                    "Delete": {
+                        "Key": {"id": {"S": "test"}},
+                        "TableName": table_name,
+                    },
+                }
+            ]
+        )
+        # Verify
+        err = exc.value.response["Error"]
+        assert err["Code"] == "ValidationException"
+        assert (
+            err["Message"]
+            == "TransactItems can only contain one of Check, Put, Update or Delete"
+        )


Code review comments:
diff_hunk: @@ -761,3 +761,42 @@ def test_query_begins_with_without_brackets():
         'Invalid KeyConditionExpression: Syntax error; token: "sk"'
     )
     err["Code"].should.equal("ValidationException")
+
+
+@mock_dynamodb
+def test_transact_write_items_multiple_operations_fail():
+
+    # Setup
+    table_schema = {
+        "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}],
+        "AttributeDefinitions": [{"AttributeName": "id", "AttributeType": "S"}],
+    }
+    dynamodb = boto3.client("dynamodb", region_name="us-east-1")
+    table_name = "test-table"
+    dynamodb.create_table(
+        TableName=table_name, BillingMode="PAY_PER_REQUEST", **table_schema
+    )
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        dynamodb.transact_write_items(
+            TransactItems=[
+                {
+                    "Put": {
+                        "Item": {"id": {"S": "test"}},
+                        "TableName": table_name,
+                    },
+                    "Delete": {
+                        "Key": {"id": {"S": "test"}},
+                        "TableName": table_name,
+                    },
+                }
+            ]
+        )
+        # Verify
+        err = exc.value.response["Error"]
+        assert err["Code"] == "ValidationException"
+        assert (
+            err["Message"]
+            == "TransactItems can only contain one of Check, Put, Update or Delete"
+        )
path: tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py
text: @user1:
```suggestion
    # Verify
    err = exc.value.response["Error"]
    assert err["Code"] == "ValidationException"
    assert (
        err["Message"]
        == "TransactItems can only contain one of Check, Put, Update or Delete"
)
```

As it stands, if `transact_write_items` throws an exception, the verification code is never run. 

@author:
Good catch