diff --git a/docs/conditional.rst b/docs/conditional.rst index 74d3fb74..db704604 100644 --- a/docs/conditional.rst +++ b/docs/conditional.rst @@ -158,5 +158,30 @@ You can check for conditional operation failures by inspecting the cause of the try: thread_item.save(Thread.forum_name.exists()) except PutError as e: - if e.cause_response_code = "ConditionalCheckFailedException": + if e.cause_response_code == "ConditionalCheckFailedException": raise ThreadDidNotExistError() + +DynamoDB can also return the old item when a conditional write fails. ``Model.save()``, +``Model.update()``, and ``Model.delete()`` accept ``return_values_on_condition_failure`` +for this DynamoDB option: + +.. code-block:: python + + from pynamodb.constants import ALL_OLD + from pynamodb.exceptions import PutError + + try: + thread_item.save( + condition=Thread.forum_name.exists(), + return_values_on_condition_failure=ALL_OLD, + ) + except PutError as e: + if e.cause_response_code == "ConditionalCheckFailedException": + old_item = e.cause.response.get('Item') + +``return_values_on_condition_failure=ALL_OLD`` can also be passed to ``update()`` and +``delete()``. PynamoDB still raises the existing write error wrapper +(:class:`~pynamodb.exceptions.PutError`, :class:`~pynamodb.exceptions.UpdateError`, or +:class:`~pynamodb.exceptions.DeleteError`) when the condition fails. If DynamoDB includes +the old item, it is available as a raw DynamoDB AttributeValue map through the wrapped +botocore error response, for example ``e.cause.response.get('Item')``. diff --git a/docs/transaction.rst b/docs/transaction.rst index 30c0b2dd..db6c1ce0 100644 --- a/docs/transaction.rst +++ b/docs/transaction.rst @@ -145,6 +145,7 @@ The ``Delete`` operation functions similarly to ``Model.delete``. * ``model`` (required) * ``condition`` (optional) - of type :py:class:`Condition ` (see :ref:`conditional_operations`) +* ``return_values`` (optional) - the values that should be returned if the condition fails (see `Delete ReturnValuesOnConditionCheckFailure`_ in the DynamoDB API reference) .. code-block:: python @@ -162,7 +163,7 @@ The ``Put`` operation functions similarly to ``Model.save``. * ``model`` (required) * ``condition`` (optional) - of type :py:class:`Condition ` (see :ref:`conditional_operations`) -* ``return_values`` (optional) - the values that should be returned if the condition fails ((see `Put ReturnValuesOnConditionCheckFailure`_ in the DynamoDB API reference) +* ``return_values`` (optional) - the values that should be returned if the condition fails (see `Put ReturnValuesOnConditionCheckFailure`_ in the DynamoDB API reference) .. code-block:: python @@ -228,5 +229,6 @@ You can expect some new error types with transactions, such as: .. _Update ReturnValuesOnConditionCheckFailure: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Update.html#DDB-Type-Update-ReturnValuesOnConditionCheckFailure> .. _Put ReturnValuesOnConditionCheckFailure: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Put.html#DDB-Type-Put-ReturnValuesOnConditionCheckFailure +.. _Delete ReturnValuesOnConditionCheckFailure: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Delete.html#DDB-Type-Delete-ReturnValuesOnConditionCheckFailure .. _TransactWriteItems Errors: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html#API_TransactWriteItems_Errors .. _TransactGetItems Errors: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactGetItems.html#API_TransactGetItems_Errors diff --git a/pynamodb/connection/base.py b/pynamodb/connection/base.py index 301ee171..0becbfe8 100644 --- a/pynamodb/connection/base.py +++ b/pynamodb/connection/base.py @@ -364,10 +364,13 @@ def _make_api_call(self, operation_name: str, operation_kwargs: Dict) -> Dict: try: return self.client._make_api_call(operation_name, operation_kwargs) except ClientError as e: - resp_metadata = e.response.get('ResponseMetadata', {}).get('HTTPHeaders', {}) - cancellation_reasons = e.response.get('CancellationReasons', []) + error_response = cast(Dict[str, Any], e.response) + resp_metadata = error_response.get('ResponseMetadata', {}).get('HTTPHeaders', {}) + cancellation_reasons = error_response.get('CancellationReasons', []) - botocore_props = {'Error': e.response.get('Error', {})} + botocore_props = {'Error': error_response.get('Error', {})} + if ITEM in error_response: + botocore_props[ITEM] = error_response[ITEM] verbose_props = { 'request_id': resp_metadata.get('x-amzn-requestid', ''), 'table_name': self._get_table_name_for_error_context(operation_kwargs), @@ -769,7 +772,7 @@ def get_return_values_on_condition_failure_map( """ Builds the return values map that is common to several operations """ - if return_values_on_condition_failure.upper() not in RETURN_VALUES_VALUES: + if return_values_on_condition_failure.upper() not in RETURN_VALUES_ON_CONDITION_FAILURE_VALUES: raise ValueError("{} must be one of {}".format( RETURN_VALUES_ON_CONDITION_FAILURE, RETURN_VALUES_ON_CONDITION_FAILURE_VALUES @@ -859,6 +862,7 @@ def delete_item( range_key: Optional[str] = None, condition: Optional[Condition] = None, return_values: Optional[str] = None, + return_values_on_condition_failure: Optional[str] = None, return_consumed_capacity: Optional[str] = None, return_item_collection_metrics: Optional[str] = None, ) -> Dict: @@ -871,6 +875,7 @@ def delete_item( range_key=range_key, condition=condition, return_values=return_values, + return_values_on_condition_failure=return_values_on_condition_failure, return_consumed_capacity=return_consumed_capacity, return_item_collection_metrics=return_item_collection_metrics ) @@ -889,6 +894,7 @@ def update_item( return_consumed_capacity: Optional[str] = None, return_item_collection_metrics: Optional[str] = None, return_values: Optional[str] = None, + return_values_on_condition_failure: Optional[str] = None, ) -> Dict: """ Performs the UpdateItem operation @@ -903,6 +909,7 @@ def update_item( actions=actions, condition=condition, return_values=return_values, + return_values_on_condition_failure=return_values_on_condition_failure, return_consumed_capacity=return_consumed_capacity, return_item_collection_metrics=return_item_collection_metrics, ) @@ -919,6 +926,7 @@ def put_item( attributes: Optional[Any] = None, condition: Optional[Condition] = None, return_values: Optional[str] = None, + return_values_on_condition_failure: Optional[str] = None, return_consumed_capacity: Optional[str] = None, return_item_collection_metrics: Optional[str] = None, ) -> Dict: @@ -933,6 +941,7 @@ def put_item( attributes=attributes, condition=condition, return_values=return_values, + return_values_on_condition_failure=return_values_on_condition_failure, return_consumed_capacity=return_consumed_capacity, return_item_collection_metrics=return_item_collection_metrics ) diff --git a/pynamodb/connection/table.py b/pynamodb/connection/table.py index 5e70ba5c..bb22be7f 100644 --- a/pynamodb/connection/table.py +++ b/pynamodb/connection/table.py @@ -90,6 +90,7 @@ def delete_item( range_key: Optional[str] = None, condition: Optional[Condition] = None, return_values: Optional[str] = None, + return_values_on_condition_failure: Optional[str] = None, return_consumed_capacity: Optional[str] = None, return_item_collection_metrics: Optional[str] = None, ) -> Dict: @@ -102,6 +103,7 @@ def delete_item( range_key=range_key, condition=condition, return_values=return_values, + return_values_on_condition_failure=return_values_on_condition_failure, return_consumed_capacity=return_consumed_capacity, return_item_collection_metrics=return_item_collection_metrics, ) @@ -115,6 +117,7 @@ def update_item( return_consumed_capacity: Optional[str] = None, return_item_collection_metrics: Optional[str] = None, return_values: Optional[str] = None, + return_values_on_condition_failure: Optional[str] = None, ) -> Dict: """ Performs the UpdateItem operation @@ -128,6 +131,7 @@ def update_item( return_consumed_capacity=return_consumed_capacity, return_item_collection_metrics=return_item_collection_metrics, return_values=return_values, + return_values_on_condition_failure=return_values_on_condition_failure, ) def put_item( @@ -137,6 +141,7 @@ def put_item( attributes: Optional[Any] = None, condition: Optional[Condition] = None, return_values: Optional[str] = None, + return_values_on_condition_failure: Optional[str] = None, return_consumed_capacity: Optional[str] = None, return_item_collection_metrics: Optional[str] = None, ) -> Dict: @@ -150,6 +155,7 @@ def put_item( attributes=attributes, condition=condition, return_values=return_values, + return_values_on_condition_failure=return_values_on_condition_failure, return_consumed_capacity=return_consumed_capacity, return_item_collection_metrics=return_item_collection_metrics, ) diff --git a/pynamodb/models.py b/pynamodb/models.py index 8e14918e..35e15ce5 100644 --- a/pynamodb/models.py +++ b/pynamodb/models.py @@ -394,7 +394,13 @@ def batch_write(cls: Type[_T], auto_commit: bool = True) -> BatchWrite[_T]: """ return BatchWrite(cls, auto_commit=auto_commit) - def delete(self, condition: Optional[Condition] = None, *, add_version_condition: bool = True) -> Any: + def delete( + self, + condition: Optional[Condition] = None, + *, + add_version_condition: bool = True, + return_values_on_condition_failure: Optional[str] = None, + ) -> Any: """ Deletes this object from DynamoDB. @@ -409,9 +415,21 @@ def delete(self, condition: Optional[Condition] = None, *, add_version_condition if add_version_condition and version_condition is not None: condition &= version_condition - return self._get_connection().delete_item(hk_value, range_key=rk_value, condition=condition) + return self._get_connection().delete_item( + hk_value, + range_key=rk_value, + condition=condition, + return_values_on_condition_failure=return_values_on_condition_failure, + ) - def update(self, actions: List[Action], condition: Optional[Condition] = None, *, add_version_condition: bool = True) -> Any: + def update( + self, + actions: List[Action], + condition: Optional[Condition] = None, + *, + add_version_condition: bool = True, + return_values_on_condition_failure: Optional[str] = None, + ) -> Any: """ Updates an item using the UpdateItem operation. @@ -432,7 +450,14 @@ def update(self, actions: List[Action], condition: Optional[Condition] = None, * if add_version_condition and version_condition is not None: condition &= version_condition - data = self._get_connection().update_item(hk_value, range_key=rk_value, return_values=ALL_NEW, condition=condition, actions=actions) + data = self._get_connection().update_item( + hk_value, + range_key=rk_value, + return_values=ALL_NEW, + condition=condition, + actions=actions, + return_values_on_condition_failure=return_values_on_condition_failure, + ) item_data = data[ATTRIBUTES] stored_cls = self._get_discriminator_class(item_data) if stored_cls and stored_cls != type(self): @@ -440,11 +465,21 @@ def update(self, actions: List[Action], condition: Optional[Condition] = None, * self.deserialize(item_data) return data - def save(self, condition: Optional[Condition] = None, *, add_version_condition: bool = True) -> Dict[str, Any]: + def save( + self, + condition: Optional[Condition] = None, + *, + add_version_condition: bool = True, + return_values_on_condition_failure: Optional[str] = None, + ) -> Dict[str, Any]: """ Save this object to dynamodb """ - args, kwargs = self._get_save_args(condition=condition, add_version_condition=add_version_condition) + args, kwargs = self._get_save_args( + condition=condition, + add_version_condition=add_version_condition, + return_values_on_condition_failure=return_values_on_condition_failure, + ) data = self._get_connection().put_item(*args, **kwargs) self.update_local_version_attribute() return data @@ -894,7 +929,13 @@ def _get_schema(cls) -> ModelSchema: return schema - def _get_save_args(self, condition: Optional[Condition] = None, *, add_version_condition: bool = True) -> Tuple[Iterable[Any], Dict[str, Any]]: + def _get_save_args( + self, + condition: Optional[Condition] = None, + *, + add_version_condition: bool = True, + return_values_on_condition_failure: Optional[str] = None, + ) -> Tuple[Iterable[Any], Dict[str, Any]]: """ Gets the proper *args, **kwargs for saving and retrieving this object @@ -921,6 +962,7 @@ def _get_save_args(self, condition: Optional[Condition] = None, *, add_version_c condition &= version_condition kwargs['attributes'] = attribute_values kwargs['condition'] = condition + kwargs['return_values_on_condition_failure'] = return_values_on_condition_failure return args, kwargs def _get_hash_range_key_serialized_values(self) -> Tuple[Any, Optional[Any]]: diff --git a/pynamodb/transactions.py b/pynamodb/transactions.py index 5b141097..d07f2d3e 100644 --- a/pynamodb/transactions.py +++ b/pynamodb/transactions.py @@ -100,9 +100,17 @@ def condition_check(self, model_cls: Type[_M], hash_key: _KeyType, range_key: Op ) self._condition_check_items.append(operation_kwargs) - def delete(self, model: _M, condition: Optional[Condition] = None, *, add_version_condition: bool = True) -> None: + def delete( + self, + model: _M, + condition: Optional[Condition] = None, + return_values: Optional[str] = None, + *, + add_version_condition: bool = True, + ) -> None: operation_kwargs = model.get_delete_kwargs_from_instance( condition=condition, + return_values_on_condition_failure=return_values, add_version_condition=add_version_condition, ) self._delete_items.append(operation_kwargs) diff --git a/tests/test_base_connection.py b/tests/test_base_connection.py index c90acd27..e17009b5 100644 --- a/tests/test_base_connection.py +++ b/tests/test_base_connection.py @@ -24,7 +24,7 @@ TableError, DeleteError, PutError, ScanError, GetError, UpdateError, TableDoesNotExist, VerboseClientError) from pynamodb.constants import ( UNPROCESSED_ITEMS, STRING, BINARY, DEFAULT_ENCODING, TABLE_KEY, - PAY_PER_REQUEST_BILLING_MODE) + PAY_PER_REQUEST_BILLING_MODE, ALL_NEW, ALL_OLD, NONE, UPDATED_OLD, UPDATED_NEW) from pynamodb.expressions.operand import Path, Value from pynamodb.expressions.update import SetAction from .data import DESCRIBE_TABLE_DATA, GET_ITEM_DATA, LIST_TABLE_DATA @@ -60,6 +60,21 @@ def test_meta_table_has_index_name(meta_table): assert not meta_table.has_index_name("NonExistentIndexName") +@pytest.mark.parametrize('return_values', [NONE, ALL_OLD]) +def test_get_return_values_on_condition_failure_map__valid(return_values): + conn = Connection(REGION) + assert conn.get_return_values_on_condition_failure_map(return_values) == { + 'ReturnValuesOnConditionCheckFailure': return_values, + } + + +@pytest.mark.parametrize('return_values', [ALL_NEW, UPDATED_OLD, UPDATED_NEW, 'badvalue']) +def test_get_return_values_on_condition_failure_map__invalid(return_values): + conn = Connection(REGION) + with pytest.raises(ValueError): + conn.get_return_values_on_condition_failure_map(return_values) + + def test_connection__create(): _ = Connection() conn = Connection(host='http://foohost') @@ -461,7 +476,7 @@ def test_connection_delete_item(): TEST_TABLE_NAME, "Amazon DynamoDB", "How do I update multiple items?", - return_values='ALL_NEW' + return_values=ALL_NEW ) params = { 'ReturnConsumedCapacity': 'TOTAL', @@ -487,6 +502,29 @@ def test_connection_delete_item(): with pytest.raises(ValueError): conn.delete_item(TEST_TABLE_NAME, "foo", "bar", return_item_collection_metrics='badvalue') + with patch(PATCH_METHOD) as req: + req.return_value = {} + conn.delete_item( + TEST_TABLE_NAME, + "Amazon DynamoDB", + "How do I update multiple items?", + return_values_on_condition_failure=ALL_OLD + ) + params = { + 'ReturnConsumedCapacity': 'TOTAL', + 'Key': { + 'ForumName': { + 'S': 'Amazon DynamoDB' + }, + 'Subject': { + 'S': 'How do I update multiple items?' + } + }, + 'TableName': TEST_TABLE_NAME, + 'ReturnValuesOnConditionCheckFailure': 'ALL_OLD' + } + assert req.call_args[0][1] == params + with patch(PATCH_METHOD) as req: req.return_value = {} conn.delete_item( @@ -532,6 +570,39 @@ def test_connection_delete_item(): } assert req.call_args[0][1] == params + with patch(PATCH_METHOD) as req: + req.return_value = {} + conn.update_item( + TEST_TABLE_NAME, + 'foo-key', + actions=[Path('Subject').set('foo-subject')], + range_key='foo-range-key', + return_values_on_condition_failure=ALL_OLD, + ) + params = { + 'ReturnConsumedCapacity': 'TOTAL', + 'Key': { + 'ForumName': { + 'S': 'foo-key' + }, + 'Subject': { + 'S': 'foo-range-key' + } + }, + 'UpdateExpression': 'SET #0 = :0', + 'ExpressionAttributeNames': { + '#0': 'Subject' + }, + 'ExpressionAttributeValues': { + ':0': { + 'S': 'foo-subject' + } + }, + 'TableName': TEST_TABLE_NAME, + 'ReturnValuesOnConditionCheckFailure': 'ALL_OLD' + } + assert req.call_args[0][1] == params + with patch(PATCH_METHOD) as req: req.return_value = {} conn.delete_item( @@ -627,8 +698,8 @@ def test_connection_update_item(): TEST_TABLE_NAME, 'foo-key', return_consumed_capacity='TOTAL', - return_item_collection_metrics='NONE', - return_values='ALL_NEW', + return_item_collection_metrics=NONE, + return_values=ALL_NEW, actions=[Path('Subject').set('foo-subject')], condition=Path('Forum').does_not_exist(), range_key='foo-range-key', @@ -723,7 +794,7 @@ def test_connection_put_item(): with patch(PATCH_METHOD) as req: req.side_effect = BotoCoreError with pytest.raises(TableError): - conn.put_item('foo-key', TEST_TABLE_NAME, return_values='ALL_NEW', attributes={'ForumName': 'foo-value'}) + conn.put_item('foo-key', TEST_TABLE_NAME, return_values=ALL_NEW, attributes={'ForumName': 'foo-value'}) with patch(PATCH_METHOD) as req: req.return_value = {} @@ -733,7 +804,7 @@ def test_connection_put_item(): range_key='foo-range-key', return_consumed_capacity='TOTAL', return_item_collection_metrics='SIZE', - return_values='ALL_NEW', + return_values=ALL_NEW, attributes={'ForumName': 'foo-value'} ) params = { @@ -792,6 +863,30 @@ def test_connection_put_item(): } assert req.call_args[0][1] == params + with patch(PATCH_METHOD) as req: + req.return_value = {} + conn.put_item( + TEST_TABLE_NAME, + 'foo-key', + range_key='foo-range-key', + attributes={'ForumName': 'foo-value'}, + return_values_on_condition_failure=ALL_OLD + ) + params = { + 'ReturnConsumedCapacity': 'TOTAL', + 'Item': { + 'ForumName': { + 'S': 'foo-value' + }, + 'Subject': { + 'S': 'foo-range-key' + } + }, + 'TableName': TEST_TABLE_NAME, + 'ReturnValuesOnConditionCheckFailure': 'ALL_OLD' + } + assert req.call_args[0][1] == params + with patch(PATCH_METHOD) as req: req.return_value = {} conn.put_item( @@ -1371,6 +1466,33 @@ def test_connection__make_api_call__wraps_verbose_client_error_create(send_mock) in str(excinfo.value) ) + +def test_connection__make_api_call__preserves_error_response_item(): + c = Connection() + client = mock.Mock() + client._request_signer = None + client._make_api_call.side_effect = ClientError( + { + 'Error': { + 'Code': 'ConditionalCheckFailedException', + 'Message': 'The conditional request failed', + }, + 'Item': { + 'ForumName': {'S': 'Amazon DynamoDB'}, + }, + }, + 'PutItem', + ) + c._client = client + + with pytest.raises(VerboseClientError) as excinfo: + c._make_api_call('PutItem', {'TableName': TEST_TABLE_NAME}) + + assert excinfo.value.response['Item'] == { + 'ForumName': {'S': 'Amazon DynamoDB'}, + } + + @mock.patch('botocore.httpsession.URLLib3Session.send') def test_connection__make_api_call__wraps_verbose_client_error_batch(send_mock): response = AWSResponse( diff --git a/tests/test_model.py b/tests/test_model.py index da54303b..bccb3fff 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -18,7 +18,7 @@ from pynamodb.constants import ( ITEM, STRING, ALL, KEYS_ONLY, INCLUDE, REQUEST_ITEMS, UNPROCESSED_KEYS, CAMEL_COUNT, RESPONSES, KEYS, ITEMS, LAST_EVALUATED_KEY, EXCLUSIVE_START_KEY, ATTRIBUTES, BINARY, - UNPROCESSED_ITEMS, DEFAULT_ENCODING, MAP, LIST, NUMBER, SCANNED_COUNT, + UNPROCESSED_ITEMS, DEFAULT_ENCODING, MAP, LIST, NUMBER, SCANNED_COUNT, ALL_NEW, ALL_OLD, ) from pynamodb.models import Model from pynamodb.indexes import ( @@ -825,6 +825,48 @@ def test_update(self, mock_time): assert item.views is None self.assertEqual({'bob'}, item.custom_aliases) + def test_update_return_values_on_condition_failure(self): + item = SimpleUserModel(user_name='foo', email='foo@example.com') + + with patch(PATCH_METHOD) as req: + req.return_value = { + ATTRIBUTES: { + 'user_name': { + 'S': 'foo' + }, + 'email': { + 'S': 'bar@example.com' + } + } + } + item.update( + actions=[SimpleUserModel.email.set('bar@example.com')], + return_values_on_condition_failure=ALL_OLD, + ) + + args = req.call_args[0][1] + params = { + 'TableName': 'SimpleModel', + 'ReturnValues': 'ALL_NEW', + 'ReturnValuesOnConditionCheckFailure': 'ALL_OLD', + 'Key': { + 'user_name': { + 'S': 'foo' + } + }, + 'UpdateExpression': 'SET #0 = :0', + 'ExpressionAttributeNames': { + '#0': 'email' + }, + 'ExpressionAttributeValues': { + ':0': { + 'S': 'bar@example.com' + } + }, + 'ReturnConsumedCapacity': 'TOTAL' + } + deep_eq(args, params, _assert=True) + def test_update_doesnt_do_validation_on_null_attributes(self): item = CarModel(12345) item.car_info = CarInfoMap(make='Foo', model='Bar') @@ -899,6 +941,31 @@ def test_save(self): } deep_eq(args, params, _assert=True) + with patch(PATCH_METHOD) as req: + req.return_value = {} + item.save(return_values_on_condition_failure=ALL_OLD) + args = req.call_args[0][1] + params = { + 'Item': { + 'callable_field': { + 'N': '42' + }, + 'email': { + 'S': u'needs_email' + }, + 'user_id': { + 'S': u'bar' + }, + 'user_name': { + 'S': u'foo' + }, + }, + 'ReturnConsumedCapacity': 'TOTAL', + 'ReturnValuesOnConditionCheckFailure': 'ALL_OLD', + 'TableName': 'UserModel' + } + deep_eq(args, params, _assert=True) + with patch(PATCH_METHOD) as req: req.return_value = {} item.save(UserModel.email.does_not_exist() & UserModel.zip_code.exists()) @@ -3385,6 +3452,28 @@ def test_delete(add_version_condition: bool) -> None: args = req.call_args[0][1] assert args == expected + with patch(PATCH_METHOD) as req: + req.return_value = None + item.delete( + add_version_condition=add_version_condition, + return_values_on_condition_failure=ALL_OLD, + ) + expected = { + 'Key': { + 'user_id': { + 'S': 'bar' + }, + 'user_name': { + 'S': 'foo' + } + }, + 'ReturnConsumedCapacity': 'TOTAL', + 'ReturnValuesOnConditionCheckFailure': 'ALL_OLD', + 'TableName': 'UserModel' + } + args = req.call_args[0][1] + assert args == expected + with patch(PATCH_METHOD) as req: req.return_value = None item.delete(UserModel.user_id =='bar', add_version_condition=add_version_condition) diff --git a/tests/test_table_connection.py b/tests/test_table_connection.py index 2ccf27ff..c61bd5bb 100644 --- a/tests/test_table_connection.py +++ b/tests/test_table_connection.py @@ -6,7 +6,7 @@ from pynamodb.connection import TableConnection from pynamodb.connection.base import MetaTable -from pynamodb.constants import TABLE_KEY +from pynamodb.constants import ALL_OLD, TABLE_KEY from pynamodb.expressions.operand import Path from .data import DESCRIBE_TABLE_DATA, GET_ITEM_DATA from .response import HttpOK @@ -331,6 +331,27 @@ def test_delete_item(self): } self.assertEqual(req.call_args[0][1], params) + with patch(PATCH_METHOD) as req: + req.return_value = {} + conn.delete_item( + "Amazon DynamoDB", + "How do I update multiple items?", + return_values_on_condition_failure=ALL_OLD) + params = { + 'ReturnConsumedCapacity': 'TOTAL', + 'Key': { + 'ForumName': { + 'S': 'Amazon DynamoDB' + }, + 'Subject': { + 'S': 'How do I update multiple items?' + } + }, + 'TableName': self.test_table_name, + 'ReturnValuesOnConditionCheckFailure': 'ALL_OLD' + } + self.assertEqual(req.call_args[0][1], params) + def test_update_item(self): """ TableConnection.update_item @@ -367,6 +388,38 @@ def test_update_item(self): } self.assertEqual(req.call_args[0][1], params) + with patch(PATCH_METHOD) as req: + req.return_value = HttpOK(), {} + conn.update_item( + 'foo-key', + actions=[Path('Subject').set('foo-subject')], + range_key='foo-range-key', + return_values_on_condition_failure=ALL_OLD, + ) + params = { + 'Key': { + 'ForumName': { + 'S': 'foo-key' + }, + 'Subject': { + 'S': 'foo-range-key' + } + }, + 'UpdateExpression': 'SET #0 = :0', + 'ExpressionAttributeNames': { + '#0': 'Subject' + }, + 'ExpressionAttributeValues': { + ':0': { + 'S': 'foo-subject' + } + }, + 'ReturnConsumedCapacity': 'TOTAL', + 'TableName': 'Thread', + 'ReturnValuesOnConditionCheckFailure': 'ALL_OLD' + } + self.assertEqual(req.call_args[0][1], params) + def test_get_item(self): """ TableConnection.get_item @@ -398,6 +451,22 @@ def test_put_item(self): } self.assertEqual(req.call_args[0][1], params) + with patch(PATCH_METHOD) as req: + req.return_value = {} + conn.put_item( + 'foo-key', + range_key='foo-range-key', + attributes={'ForumName': 'foo-value'}, + return_values_on_condition_failure=ALL_OLD + ) + params = { + 'ReturnConsumedCapacity': 'TOTAL', + 'TableName': self.test_table_name, + 'Item': {'ForumName': {'S': 'foo-value'}, 'Subject': {'S': 'foo-range-key'}}, + 'ReturnValuesOnConditionCheckFailure': 'ALL_OLD' + } + self.assertEqual(req.call_args[0][1], params) + with patch(PATCH_METHOD) as req: req.return_value = {} conn.put_item( diff --git a/tests/test_transaction.py b/tests/test_transaction.py index 94cb3c7f..fd243e38 100644 --- a/tests/test_transaction.py +++ b/tests/test_transaction.py @@ -3,7 +3,7 @@ from pynamodb.connection import Connection from pynamodb.connection.base import MetaTable -from pynamodb.constants import TABLE_KEY +from pynamodb.constants import ALL_OLD, TABLE_KEY from pynamodb.transactions import Transaction, TransactGet, TransactWrite from pynamodb.models import Model from tests.test_base_connection import PATCH_METHOD @@ -85,9 +85,9 @@ def test_commit(self, mocker): mock_connection_transact_write = mocker.patch.object(connection, 'transact_write_items') with TransactWrite(connection=connection) as t: t.condition_check(MockModel, 1, 3, condition=(MockModel.mock_hash.does_not_exist())) - t.delete(MockModel(2, 4)) + t.delete(MockModel(2, 4), return_values=ALL_OLD) t.save(MockModel(3, 5)) - t.update(MockModel(4, 6), actions=[MockModel.mock_toot.set('hello')], return_values='ALL_OLD') + t.update(MockModel(4, 6), actions=[MockModel.mock_toot.set('hello')], return_values=ALL_OLD) expected_condition_checks = [{ 'ConditionExpression': 'attribute_not_exists (#0)', @@ -99,6 +99,7 @@ def test_commit(self, mocker): 'ConditionExpression': 'attribute_not_exists (#0)', 'ExpressionAttributeNames': {'#0': 'mock_version'}, 'Key': {'mock_hash': {'N': '2'}, 'mock_range': {'N': '4'}}, + 'ReturnValuesOnConditionCheckFailure': 'ALL_OLD', 'TableName': 'mock' }] expected_puts = [{