Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiple attrs update #194

Merged
merged 10 commits into from Nov 30, 2016
2 changes: 1 addition & 1 deletion docs/conditional.rst
Expand Up @@ -103,7 +103,7 @@ This example will update a `Thread` item, if the `forum_name` attribute equals '

.. code-block:: python

thread_item.update_item(
thread_item.update(
conditional_operator='or',
forum_name__eq='Some Forum',
subject__null=False)
Expand Down
21 changes: 20 additions & 1 deletion docs/tutorial.rst
Expand Up @@ -275,7 +275,26 @@ You can update an item with the latest data from your table:
>>> thread_item.refresh()

Updates to table items are supported too, even atomic updates. Here is an example of
atomically updating the view count of an item:
atomically updating the view count of an item + updating the value of the last post.

.. code-block:: python

>>> thread_item.update({
'views': {
'action': 'add',
'value': 1,
},
'last_post_datetime': {
'action': 'put',
'value': datetime.now(),
},
})


.. deprecated:: 2.0

:func:`update_item` is replaced with :func:`update`


.. code-block:: python

Expand Down
12 changes: 12 additions & 0 deletions examples/model.py
Expand Up @@ -180,6 +180,18 @@ class Meta:
subject__null=False)
)

# DynamoDB will atomically update the attributes `replies` (increase value by 1),
# and `last_post_datetime` (set value to the current datetime)
print(thread_item.update({
'replies': {
'action': 'add',
'value': 1,
},
'last_post_datetime': {
'action': 'put',
'value': datetime.now(),
},
}))

# DynamoDB will delete the item, only if the views attribute is equal to one
print(thread_item.delete(views__eq=1))
Expand Down
10 changes: 6 additions & 4 deletions pynamodb/constants.py
Expand Up @@ -93,8 +93,11 @@
BINARY_SET = 'BinarySet'
MAP = 'Map'
LIST = 'List'
NULL = 'NULL'
NULL_SHORT = 'NULL'

SHORT_ATTR_TYPES = [STRING_SHORT, STRING_SET_SHORT, NUMBER_SHORT, NUMBER_SET_SHORT,
BINARY_SHORT, BINARY_SET_SHORT, MAP_SHORT, LIST_SHORT, BOOLEAN_SHORT]
BINARY_SHORT, BINARY_SET_SHORT, MAP_SHORT, LIST_SHORT, BOOLEAN_SHORT, NULL_SHORT]
ATTR_TYPE_MAP = {
STRING: STRING_SHORT,
STRING_SET: STRING_SET_SHORT,
Expand All @@ -110,7 +113,8 @@
BINARY_SET_SHORT: BINARY_SET,
MAP: MAP_SHORT,
LIST: LIST_SHORT,
BOOLEAN: BOOLEAN_SHORT
BOOLEAN: BOOLEAN_SHORT,
NULL: NULL_SHORT,
}
# Constants needed for creating indexes
LOCAL_SECONDARY_INDEX = 'LocalSecondaryIndex'
Expand Down Expand Up @@ -162,7 +166,6 @@
# These are the valid select values for the Scan operation
# See: http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Scan.html#DDB-Scan-request-Select
NOT_NULL = 'NOT_NULL'
NULL = 'NULL'
CONTAINS = 'CONTAINS'
NOT_CONTAINS = 'NOT_CONTAINS'
ALL_ATTRIBUTES = 'ALL_ATTRIBUTES'
Expand Down Expand Up @@ -246,4 +249,3 @@
AND = 'AND'
OR = 'OR'
CONDITIONAL_OPERATORS = [AND, OR]

97 changes: 81 additions & 16 deletions pynamodb/models.py
Expand Up @@ -6,6 +6,8 @@
import six
import copy
import logging
import warnings

