Repository: getmoto/moto
Issue: DynamoDB : UPDATED_NEW return value is not equivalent to the real one

Hello,

While using the tool I found out that the return value UPDATED_NEW when doing an UpdateItem queries are not the same than what we retrieve from the database with real calls.

The rules around what to returns are a bit blurry, here is what I have:
* For lists, if items are added / removed return the whole list
* For lists, if items are modified return only the modified items
* As a general rule, we only return items that changed

Do not hesitate to tell me if I missed rules and see the PR for my fix attempt.

Thank you

Changed files:
moto/dynamodb2/models.py, moto/dynamodb2/responses.py, tests/test_dynamodb2/test_dynamodb.py

diff --git a/moto/dynamodb2/models.py b/moto/dynamodb2/models.py
index 8e5a61755..65dd2c3f7 100644
--- a/moto/dynamodb2/models.py
+++ b/moto/dynamodb2/models.py
@@ -146,6 +146,9 @@ class DynamoType(object):
     def __eq__(self, other):
         return self.type == other.type and self.value == other.value
 
+    def __ne__(self, other):
+        return self.type != other.type or self.value != other.value
+
     def __lt__(self, other):
         return self.cast_value < other.cast_value
 
diff --git a/moto/dynamodb2/responses.py b/moto/dynamodb2/responses.py
index d3767c3fd..9e3c3a79b 100644
--- a/moto/dynamodb2/responses.py
+++ b/moto/dynamodb2/responses.py
@@ -1,9 +1,12 @@
 from __future__ import unicode_literals
-import itertools
+
+import copy
 import json
-import six
 import re
 
+import itertools
+import six
+
 from moto.core.responses import BaseResponse
 from moto.core.utils import camelcase_to_underscores, amzn_request_id
 from .exceptions import InvalidIndexNameError, InvalidUpdateExpression, ItemSizeTooLarge
@@ -710,7 +713,8 @@ class DynamoHandler(BaseResponse):
         attribute_updates = self.body.get("AttributeUpdates")
         expression_attribute_names = self.body.get("ExpressionAttributeNames", {})
         expression_attribute_values = self.body.get("ExpressionAttributeValues", {})
-        existing_item = self.dynamodb_backend.get_item(name, key)
+        # We need to copy the item in order to avoid it being modified by the update_item operation
+        existing_item = copy.deepcopy(self.dynamodb_backend.get_item(name, key))
         if existing_item:
             existing_attributes = existing_item.to_json()["Attributes"]
         else:
@@ -796,14 +800,39 @@ class DynamoHandler(BaseResponse):
                 k: v for k, v in existing_attributes.items() if k in changed_attributes
             }
         elif return_values == "UPDATED_NEW":
-            item_dict["Attributes"] = {
-                k: v
-                for k, v in item_dict["Attributes"].items()
-                if k in changed_attributes
-            }
+            item_dict["Attributes"] = self._build_updated_new_attributes(
+                existing_attributes, item_dict["Attributes"]
+            )
 
         return dynamo_json_dump(item_dict)
 
