Skip to content

Commit 495eae2

Browse files
authored
Complete Expression API (#352)
1 parent 5fd2ee0 commit 495eae2

File tree

12 files changed

+411
-32
lines changed

12 files changed

+411
-32
lines changed

docs/conditional.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ This example will update a `Thread` item, if the `views` attribute is less than
8686
8787
.. code-block:: python
8888
89-
thread_item.update((Thread.views < 5) | (Thread.views > 10))
89+
thread_item.update(condition=(Thread.views < 5) | (Thread.views > 10))
9090
9191
9292
Conditional Model.delete

docs/tutorial.rst

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -279,16 +279,10 @@ atomically updating the view count of an item + updating the value of the last p
279279

280280
.. code-block:: python
281281
282-
>>> thread_item.update({
283-
'views': {
284-
'action': 'add',
285-
'value': 1,
286-
},
287-
'last_post_datetime': {
288-
'action': 'put',
289-
'value': datetime.now(),
290-
},
291-
})
282+
>>> thread_item.update(actions=[
283+
Thread.views.set(Thread.views + 1),
284+
Thread.last_post_datetime.set(datetime.now()),
285+
])
292286
293287
294288
.. deprecated:: 2.0

pynamodb/attributes.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,40 @@ def startswith(self, prefix):
130130
def contains(self, item):
131131
return Path(self).contains(item)
132132

133+
# Update Expression Support
134+
def __add__(self, other):
135+
return Path(self).__add__(other)
136+
137+
def __radd__(self, other):
138+
return Path(self).__radd__(other)
139+
140+
def __sub__(self, other):
141+
return Path(self).__sub__(other)
142+
143+
def __rsub__(self, other):
144+
return Path(self).__rsub__(other)
145+
146+
def __or__(self, other):
147+
return Path(self).__or__(other)
148+
149+
def append(self, other):
150+
return Path(self).append(other)
151+
152+
def prepend(self, other):
153+
return Path(self).prepend(other)
154+
155+
def set(self, value):
156+
return Path(self).set(value)
157+
158+
def update(self, subset):
159+
return Path(self).update(subset)
160+
161+
def difference_update(self, subset):
162+
return Path(self).difference_update(subset)
163+
164+
def remove(self):
165+
return Path(self).remove()
166+
133167

134168
class AttributeContainerMeta(type):
135169

pynamodb/connection/base.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,7 @@ def update_item(self,
839839
table_name,
840840
hash_key,
841841
range_key=None,
842+
actions=None,
842843
attribute_updates=None,
843844
condition=None,
844845
expected=None,
@@ -849,6 +850,7 @@ def update_item(self,
849850
"""
850851
Performs the UpdateItem operation
851852
"""
853+
self._check_actions(actions, attribute_updates)
852854
self._check_condition('condition', condition, expected, conditional_operator)
853855

854856
operation_kwargs = {TABLE_NAME: table_name}
@@ -865,10 +867,12 @@ def update_item(self,
865867
operation_kwargs.update(self.get_item_collection_map(return_item_collection_metrics))
866868
if return_values:
867869
operation_kwargs.update(self.get_return_values_map(return_values))
868-
if not attribute_updates:
870+
if not actions and not attribute_updates:
869871
raise ValueError("{0} cannot be empty".format(ATTR_UPDATES))
872+
actions = actions or []
873+
attribute_updates = attribute_updates or {}
870874

871-
update_expression = Update()
875+
update_expression = Update(*actions)
872876
# We sort the keys here for determinism. This is mostly done to simplify testing.
873877
for key in sorted(attribute_updates.keys()):
874878
path = Path([key])
@@ -1438,6 +1442,14 @@ def _get_condition(self, table_name, attribute_name, operator, *values):
14381442
]
14391443
return getattr(Path([attribute_name]), operator)(*values)
14401444

1445+
def _check_actions(self, actions, attribute_updates):
1446+
if actions is not None:
1447+
if attribute_updates is not None:
1448+
raise ValueError("Legacy attribute updates cannot be used with update actions")
1449+
else:
1450+
if attribute_updates is not None:
1451+
warnings.warn("Legacy attribute updates are deprecated in favor of update actions")
1452+
14411453
def _check_condition(self, name, condition, expected_or_filter, conditional_operator):
14421454
if condition is not None:
14431455
if not isinstance(condition, Condition):

pynamodb/connection/table.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def delete_item(self, hash_key,
5353
def update_item(self,
5454
hash_key,
5555
range_key=None,
56+
actions=None,
5657
attribute_updates=None,
5758
condition=None,
5859
expected=None,
@@ -68,6 +69,7 @@ def update_item(self,
6869
self.table_name,
6970
hash_key,
7071
range_key=range_key,
72+
actions=actions,
7173
attribute_updates=attribute_updates,
7274
condition=condition,
7375
expected=expected,

pynamodb/expressions/operand.py

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from pynamodb.constants import (
2-
ATTR_TYPE_MAP, BINARY_SET, LIST, MAP, NUMBER_SET, NUMBER_SHORT, SHORT_ATTR_TYPES, STRING_SET, STRING_SHORT
2+
ATTR_TYPE_MAP, BINARY_SET, LIST, LIST_SHORT, MAP, NUMBER_SET, NUMBER_SHORT, SHORT_ATTR_TYPES, STRING_SET, STRING_SHORT
33
)
44
from pynamodb.expressions.condition import (
55
BeginsWith, Between, Comparison, Contains, Exists, In, IsType, NotExists
@@ -75,6 +75,36 @@ def is_in(self, *values):
7575
return In(self, *values)
7676

7777

78+
class _NumericOperand(_Operand):
79+
"""
80+
A base class for Operands that can be used in the increment and decrement SET update actions.
81+
"""
82+
83+
def __add__(self, other):
84+
return _Increment(self, self._to_operand(other))
85+
86+
def __radd__(self, other):
87+
return _Increment(self._to_operand(other), self)
88+
89+
def __sub__(self, other):
90+
return _Decrement(self, self._to_operand(other))
91+
92+
def __rsub__(self, other):
93+
return _Decrement(self._to_operand(other), self)
94+
95+
96+
class _ListAppendOperand(_Operand):
97+
"""
98+
A base class for Operands that can be used in the list_append function for the SET update action.
99+
"""
100+
101+
def append(self, other):
102+
return _ListAppend(self, self._to_operand(other))
103+
104+
def prepend(self, other):
105+
return _ListAppend(self._to_operand(other), self)
106+
107+
78108
class _Size(_ConditionOperand):
79109
"""
80110
Size is a special operand that represents the result of calling the 'size' function on a Path operand.
@@ -93,7 +123,60 @@ def _to_operand(self, value):
93123
return operand
94124

95125

96-
class Value(_ConditionOperand):
126+
class _Increment(_Operand):
127+
"""
128+
Increment is a special operand that represents an increment SET update action.
129+
"""
130+
format_string = '{0} + {1}'
131+
short_attr_type = NUMBER_SHORT
132+
133+
def __init__(self, lhs, rhs):
134+
lhs._type_check(NUMBER_SHORT)
135+
rhs._type_check(NUMBER_SHORT)
136+
super(_Increment, self).__init__(lhs, rhs)
137+
138+
139+
class _Decrement(_Operand):
140+
"""
141+
Decrement is a special operand that represents an decrement SET update action.
142+
"""
143+
format_string = '{0} - {1}'
144+
short_attr_type = NUMBER_SHORT
145+
146+
def __init__(self, lhs, rhs):
147+
lhs._type_check(NUMBER_SHORT)
148+
rhs._type_check(NUMBER_SHORT)
149+
super(_Decrement, self).__init__(lhs, rhs)
150+
151+
152+
class _ListAppend(_Operand):
153+
"""
154+
ListAppend is a special operand that represents the list_append function for the SET update action.
155+
"""
156+
format_string = 'list_append ({0}, {1})'
157+
short_attr_type = LIST_SHORT
158+
159+
def __init__(self, list1, list2):
160+
list1._type_check(LIST_SHORT)
161+
list2._type_check(LIST_SHORT)
162+
super(_ListAppend, self).__init__(list1, list2)
163+
164+
165+
class _IfNotExists(_NumericOperand, _ListAppendOperand):
166+
"""
167+
IfNotExists is a special operand that represents the if_not_exists function for the SET update action.
168+
"""
169+
format_string = 'if_not_exists ({0}, {1})'
170+
171+
def __init__(self, path, value):
172+
self.short_attr_type = path.short_attr_type or value.short_attr_type
173+
if self.short_attr_type != value.short_attr_type:
174+
# path and value have conflicting types -- defer any type checks to DynamoDB
175+
self.short_attr_type = None
176+
super(_IfNotExists, self).__init__(path, value)
177+
178+
179+
class Value(_NumericOperand, _ListAppendOperand, _ConditionOperand):
97180
"""
98181
Value is an operand that represents an attribute value.
99182
"""
@@ -103,6 +186,8 @@ def __init__(self, value, attribute=None):
103186
# Check to see if value is already serialized
104187
if isinstance(value, dict) and len(value) == 1 and list(value.keys())[0] in SHORT_ATTR_TYPES:
105188
(self.short_attr_type, value), = value.items()
189+
elif value is None:
190+
(self.short_attr_type, value) = Value.__serialize(value)
106191
else:
107192
(self.short_attr_type, value) = Value.__serialize(value, attribute)
108193
super(Value, self).__init__({self.short_attr_type: value})
@@ -134,7 +219,7 @@ def __serialize_based_on_type(value):
134219
return ATTR_TYPE_MAP[attr_class.attr_type], attr_class.serialize(value)
135220

136221

137-
class Path(_ConditionOperand):
222+
class Path(_NumericOperand, _ListAppendOperand, _ConditionOperand):
138223
"""
139224
Path is an operand that represents either an attribute name or document path.
140225
"""
@@ -166,6 +251,9 @@ def __getitem__(self, idx):
166251
element_path.path[-1] = '{0}[{1}]'.format(self.path[-1], idx)
167252
return element_path
168253

254+
def __or__(self, other):
255+
return _IfNotExists(self, self._to_operand(other))
256+
169257
def set(self, value):
170258
# Returns an update action that sets this attribute to the given value
171259
return SetAction(self, self._to_operand(value))

pynamodb/expressions/update.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ def serialize(self, placeholder_names, expression_attribute_values):
1111
values = [value.serialize(placeholder_names, expression_attribute_values) for value in self.values]
1212
return self.format_string.format(*values)
1313

14+
def __repr__(self):
15+
values = [str(value) for value in self.values]
16+
return self.format_string.format(*values)
17+
1418

1519
class SetAction(Action):
1620
"""
@@ -56,11 +60,13 @@ def __init__(self, path, subset):
5660

5761
class Update(object):
5862

59-
def __init__(self):
63+
def __init__(self, *actions):
6064
self.set_actions = []
6165
self.remove_actions = []
6266
self.add_actions = []
6367
self.delete_actions = []
68+
for action in actions:
69+
self.add_action(action)
6470

6571
def add_action(self, action):
6672
if isinstance(action, SetAction):

pynamodb/models.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ def update_item(self, attribute, value=None, action=None, condition=None, condit
378378
setattr(self, attr_name, attr.deserialize(value.get(ATTR_TYPE_MAP[attr.attr_type])))
379379
return data
380380

381-
def update(self, attributes, condition=None, conditional_operator=None, **expected_values):
381+
def update(self, attributes=None, actions=None, condition=None, conditional_operator=None, **expected_values):
382382
"""
383383
Updates an item using the UpdateItem operation.
384384
@@ -388,24 +388,29 @@ def update(self, attributes, condition=None, conditional_operator=None, **expect
388388
next_attr: {'value': True, 'action': 'PUT'},
389389
}
390390
"""
391-
if not isinstance(attributes, dict):
391+
if attributes is not None and not isinstance(attributes, dict):
392392
raise TypeError("the value of `attributes` is expected to be a dictionary")
393+
if actions is not None and not isinstance(actions, list):
394+
raise TypeError("the value of `actions` is expected to be a list")
393395

394396
self._conditional_operator_check(conditional_operator)
395397
args, save_kwargs = self._get_save_args(null_check=False)
396398
kwargs = {
397399
pythonic(RETURN_VALUES): ALL_NEW,
398-
pythonic(ATTR_UPDATES): {},
399400
'conditional_operator': conditional_operator,
400401
}
401402

403+
if attributes:
404+
kwargs[pythonic(ATTR_UPDATES)] = {}
405+
402406
if pythonic(RANGE_KEY) in save_kwargs:
403407
kwargs[pythonic(RANGE_KEY)] = save_kwargs[pythonic(RANGE_KEY)]
404408

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

408412
attrs = self._get_attributes()
413+
attributes = attributes or {}
409414
for attr, params in attributes.items():
410415
attribute_cls = attrs[attr]
411416
action = params['action'] and params['action'].upper()
@@ -416,6 +421,7 @@ def update(self, attributes, condition=None, conditional_operator=None, **expect
416421
kwargs[pythonic(ATTR_UPDATES)][attribute_cls.attr_name] = attr_values
417422

418423
kwargs.update(condition=condition)
424+
kwargs.update(actions=actions)
419425
data = self._get_connection().update_item(*args, **kwargs)
420426
self._throttle.add_record(data.get(CONSUMED_CAPACITY))
421427
for name, value in data[ATTRIBUTES].items():

0 commit comments

Comments
 (0)