from six import with_metaclass
from pynamodb.exceptions import DoesNotExist, TableDoesNotExist, TableError
from pynamodb.throttle import NoThrottle
Expand Down Expand Up @@ -323,6 +325,8 @@ def update_item(self, attribute, value=None, action=None, conditional_operator=N
:param action: The action to take if this item already exists.
See: http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateItem.html#DDB-UpdateItem-request-AttributeUpdate
"""
warnings.warn("`Model.update_item` is deprecated in favour of `Model.update` now")

self._conditional_operator_check(conditional_operator)
args, save_kwargs = self._get_save_args(null_check=False)
attribute_cls = None
Expand Down Expand Up @@ -357,6 +361,52 @@ def update_item(self, attribute, value=None, action=None, conditional_operator=N
setattr(self, name, attr.deserialize(value.get(ATTR_TYPE_MAP[attr.attr_type])))
return data


def update(self, attributes, conditional_operator=None, **expected_values):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be better to take kwargs that map from the model_field_name to the new value? i see we've already got **expected_values filling that role though... maybe we could make that a keyword argument

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be good to do now since this is new...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jmphilli you might need to pass action for the attributes too. That would mean a bunch of small dictionaries like attribute_name={'value': 1, 'action': 'add'}.

"""
Updates an item using the UpdateItem operation.

:param attributes: A dictionary of attributes to update in the following format
{
attr_name: {'value': 10, 'action': 'ADD'},
next_attr: {'value': True, 'action': 'PUT'},
}
"""
if not isinstance(attributes, dict):
raise TypeError("the value of `attributes` is expected to be a dictionary")

self._conditional_operator_check(conditional_operator)
args, save_kwargs = self._get_save_args(null_check=False)
kwargs = {
pythonic(RETURN_VALUES): ALL_NEW,
pythonic(ATTR_UPDATES): {},
'conditional_operator': conditional_operator,
}

if pythonic(RANGE_KEY) in save_kwargs:
kwargs[pythonic(RANGE_KEY)] = save_kwargs[pythonic(RANGE_KEY)]

if expected_values:
kwargs['expected'] = self._build_expected_values(expected_values, UPDATE_FILTER_OPERATOR_MAP)

attrs = self._get_attributes()
for attr, params in attributes.items():
attribute_cls = attrs[attr]
action = params['action'] and params['action'].upper()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

won't this return a bool ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, either False (in case params['action'] is evaluated to False) or the value of the second statement)

>>> a = {'none': False, 'one': 'yes'}
>>> a['none'] and a['none'].upper()
False
>>> a['one'] and a['one'].upper()
'YES'

attr_values = {ACTION: action}
if action != DELETE:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add a test for DELETE

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

attr_values[VALUE] = self._serialize_value(attribute_cls, params['value'])

kwargs[pythonic(ATTR_UPDATES)][attribute_cls.attr_name] = attr_values

data = self._get_connection().update_item(*args, **kwargs)
self._throttle.add_record(data.get(CONSUMED_CAPACITY))
for name, value in data[ATTRIBUTES].items():
attr = self._get_attributes().get(name)
if attr:
setattr(self, name, attr.deserialize(value.get(ATTR_TYPE_MAP[attr.attr_type])))
return data

def save(self, conditional_operator=None, **expected_values):
"""
Save this object to dynamodb
Expand Down Expand Up @@ -1189,7 +1239,7 @@ def _deserialize(self, attrs):

def _serialize(self, attr_map=False, null_check=True):
"""
Serializes a value for use with DynamoDB
Serializes all model attributes for use with DynamoDB

:param attr_map: If True, then attributes are returned
:param null_check: If True, then attributes are checked for null
Expand All @@ -1198,33 +1248,48 @@ def _serialize(self, attr_map=False, null_check=True):
attrs = {attributes: {}}
for name, attr in self._get_attributes().aliased_attrs():
value = getattr(self, name)
if value is None:
if attr.null:
continue
elif null_check:
raise ValueError("Attribute '{0}' cannot be None".format(attr.attr_name))
if isinstance(value, MapAttribute):
if not value.validate():
raise ValueError("Attribute '{0}' is not correctly typed".format(attr.attr_name))
value = value.get_values()
serialized = attr.serialize(value)
if serialized is None:

serialized = self._serialize_value(attr, value, null_check)
if NULL in serialized:
continue

if attr_map:
attrs[attributes][attr.attr_name] = {
ATTR_TYPE_MAP[attr.attr_type]: serialized
}
attrs[attributes][attr.attr_name] = serialized
else:
if attr.is_hash_key:
attrs[HASH] = serialized
attrs[HASH] = serialized[ATTR_TYPE_MAP[attr.attr_type]]
elif attr.is_range_key:
attrs[RANGE] = serialized
attrs[RANGE] = serialized[ATTR_TYPE_MAP[attr.attr_type]]
else:
attrs[attributes][attr.attr_name] = {
ATTR_TYPE_MAP[attr.attr_type]: serialized
}
attrs[attributes][attr.attr_name] = serialized

return attrs

@classmethod
def _serialize_value(cls, attr, value, null_check=True):
"""
Serializes a value for use with DynamoDB

:param attr: an instance of `Attribute` for serialization
:param value: a value to be serialized
:param null_check: If True, then attributes are checked for null
"""
if value is None:
serialized = None
else:
serialized = attr.serialize(value)

if serialized is None:
if not attr.null and null_check:
raise ValueError("Attribute '{0}' cannot be None".format(attr.attr_name))
return {NULL: True}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we just set serialized = True and let the ATTR_TYPE_MAP on line 1286 handle the null bit? seems nicer maybe? don't really know tbh

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is a fix for #195


return {ATTR_TYPE_MAP[attr.attr_type]: serialized}

@classmethod
def _serialize_keys(cls, hash_key, range_key=None):
"""
Expand Down
69 changes: 69 additions & 0 deletions pynamodb/tests/test_model.py
Expand Up @@ -189,6 +189,7 @@ class Meta:
icons = BinarySetAttribute()
views = NumberAttribute(null=True)
is_active = BooleanAttribute(null=True)
signature = UnicodeAttribute(null=True)


class ThrottledUserModel(Model):
Expand Down Expand Up @@ -810,6 +811,74 @@ def test_delete(self):
args = req.call_args[0][1]
deep_eq(args, params, _assert=True)

def test_update(self):
"""
Model.update
"""
with patch(PATCH_METHOD) as req:
req.return_value = SIMPLE_MODEL_TABLE_DATA
item = SimpleUserModel('foo', is_active=True, email='foo@example.com', signature='foo')

with patch(PATCH_METHOD) as req:
req.return_value = {}
item.save()

with patch(PATCH_METHOD) as req:
req.return_value = {
ATTRIBUTES: {
"email": {
"S": "foo@example.com",
},
"is_active": {
"NULL": None,
},
}
}
item.update({
'email': {'value': 'foo@example.com', 'action': 'put'},
'views': {'action': 'delete'},
'is_active': {'value': None, 'action': 'put'},
'signature': {'value': None, 'action': 'put'},
})

args = req.call_args[0][1]
params = {
'TableName': 'SimpleModel',
'ReturnValues': 'ALL_NEW',
'Key': {
'user_name': {
'S': 'foo'
}
},
'AttributeUpdates': {
'email': {
'Action': 'PUT',
'Value': {
'S': 'foo@example.com',
},
},
'views': {
'Action': 'DELETE',
},
'is_active': {
'Action': 'PUT',
'Value': {
'NULL': True,
},
},
'signature': {
'Action': 'PUT',
'Value': {
'NULL': True,
},
},
},
'ReturnConsumedCapacity': 'TOTAL'
}
deep_eq(args, params, _assert=True)

assert item.views is None

def test_update_item(self):
"""
Model.update_item
Expand Down