+    def _build_updated_new_attributes(self, original, changed):
+        if type(changed) != type(original):
+            return changed
+        else:
+            if type(changed) is dict:
+                return {
+                    key: self._build_updated_new_attributes(
+                        original.get(key, None), changed[key]
+                    )
+                    for key in changed.keys()
+                    if changed[key] != original.get(key, None)
+                }
+            elif type(changed) in (set, list):
+                if len(changed) != len(original):
+                    return changed
+                else:
+                    return [
+                        self._build_updated_new_attributes(
+                            original[index], changed[index]
+                        )
+                        for index in range(len(changed))
+                    ]
+            elif changed != original:
+                return changed
+            else:
+                return None
+
     def describe_limits(self):
         return json.dumps(
             {
diff --git a/tests/test_dynamodb2/test_dynamodb.py b/tests/test_dynamodb2/test_dynamodb.py
index 428b58f81..05c539721 100644
--- a/tests/test_dynamodb2/test_dynamodb.py
+++ b/tests/test_dynamodb2/test_dynamodb.py
@@ -3412,13 +3412,18 @@ def test_update_supports_list_append():
     )
 
     # Update item using list_append expression
-    client.update_item(
+    updated_item = client.update_item(
         TableName="TestTable",
         Key={"SHA256": {"S": "sha-of-file"}},
         UpdateExpression="SET crontab = list_append(crontab, :i)",
         ExpressionAttributeValues={":i": {"L": [{"S": "bar2"}]}},
+        ReturnValues="UPDATED_NEW",
     )
 
+    # Verify updated item is correct
+    updated_item["Attributes"].should.equal(
+        {"crontab": {"L": [{"S": "bar1"}, {"S": "bar2"}]}}
+    )
     # Verify item is appended to the existing list
     result = client.get_item(
         TableName="TestTable", Key={"SHA256": {"S": "sha-of-file"}}
@@ -3451,15 +3456,19 @@ def test_update_supports_nested_list_append():
     )
 
     # Update item using list_append expression
-    client.update_item(
+    updated_item = client.update_item(
         TableName="TestTable",
         Key={"id": {"S": "nested_list_append"}},
         UpdateExpression="SET a.#b = list_append(a.#b, :i)",
         ExpressionAttributeValues={":i": {"L": [{"S": "bar2"}]}},
         ExpressionAttributeNames={"#b": "b"},
+        ReturnValues="UPDATED_NEW",
     )
 
-    # Verify item is appended to the existing list
+    # Verify updated item is correct
+    updated_item["Attributes"].should.equal(
+        {"a": {"M": {"b": {"L": [{"S": "bar1"}, {"S": "bar2"}]}}}}
+    )
     result = client.get_item(
         TableName="TestTable", Key={"id": {"S": "nested_list_append"}}
     )["Item"]
@@ -3491,14 +3500,19 @@ def test_update_supports_multiple_levels_nested_list_append():
     )
 
     # Update item using list_append expression
-    client.update_item(
+    updated_item = client.update_item(
         TableName="TestTable",
         Key={"id": {"S": "nested_list_append"}},
         UpdateExpression="SET a.#b.c = list_append(a.#b.#c, :i)",
         ExpressionAttributeValues={":i": {"L": [{"S": "bar2"}]}},
         ExpressionAttributeNames={"#b": "b", "#c": "c"},
+        ReturnValues="UPDATED_NEW",
     )
 
+    # Verify updated item is correct
+    updated_item["Attributes"].should.equal(
+        {"a": {"M": {"b": {"M": {"c": {"L": [{"S": "bar1"}, {"S": "bar2"}]}}}}}}
+    )
     # Verify item is appended to the existing list
     result = client.get_item(
         TableName="TestTable", Key={"id": {"S": "nested_list_append"}}
@@ -3532,14 +3546,19 @@ def test_update_supports_nested_list_append_onto_another_list():
     )
 
     # Update item using list_append expression
-    client.update_item(
+    updated_item = client.update_item(
         TableName="TestTable",
         Key={"id": {"S": "list_append_another"}},
         UpdateExpression="SET a.#c = list_append(a.#b, :i)",
         ExpressionAttributeValues={":i": {"L": [{"S": "bar2"}]}},
         ExpressionAttributeNames={"#b": "b", "#c": "c"},
+        ReturnValues="UPDATED_NEW",
     )
 
+    # Verify updated item is correct
+    updated_item["Attributes"].should.equal(
+        {"a": {"M": {"c": {"L": [{"S": "bar1"}, {"S": "bar2"}]}}}}
+    )
     # Verify item is appended to the existing list
     result = client.get_item(
         TableName="TestTable", Key={"id": {"S": "list_append_another"}}
@@ -3582,13 +3601,18 @@ def test_update_supports_list_append_maps():
     )
 
     # Update item using list_append expression
-    client.update_item(
+    updated_item = client.update_item(
         TableName="TestTable",
         Key={"id": {"S": "nested_list_append"}, "rid": {"S": "range_key"}},
         UpdateExpression="SET a = list_append(a, :i)",
         ExpressionAttributeValues={":i": {"L": [{"M": {"b": {"S": "bar2"}}}]}},
+        ReturnValues="UPDATED_NEW",
     )
 
+    # Verify updated item is correct
+    updated_item["Attributes"].should.equal(
+        {"a": {"L": [{"M": {"b": {"S": "bar1"}}}, {"M": {"b": {"S": "bar2"}}}]}}
+    )
     # Verify item is appended to the existing list
     result = client.query(
         TableName="TestTable",
@@ -3624,11 +3648,18 @@ def test_update_supports_list_append_with_nested_if_not_exists_operation():
     table = dynamo.Table(table_name)
 
     table.put_item(Item={"Id": "item-id", "nest1": {"nest2": {}}})
-    table.update_item(
+    updated_item = table.update_item(
         Key={"Id": "item-id"},
         UpdateExpression="SET nest1.nest2.event_history = list_append(if_not_exists(nest1.nest2.event_history, :empty_list), :new_value)",
         ExpressionAttributeValues={":empty_list": [], ":new_value": ["some_value"]},
+        ReturnValues="UPDATED_NEW",
     )
+
+    # Verify updated item is correct
+    updated_item["Attributes"].should.equal(
+        {"nest1": {"nest2": {"event_history": ["some_value"]}}}
+    )
+
     table.get_item(Key={"Id": "item-id"})["Item"].should.equal(
         {"Id": "item-id", "nest1": {"nest2": {"event_history": ["some_value"]}}}
     )
@@ -3649,11 +3680,18 @@ def test_update_supports_list_append_with_nested_if_not_exists_operation_and_pro
     table = dynamo.Table(table_name)
 
     table.put_item(Item={"Id": "item-id", "event_history": ["other_value"]})
-    table.update_item(
+    updated_item = table.update_item(
         Key={"Id": "item-id"},
         UpdateExpression="SET event_history = list_append(if_not_exists(event_history, :empty_list), :new_value)",
         ExpressionAttributeValues={":empty_list": [], ":new_value": ["some_value"]},
+        ReturnValues="UPDATED_NEW",
     )
+
+    # Verify updated item is correct
+    updated_item["Attributes"].should.equal(
+        {"event_history": ["other_value", "some_value"]}
+    )
+
     table.get_item(Key={"Id": "item-id"})["Item"].should.equal(
         {"Id": "item-id", "event_history": ["other_value", "some_value"]}
     )
@@ -3740,11 +3778,16 @@ def test_update_nested_item_if_original_value_is_none():
     )
     table = dynamo.Table("origin-rbu-dev")
     table.put_item(Item={"job_id": "a", "job_details": {"job_name": None}})
-    table.update_item(
+    updated_item = table.update_item(
         Key={"job_id": "a"},
         UpdateExpression="SET job_details.job_name = :output",
         ExpressionAttributeValues={":output": "updated"},
+        ReturnValues="UPDATED_NEW",
     )
+
+    # Verify updated item is correct
+    updated_item["Attributes"].should.equal({"job_details": {"job_name": "updated"}})
+
     table.scan()["Items"][0]["job_details"]["job_name"].should.equal("updated")
 
 
@@ -3760,11 +3803,16 @@ def test_allow_update_to_item_with_different_type():
     table = dynamo.Table("origin-rbu-dev")
     table.put_item(Item={"job_id": "a", "job_details": {"job_name": {"nested": "yes"}}})
     table.put_item(Item={"job_id": "b", "job_details": {"job_name": {"nested": "yes"}}})
-    table.update_item(
+    updated_item = table.update_item(
         Key={"job_id": "a"},
         UpdateExpression="SET job_details.job_name = :output",
         ExpressionAttributeValues={":output": "updated"},
+        ReturnValues="UPDATED_NEW",
     )
+
+    # Verify updated item is correct
+    updated_item["Attributes"].should.equal({"job_details": {"job_name": "updated"}})
+
     table.get_item(Key={"job_id": "a"})["Item"]["job_details"][
         "job_name"
     ].should.be.equal("updated")