Skip to content

Commit ea2a2ee

Browse files
authored
Support complete condition expression syntax. (#329)
1 parent 3560228 commit ea2a2ee

File tree

6 files changed

+298
-47
lines changed

6 files changed

+298
-47
lines changed

pynamodb/attributes.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ def __eq__(self, other):
7676
return self is other
7777
return AttributePath(self).__eq__(other)
7878

79+
def __ne__(self, other):
80+
if other is None or isinstance(other, Attribute): # handle object identity comparison
81+
return self is not other
82+
return AttributePath(self).__ne__(other)
83+
7984
def __lt__(self, other):
8085
return AttributePath(self).__lt__(other)
8186

@@ -94,9 +99,25 @@ def __getitem__(self, idx):
9499
def between(self, lower, upper):
95100
return AttributePath(self).between(lower, upper)
96101

102+
def is_in(self, *values):
103+
return AttributePath(self).is_in(*values)
104+
105+
def exists(self):
106+
return AttributePath(self).exists()
107+
108+
def not_exists(self):
109+
return AttributePath(self).not_exists()
110+
111+
def is_type(self):
112+
# What makes sense here? Are we using this to check if deserialization will be successful?
113+
return AttributePath(self).is_type(ATTR_TYPE_MAP[self.attr_type])
114+
97115
def startswith(self, prefix):
98116
return AttributePath(self).startswith(prefix)
99117

118+
def contains(self, item):
119+
return AttributePath(self).contains(item)
120+
100121

101122
class AttributePath(Path):
102123

pynamodb/expressions/condition.py

Lines changed: 178 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,20 @@
11
from copy import copy
2-
from pynamodb.constants import AND, BETWEEN
2+
from pynamodb.constants import AND, ATTR_TYPE_MAP, BETWEEN, IN, OR, SHORT_ATTR_TYPES, STRING_SHORT
33
from pynamodb.expressions.util import get_value_placeholder, substitute_names
4+
from six.moves import range
45

56

6-
class Path(object):
7-
8-
def __init__(self, path, attribute_name=False):
9-
self.path = path
10-
self.attribute_name = attribute_name
11-
12-
def __getitem__(self, idx):
13-
# list dereference operator
14-
if not isinstance(idx, int):
15-
raise TypeError("list indices must be integers, not {0}".format(type(idx).__name__))
16-
element_path = copy(self)
17-
element_path.path = '{0}[{1}]'.format(self.path, idx)
18-
return element_path
7+
class Operand(object):
8+
"""
9+
Operand is the base class for objects that support creating conditions from comparators.
10+
"""
1911

2012
def __eq__(self, other):
2113
return self._compare('=', other)
2214

15+
def __ne__(self, other):
16+
return self._compare('<>', other)
17+
2318
def __lt__(self, other):
2419
return self._compare('<', other)
2520

@@ -41,13 +36,85 @@ def between(self, lower, upper):
4136
# work but similar expressions like value1 <= attribute & attribute < value2 fail seems too brittle.
4237
return Between(self, self._serialize(lower), self._serialize(upper))
4338

39+
def is_in(self, *values):
40+
values = [self._serialize(value) for value in values]
41+
return In(self, *values)
42+
43+
def _serialize(self, value):
44+
# Check to see if value is already serialized
45+
if isinstance(value, dict) and len(value) == 1 and list(value.keys())[0] in SHORT_ATTR_TYPES:
46+
return value
47+
# Serialize value based on its type
48+
from pynamodb.attributes import _get_class_for_serialize
49+
attr_class = _get_class_for_serialize(value)
50+
return {ATTR_TYPE_MAP[attr_class.attr_type]: attr_class.serialize(value)}
51+
52+
53+
class Size(Operand):
54+
"""
55+
Size is a special operand that represents the result of calling the 'size' function on a Path operand.
56+
"""
57+
58+
def __init__(self, path):
59+
# prevent circular import -- AttributePath imports Path
60+
from pynamodb.attributes import Attribute, AttributePath
61+
if isinstance(path, Path):
62+
self.path = Path
63+
elif isinstance(path, Attribute):
64+
self.path = AttributePath(path)
65+
else:
66+
self.path = Path(path)
67+
68+
def _serialize(self, value):
69+
if not isinstance(value, int):
70+
raise TypeError("size must be compared to an integer, not {0}".format(type(value).__name__))
71+
return {'N': str(value)}
72+
73+
def __str__(self):
74+
return "size({0})".format(self.path)
75+
76+
def __repr__(self):
77+
return "Size({0})".format(repr(self.path))
78+
79+
80+
# match dynamo function syntax: size(path)
81+
def size(path):
82+
return Size(path)
83+
84+
85+
class Path(Operand):
86+
"""
87+
Path is an operand that represents either an attribute name or document path.
88+
In addition to supporting comparisons, Path also supports creating conditions from functions.
89+
"""
90+
91+
def __init__(self, path, attribute_name=False):
92+
self.path = path
93+
self.attribute_name = attribute_name
94+
95+
def __getitem__(self, idx):
96+
# list dereference operator
97+
if not isinstance(idx, int):
98+
raise TypeError("list indices must be integers, not {0}".format(type(idx).__name__))
99+
element_path = copy(self)
100+
element_path.path = '{0}[{1}]'.format(self.path, idx)
101+
return element_path
102+
103+
def exists(self):
104+
return Exists(self)
105+
106+
def not_exists(self):
107+
return NotExists(self)
108+
109+
def is_type(self, attr_type):
110+
return IsType(self, attr_type)
111+
44112
def startswith(self, prefix):
45113
# A 'pythonic' replacement for begins_with to match string behavior (e.g. "foo".startswith("f"))
46114
return BeginsWith(self, self._serialize(prefix))
47115

48-
def _serialize(self, value):
49-
# Allow subclasses to define value serialization.
50-
return value
116+
def contains(self, item):
117+
return Contains(self, self._serialize(item))
51118

52119
def __str__(self):
53120
if self.attribute_name and '.' in self.path:
@@ -67,34 +134,47 @@ def __init__(self, path, operator, *values):
67134
self.path = path
68135
self.operator = operator
69136
self.values = values
70-
self.logical_operator = None
71-
self.other_condition = None
72137

73138
def serialize(self, placeholder_names, expression_attribute_values):
74-
split = not self.path.attribute_name
75-
path = substitute_names(self.path.path, placeholder_names, split=split)
76-
values = [get_value_placeholder(value, expression_attribute_values) for value in self.values]
77-
condition = self.format_string.format(*values, path=path, operator=self.operator)
78-
if self.logical_operator:
79-
other_condition = self.other_condition.serialize(placeholder_names, expression_attribute_values)
80-
return '{0} {1} {2}'.format(condition, self.logical_operator, other_condition)
81-
return condition
139+
path = self._get_path(self.path, placeholder_names)
140+
values = self._get_values(placeholder_names, expression_attribute_values)
141+
return self.format_string.format(*values, path=path, operator=self.operator)
142+
143+
def _get_path(self, path, placeholder_names):
144+
if isinstance(path, Path):
145+
split = not path.attribute_name
146+
return substitute_names(path.path, placeholder_names, split=split)
147+
elif isinstance(path, Size):
148+
return "size ({0})".format(self._get_path(path.path, placeholder_names))
149+
else:
150+
return path
151+
152+
def _get_values(self, placeholder_names, expression_attribute_values):
153+
return [
154+
value.serialize(placeholder_names, expression_attribute_values)
155+
if isinstance(value, Condition)
156+
else get_value_placeholder(value, expression_attribute_values)
157+
for value in self.values
158+
]
82159

83160
def __and__(self, other):
84161
if not isinstance(other, Condition):
85162
raise TypeError("unsupported operand type(s) for &: '{0}' and '{1}'",
86163
self.__class__.__name__, other.__class__.__name__)
87-
self.logical_operator = AND
88-
self.other_condition = other
89-
return self
164+
return And(self, other)
165+
166+
def __or__(self, other):
167+
if not isinstance(other, Condition):
168+
raise TypeError("unsupported operand type(s) for |: '{0}' and '{1}'",
169+
self.__class__.__name__, other.__class__.__name__)
170+
return Or(self, other)
171+
172+
def __invert__(self):
173+
return Not(self)
90174

91175
def __repr__(self):
92-
values = [value.items()[0][1] for value in self.values]
93-
condition = self.format_string.format(*values, path=self.path, operator = self.operator)
94-
if self.logical_operator:
95-
other_conditions = repr(self.other_condition)
96-
return '{0} {1} {2}'.format(condition, self.logical_operator, other_conditions)
97-
return condition
176+
values = [repr(value) if isinstance(value, Condition) else value.items()[0][1] for value in self.values]
177+
return self.format_string.format(*values, path=self.path, operator = self.operator)
98178

99179
def __nonzero__(self):
100180
# Prevent users from accidentally comparing the condition object instead of the attribute instance
@@ -112,8 +192,70 @@ def __init__(self, path, lower, upper):
112192
super(Between, self).__init__(path, BETWEEN, lower, upper)
113193

114194

195+
class In(Condition):
196+
def __init__(self, path, *values):
197+
super(In, self).__init__(path, IN, *values)
198+
list_format = ', '.join('{' + str(i) + '}' for i in range(len(values)))
199+
self.format_string = '{path} {operator} (' + list_format + ')'
200+
201+
202+
class Exists(Condition):
203+
format_string = '{operator} ({path})'
204+
205+
def __init__(self, path):
206+
super(Exists, self).__init__(path, 'attribute_exists')
207+
208+
209+
class NotExists(Condition):
210+
format_string = '{operator} ({path})'
211+
212+
def __init__(self, path):
213+
super(NotExists, self).__init__(path, 'attribute_not_exists')
214+
215+
216+
class IsType(Condition):
217+
format_string = '{operator} ({path}, {0})'
218+
219+
def __init__(self, path, attr_type):
220+
if attr_type not in SHORT_ATTR_TYPES:
221+
raise ValueError("{0} is not a valid attribute type. Must be one of {1}".format(
222+
attr_type, SHORT_ATTR_TYPES))
223+
super(IsType, self).__init__(path, 'attribute_type', {STRING_SHORT: attr_type})
224+
225+
115226
class BeginsWith(Condition):
116227
format_string = '{operator} ({path}, {0})'
117228

118229
def __init__(self, path, prefix):
119230
super(BeginsWith, self).__init__(path, 'begins_with', prefix)
231+
232+
233+
class Contains(Condition):
234+
format_string = '{operator} ({path}, {0})'
235+
236+
def __init__(self, path, item):
237+
(attr_type, value), = item.items()
238+
if attr_type != STRING_SHORT:
239+
raise ValueError("{0} must be a string".format(value))
240+
super(Contains, self).__init__(path, 'contains', item)
241+
242+
243+
class And(Condition):
244+
format_string = '({0} {operator} {1})'
245+
246+
def __init__(self, condition1, condition2):
247+
super(And, self).__init__(None, AND, condition1, condition2)
248+
249+
250+
class Or(Condition):
251+
format_string = '({0} {operator} {1})'
252+
253+
def __init__(self, condition1, condition2):
254+
super(Or, self).__init__(None, OR, condition1, condition2)
255+
256+
257+
class Not(Condition):
258+
format_string = '({operator} {0})'
259+
260+
def __init__(self, condition):
261+
super(Not, self).__init__(None, 'NOT', condition)

pynamodb/tests/test_base_connection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,7 +1348,7 @@ def test_query(self):
13481348
'ScanIndexForward': True,
13491349
'Select': 'ALL_ATTRIBUTES',
13501350
'ReturnConsumedCapacity': 'TOTAL',
1351-
'KeyConditionExpression': '#0 = :0 AND begins_with (#1, :1)',
1351+
'KeyConditionExpression': '(#0 = :0 AND begins_with (#1, :1))',
13521352
'ExpressionAttributeNames': {
13531353
'#0': 'ForumName',
13541354
'#1': 'Subject'
@@ -1374,7 +1374,7 @@ def test_query(self):
13741374
)
13751375
params = {
13761376
'ReturnConsumedCapacity': 'TOTAL',
1377-
'KeyConditionExpression': '#0 = :0 AND begins_with (#1, :1)',
1377+
'KeyConditionExpression': '(#0 = :0 AND begins_with (#1, :1))',
13781378
'ExpressionAttributeNames': {
13791379
'#0': 'ForumName',
13801380
'#1': 'Subject'

0 commit comments

Comments
 (0)