diff --git a/docs/index.rst b/docs/index.rst index 492eb3b1f..88ffe36cc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -43,6 +43,7 @@ Topics logging contributing release_notes + transaction versioning API docs diff --git a/docs/transaction.rst b/docs/transaction.rst new file mode 100644 index 000000000..fe86ca040 --- /dev/null +++ b/docs/transaction.rst @@ -0,0 +1,231 @@ +Transaction Operations +====================== + +Transact operations are similar to Batch operations, with the key differences being that the writes support the +inclusion of condition checks, and they all must fail or succeed together. + + +Transaction operations are supported using context managers. Keep in mind that DynamoDB imposes limits on the number of +items that a single transaction can contain. + + +Suppose you have defined a BankStatement model, like in the example below. + +.. code-block:: python + + from pynamodb.models import Model + from pynamodb.attributes import BooleanAttribute, NumberAttribute, UnicodeAttribute + + class BankStatement(Model): + class Meta: + table_name = 'BankStatement' + + user_id = UnicodeAttribute(hash_key=True) + account_balance = NumberAttribute(default=0) + is_active = BooleanAttribute() + + +Transact Writes +^^^^^^^^^^^^^^^ + +A :py:class:`TransactWrite ` can be initialized with the following parameters: + +* ``connection`` (required) - the :py:class:`Connection ` used to make the request (see :ref:`low_level`) +* ``client_request_token`` - an idempotency key for the request (:ref:`client-request-token`) +* ``return_consumed_capacity`` - determines the level of detail about provisioned throughput consumption that is returned in the response (:ref:`return_consumed_write_capacity`) +* ``return_item_collection_metrics`` - determines whether item collection metrics are returned (:ref:`return_item_collection_metrics`) + +Here's an example of using a context manager for a :py:class:`TransactWrite ` operation: + +.. code-block:: python + + from pynamodb.connection import Connection + from pynamodb.connection.transactions import TransactWrite + + # Two existing bank statements in the following states + user1_statement = BankStatement('user1', account_balance=2000, is_active=True) + user2_statement = BankStatement('user2', account_balance=0, is_active=True) + + user1_statement.save() + user2_statement.save() + + connection = Connection() + + with TransactWrite(connection=connection, client_request_token='super-unique-key') as transaction: + # attempting to transfer funds from user1's account to user2's + transfer_amount = 1000 + transaction.update( + BankStatement(user_id='user1'), + actions=[BankStatement.account_balance.add(transfer_amount * -1)], + condition=( + (BankStatement.account_balance >= transfer_amount) & + (BankStatement.is_active == True) + ) + ) + transaction.update( + BankStatement(user_id='user2'), + actions=[BankStatement.account_balance.add(transfer_amount)], + condition=(BankStatement.is_active == True) + ) + + user1_statement.refresh() + user2_statement.refresh() + + assert user1_statement.account_balance == 1000 + assert user2_statement.account_balance == 1000 + + +Now, say you make another attempt to debit one of the accounts when they don't have enough money in the bank: + +.. code-block:: python + + from pynamodb.exceptions import TransactWriteError + + assert user1_statement.account_balance == 1000 + assert user2_statement.account_balance == 1000 + + try: + with TransactWrite(connection=connection, client_request_token='another-super-unique-key') as transaction: + # attempting to transfer funds from user1's account to user2's + transfer_amount = 2000 + transaction.update( + BankStatement(user_id='user1'), + actions=[BankStatement.account_balance.add(transfer_amount * -1)], + condition=( + (BankStatement.account_balance >= transfer_amount) & + (BankStatement.is_active == True) + ) + ) + transaction.update( + BankStatement(user_id='user2'), + actions=[BankStatement.account_balance.add(transfer_amount)], + condition=(BankStatement.is_active == True) + ) + except TransactWriteError as e: + # Because the condition check on the account balance failed, + # the entire transaction should be cancelled + assert e.cause_response_code == 'TransactionCanceledException' + + user1_statement.refresh() + user2_statement.refresh() + # and both models should be unchanged + assert user1_statement.account_balance == 1000 + assert user2_statement.account_balance == 1000 + + +Condition Check +--------------- + +The ``ConditionCheck`` operation is used on a :py:class:`TransactWrite ` to check if the current state of a record you +aren't modifying within the overall transaction fits some criteria that, if it fails, would cause the entire +transaction to fail. The ``condition`` argument is of type :ref:`conditional`. + +* ``model_cls`` (required) +* ``hash_key`` (required) +* ``range_key`` (optional) +* ``condition`` (required) - of type :py:class:`Condition ` (see :ref:`conditional`) + +.. code-block:: python + + with TransactWrite(connection=connection) as transaction: + transaction.condition_check(BankStatement, 'user1', condition=(BankStatement.is_active == True)) + + +Delete +------ + +The ``Delete`` operation functions similarly to ``Model.delete``. + +* ``model`` (required) +* ``condition`` (optional) - of type :py:class:`Condition ` (see :ref:`conditional`) + +.. code-block:: python + + statement = BankStatement.get('user1') + + with TransactWrite(connection=connection) as transaction: + transaction.delete(statement, condition=(~BankStatement.is_active)) + + + +Save +---- + +The ``Put`` operation functions similarly to ``Model.save``. + +* ``model`` (required) +* ``condition`` (optional) - of type :py:class:`Condition ` (see :ref:`conditional`) +* ``return_values`` (optional) - the values that should be returned if the condition fails (:ref:`return_values_on_check_failure_update`) + +.. code-block:: python + + statement = BankStatement(user_id='user3', account_balance=20, is_active=True) + + with TransactWrite(connection=connection) as transaction: + transaction.save(statement, condition=(BankStatement.user_id.does_not_exist())) + + +Update +------ + +The ``Update`` operation functions similarly to ``Model.update``. + +* ``model_cls`` (required) +* ``hash_key`` (required) +* ``range_key`` (optional) +* ``actions`` (required) - a list of type :py:class:`Action ` (see :ref:`updates`) +* ``condition`` (optional) - of type :py:class:`Condition ` (see :ref:`conditional`) +* ``return_values`` (optional) - the values that should be returned if the condition fails (:ref:`return_values_on_check_failure_save`) + + +.. code-block:: python + + with TransactWrite(connection=connection) as transaction: + transaction.update( + BankStatement, + 'user1', + actions=[BankStatement.account_balance.set(0), BankStatement.is_active.set(False)] + condition=(BankStatement.user_id.exists()) + ) + + +Transact Gets +^^^^^^^^^^^^^ +.. code-block:: python + + with TransactGet(connection=connection) as transaction: + """ attempting to get records of users' bank statements """ + user1_statement_future = transaction.get(BankStatement, 'user1') + user2_statement_future = transaction.get(BankStatement, 'user2') + + user1_statement: BankStatement = user1_statement_future.get() + user2_statement: BankStatement = user2_statement_future.get() + +The :py:class:`TransactGet ` operation currently only supports the ``Get`` method, which only takes the following parameters: + +* ``model_cls`` (required) +* ``hash_key`` (required) +* ``range_key`` (optional) + +The ``.get`` returns a class of type ``_ModelFuture`` that acts as a placeholder for the record until the transaction completes. + +To retrieve the resolved model, you say `model_future.get()`. Any attempt to access this model before the transaction is complete +will result in a :py:class:`InvalidStateError `. + +Error Types +^^^^^^^^^^^ + +You can expect some new error types with transactions, such as: + +* :py:exc:`TransactWriteError ` - thrown when a :py:class:`TransactWrite ` request returns a bad response. +* :py:exc:`TransactGetError ` - thrown when a :py:class:`TransactGet ` request returns a bad response. +* :py:exc:`InvalidStateError ` - thrown when an attempt is made to access data on a :py:class:`_ModelFuture ` before the `TransactGet` request is completed. + +You can learn more about the new error messages :ref:`transaction_errors` + +.. _client-request-token: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html#DDB-TransactWriteItems-request-ClientRequestToken +.. _return_consumed_write_capacity: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html#DDB-TransactWriteItems-request-ReturnConsumedCapacity +.. _return_item_collection_metrics: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html#DDB-TransactWriteItems-request-ReturnItemCollectionMetrics +.. _return_values_on_check_failure_update: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Put.html#DDB-Type-Put-ReturnValuesOnConditionCheckFailure +.. _return_values_on_check_failure_save: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Update.html#DDB-Type-Update-ReturnValuesOnConditionCheckFailure +.. _transaction_errors: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html#DDB-TransactWriteItems-response-ItemCollectionMetrics diff --git a/pynamodb/transactions.pyi b/pynamodb/transactions.pyi index 0d55b6a22..f94b3e858 100644 --- a/pynamodb/transactions.pyi +++ b/pynamodb/transactions.pyi @@ -1,4 +1,4 @@ -from typing import Set, Tuple, TypeVar, Type, Any, List, Optional, Dict, Union, Text +from typing import Tuple, TypeVar, Type, Any, List, Optional, Dict, Union, Text from pynamodb.expressions.condition import Condition from pynamodb.models import Model, _ModelFuture