Skip to content

Commit 7bc940c

Browse files
authored
Replace AttributeUpdates with UpdateExpression (#341)
1 parent b00890e commit 7bc940c

File tree

7 files changed

+479
-208
lines changed

7 files changed

+479
-208
lines changed

pynamodb/connection/base.py

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@
3434
WRITE_CAPACITY_UNITS, GLOBAL_SECONDARY_INDEXES, PROJECTION, EXCLUSIVE_START_TABLE_NAME, TOTAL,
3535
DELETE_TABLE, UPDATE_TABLE, LIST_TABLES, GLOBAL_SECONDARY_INDEX_UPDATES,
3636
CONSUMED_CAPACITY, CAPACITY_UNITS, QUERY_FILTER, QUERY_FILTER_VALUES, CONDITIONAL_OPERATOR,
37-
CONDITIONAL_OPERATORS, NULL, NOT_NULL, SHORT_ATTR_TYPES, DELETE,
37+
CONDITIONAL_OPERATORS, NULL, NOT_NULL, SHORT_ATTR_TYPES, DELETE, PUT,
3838
ITEMS, DEFAULT_ENCODING, BINARY_SHORT, BINARY_SET_SHORT, LAST_EVALUATED_KEY, RESPONSES, UNPROCESSED_KEYS,
39-
UNPROCESSED_ITEMS, STREAM_SPECIFICATION, STREAM_VIEW_TYPE, STREAM_ENABLED,
39+
UNPROCESSED_ITEMS, STREAM_SPECIFICATION, STREAM_VIEW_TYPE, STREAM_ENABLED, UPDATE_EXPRESSION,
4040
EXPRESSION_ATTRIBUTE_NAMES, EXPRESSION_ATTRIBUTE_VALUES, KEY_CONDITION_OPERATOR_MAP,
4141
CONDITION_EXPRESSION, FILTER_EXPRESSION, FILTER_EXPRESSION_OPERATOR_MAP, NOT_CONTAINS, AND)
4242
from pynamodb.exceptions import (
@@ -45,6 +45,7 @@
4545
)
4646
from pynamodb.expressions.condition import Condition, Path
4747
from pynamodb.expressions.projection import create_projection_expression
48+
from pynamodb.expressions.update import AddAction, DeleteAction, RemoveAction, SetAction, Update
4849
from pynamodb.settings import get_settings_value
4950
from pynamodb.signals import pre_dynamodb_send, post_dynamodb_send
5051
from pynamodb.types import HASH, RANGE
@@ -866,20 +867,26 @@ def update_item(self,
866867
if not attribute_updates:
867868
raise ValueError("{0} cannot be empty".format(ATTR_UPDATES))
868869

869-
operation_kwargs[ATTR_UPDATES] = {}
870-
for key, update in attribute_updates.items():
871-
value = update.get(VALUE)
872-
attr_type, value = self.parse_attribute(value, return_type=True)
870+
update_expression = Update()
871+
# We sort the keys here for determinism. This is mostly done to simplify testing.
872+
for key in sorted(attribute_updates.keys()):
873+
update = attribute_updates[key]
873874
action = update.get(ACTION)
874-
if attr_type is None and action is not None and action.upper() != DELETE:
875-
attr_type = self.get_attribute_type(table_name, key, value)
876875
if action not in ATTR_UPDATE_ACTIONS:
877876
raise ValueError("{0} must be one of {1}".format(ACTION, ATTR_UPDATE_ACTIONS))
878-
operation_kwargs[ATTR_UPDATES][key] = {
879-
ACTION: action,
880-
}
881-
if action.upper() != DELETE:
882-
operation_kwargs[ATTR_UPDATES][key][VALUE] = {attr_type: value}
877+
value = update.get(VALUE)
878+
attr_type, value = self.parse_attribute(value, return_type=True)
879+
if attr_type is None and action != DELETE:
880+
attr_type = self.get_attribute_type(table_name, key, value)
881+
value = {attr_type: value}
882+
if action == DELETE:
883+
action = RemoveAction(key) if attr_type is None else DeleteAction(key, value)
884+
elif action == PUT:
885+
action = SetAction(key, value)
886+
else:
887+
action = AddAction(key, value)
888+
update_expression.add_action(action)
889+
operation_kwargs[UPDATE_EXPRESSION] = update_expression.serialize(name_placeholders, expression_attribute_values)
883890

884891
# We read the conditional operator even without expected passed in to maintain existing behavior.
885892
conditional_operator = self.get_conditional_operator(conditional_operator or AND)
@@ -1368,9 +1375,7 @@ def _get_condition_expression(self, table_name, expected, conditional_operator,
13681375
condition_expression = None
13691376
conditional_operator = conditional_operator[CONDITIONAL_OPERATOR]
13701377
# We sort the keys here for determinism. This is mostly done to simplify testing.
1371-
keys = list(expected.keys())
1372-
keys.sort()
1373-
for key in keys:
1378+
for key in sorted(expected.keys()):
13741379
condition = expected[key]
13751380
if EXISTS in condition:
13761381
operator = NOT_NULL if condition.get(EXISTS, True) else NULL
@@ -1404,9 +1409,7 @@ def _get_filter_expression(self, table_name, filters, conditional_operator,
14041409
condition_expression = None
14051410
conditional_operator = conditional_operator[CONDITIONAL_OPERATOR]
14061411
# We sort the keys here for determinism. This is mostly done to simplify testing.
1407-
keys = list(filters.keys())
1408-
keys.sort()
1409-
for key in keys:
1412+
for key in sorted(filters.keys()):
14101413
condition = filters[key]
14111414
operator = condition.get(COMPARISON_OPERATOR)
14121415
if operator not in QUERY_FILTER_VALUES:
@@ -1438,6 +1441,9 @@ def _check_condition(self, name, condition, expected_or_filter, conditional_oper
14381441
raise ValueError("'{0}' must be an instance of Condition".format(name))
14391442
if expected_or_filter or conditional_operator is not None:
14401443
raise ValueError("Legacy conditional parameters cannot be used with condition expressions")
1444+
else:
1445+
if expected_or_filter or conditional_operator is not None:
1446+
warnings.warn("Legacy conditional parameters are deprecated in favor of condition expressions")
14411447

14421448
@staticmethod
14431449
def _reverse_dict(d):

pynamodb/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
FILTER_EXPRESSION = 'FilterExpression'
7070
KEY_CONDITION_EXPRESSION = 'KeyConditionExpression'
7171
PROJECTION_EXPRESSION = 'ProjectionExpression'
72+
UPDATE_EXPRESSION = 'UpdateExpression'
7273

7374
# Defaults
7475
DEFAULT_ENCODING = 'utf-8'

pynamodb/expressions/update.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
from pynamodb.constants import BINARY_SET_SHORT, LIST_SHORT, NUMBER_SET_SHORT, NUMBER_SHORT, STRING_SET_SHORT
2+
from pynamodb.expressions.util import get_value_placeholder, substitute_names
3+
4+
5+
class Action(object):
6+
format_string = ''
7+
8+
def __init__(self, path, value=None):
9+
self.path = path
10+
self.value = value
11+
12+
def serialize(self, placeholder_names, expression_attribute_values):
13+
path = substitute_names(self.path, placeholder_names, split=True)
14+
value = get_value_placeholder(self.value, expression_attribute_values) if self.value else None
15+
return self.format_string.format(value, path=path)
16+
17+
18+
class SetAction(Action):
19+
"""
20+
The SET action adds an attribute to an item.
21+
"""
22+
format_string = '{path} = {0}'
23+
24+
def __init__(self, path, value):
25+
super(SetAction, self).__init__(path, value)
26+
27+
28+
class IncrementAction(SetAction):
29+
"""
30+
A SET action that is used to add to a number attribute.
31+
"""
32+
format_string = '{path} = {path} + {0}'
33+
34+
def __init__(self, path, amount):
35+
(attr_type, value), = amount.items()
36+
if attr_type != NUMBER_SHORT:
37+
raise ValueError("{0} must be a number".format(value))
38+
super(IncrementAction, self).__init__(path, amount)
39+
40+
41+
class DecrementAction(SetAction):
42+
"""
43+
A SET action that is used to subtract from a number attribute.
44+
"""
45+
format_string = '{path} = {path} - {0}'
46+
47+
def __init__(self, path, amount):
48+
(attr_type, value), = amount.items()
49+
if attr_type != NUMBER_SHORT:
50+
raise ValueError("{0} must be a number".format(value))
51+
super(DecrementAction, self).__init__(path, amount)
52+
53+
54+
class AppendAction(SetAction):
55+
"""
56+
A SET action that appends elements to the end of a list.
57+
"""
58+
format_string = '{path} = list_append({path}, {0})'
59+
60+
def __init__(self, path, elements):
61+
(attr_type, value), = elements.items()
62+
if attr_type != LIST_SHORT:
63+
raise ValueError("{0} must be a list".format(value))
64+
super(AppendAction, self).__init__(path, elements)
65+
66+
67+
class PrependAction(SetAction):
68+
"""
69+
A SET action that prepends elements to the beginning of a list.
70+
"""
71+
format_string = '{path} = list_append({0}, {path})'
72+
73+
def __init__(self, path, elements):
74+
(attr_type, value), = elements.items()
75+
if attr_type != LIST_SHORT:
76+
raise ValueError("{0} must be a list".format(value))
77+
super(PrependAction, self).__init__(path, elements)
78+
79+
80+
class SetIfNotExistsAction(SetAction):
81+
"""
82+
A SET action that avoids overwriting an existing attribute.
83+
"""
84+
format_string = '{path} = if_not_exists({path}, {0})'
85+
86+
87+
class RemoveAction(Action):
88+
"""
89+
The REMOVE action deletes an attribute from an item.
90+
"""
91+
format_string = '{path}'
92+
93+
def __init__(self, path):
94+
super(RemoveAction, self).__init__(path)
95+
96+
97+
class AddAction(Action):
98+
"""
99+
The ADD action appends elements to a set or mathematically adds to a number attribute.
100+
"""
101+
format_string = '{path} {0}'
102+
103+
def __init__(self, path, subset):
104+
(attr_type, value), = subset.items()
105+
if attr_type not in [BINARY_SET_SHORT, NUMBER_SET_SHORT, NUMBER_SHORT, STRING_SET_SHORT]:
106+
raise ValueError("{0} must be a number or set".format(value))
107+
super(AddAction, self).__init__(path, subset)
108+
109+
110+
class DeleteAction(Action):
111+
"""
112+
The DELETE action removes elements from a set.
113+
"""
114+
format_string = '{path} {0}'
115+
116+
def __init__(self, path, subset):
117+
(attr_type, value), = subset.items()
118+
if attr_type not in [BINARY_SET_SHORT, NUMBER_SET_SHORT, STRING_SET_SHORT]:
119+
raise ValueError("{0} must be a set".format(value))
120+
super(DeleteAction, self).__init__(path, subset)
121+
122+
123+
class Update(object):
124+
125+
def __init__(self):
126+
self.set_actions = []
127+
self.remove_actions = []
128+
self.add_actions = []
129+
self.delete_actions = []
130+
131+
def add_action(self, action):
132+
if isinstance(action, SetAction):
133+
self.set_actions.append(action)
134+
elif isinstance(action, RemoveAction):
135+
self.remove_actions.append(action)
136+
elif isinstance(action, AddAction):
137+
self.add_actions.append(action)
138+
elif isinstance(action, DeleteAction):
139+
self.delete_actions.append(action)
140+
else:
141+
raise ValueError("unsupported action type: '{0}'".format(action.__class__.__name__))
142+
143+
def serialize(self, placeholder_names, expression_attribute_values):
144+
expression = None
145+
expression = self._add_clause(expression, 'SET', self.set_actions, placeholder_names, expression_attribute_values)
146+
expression = self._add_clause(expression, 'REMOVE', self.remove_actions, placeholder_names, expression_attribute_values)
147+
expression = self._add_clause(expression, 'ADD', self.add_actions, placeholder_names, expression_attribute_values)
148+
expression = self._add_clause(expression, 'DELETE', self.delete_actions, placeholder_names, expression_attribute_values)
149+
return expression
150+
151+
@staticmethod
152+
def _add_clause(expression, keyword, actions, placeholder_names, expression_attribute_values):
153+
clause = Update._get_clause(keyword, actions, placeholder_names, expression_attribute_values)
154+
if clause is None:
155+
return expression
156+
return clause if expression is None else expression + " " + clause
157+
158+
@staticmethod
159+
def _get_clause(keyword, actions, placeholder_names, expression_attribute_values):
160+
actions = ", ".join([action.serialize(placeholder_names, expression_attribute_values) for action in actions])
161+
return keyword + " " + actions if actions else None

0 commit comments

Comments
 (0)