Skip to content

Commit 9e01729

Browse files
authored
Support document paths and attribute names containing dot characters. (#322)
1 parent c64df08 commit 9e01729

File tree

5 files changed

+188
-32
lines changed

5 files changed

+188
-32
lines changed

pynamodb/attributes.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -73,33 +73,49 @@ def get_value(self, value):
7373
def __eq__(self, other):
7474
if other is None or isinstance(other, Attribute): # handle object identity comparison
7575
return self is other
76-
return Path(self.attr_name).__eq__(self._get_attribute_value(other))
76+
return AttributePath(self).__eq__(other)
7777

7878
def __lt__(self, other):
79-
return Path(self.attr_name).__lt__(self._get_attribute_value(other))
79+
return AttributePath(self).__lt__(other)
8080

8181
def __le__(self, other):
82-
return Path(self.attr_name).__le__(self._get_attribute_value(other))
82+
return AttributePath(self).__le__(other)
8383

8484
def __gt__(self, other):
85-
return Path(self.attr_name).__gt__(self._get_attribute_value(other))
85+
return AttributePath(self).__gt__(other)
8686

8787
def __ge__(self, other):
88-
return Path(self.attr_name).__ge__(self._get_attribute_value(other))
88+
return AttributePath(self).__ge__(other)
8989

90-
def __getitem__(self, idx): # support accessing list elements in condition expressions
91-
if not isinstance(idx, int):
92-
raise TypeError('list indices must be integers, not {}'.format(type(idx).__name__))
93-
return Path('{0}[{1}]'.format(self.attr_name, idx)) # TODO include attribute value formatting
90+
def __getitem__(self, idx):
91+
return AttributePath(self)[idx]
9492

95-
def between(self, value1, value2):
96-
return Path(self.attr_name).between(self._get_attribute_value(value1), self._get_attribute_value(value2))
93+
def between(self, lower, upper):
94+
return AttributePath(self).between(lower, upper)
9795

9896
def startswith(self, prefix):
99-
return Path(self.attr_name).startswith(self._get_attribute_value(prefix))
97+
return AttributePath(self).startswith(prefix)
10098

101-
def _get_attribute_value(self, value):
102-
return {ATTR_TYPE_MAP[self.attr_type]: self.serialize(value)}
99+
100+
class AttributePath(Path):
101+
102+
def __init__(self, attribute):
103+
super(AttributePath, self).__init__(attribute.attr_name, attribute_name=True)
104+
self.attribute = attribute
105+
106+
def __getitem__(self, idx):
107+
if self.attribute.attr_type != LIST: # only list elements support the list dereference operator
108+
raise TypeError("'{0}' object has no attribute __getitem__".format(self.attribute.__class__.__name__))
109+
return super(AttributePath, self).__getitem__(idx)
110+
111+
def _serialize(self, value):
112+
if self.attribute.attr_type == LIST and not isinstance(value, list):
113+
# List attributes assume the values to be serialized are lists.
114+
return self.attribute.serialize([value])[0]
115+
if self.attribute.attr_type == MAP and not isinstance(value, dict):
116+
# Map attributes assume the values to be serialized are maps.
117+
return self.attribute.serialize({'': value})['']
118+
return {ATTR_TYPE_MAP[self.attribute.attr_type]: self.attribute.serialize(value)}
103119

104120

105121
class AttributeContainer(object):

pynamodb/expressions/condition.py

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
1+
from copy import copy
2+
from pynamodb.constants import AND, BETWEEN
13
from pynamodb.expressions.util import get_value_placeholder, substitute_names
24

35

46
class Path(object):
57

6-
def __init__(self, path):
8+
def __init__(self, path, attribute_name=False):
79
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
819

920
def __eq__(self, other):
1021
return self._compare('=', other)
@@ -21,18 +32,32 @@ def __gt__(self, other):
2132
def __ge__(self, other):
2233
return self._compare('>=', other)
2334

24-
def _compare(self, operator, value):
25-
return Condition(self.path, operator, value)
35+
def _compare(self, operator, other):
36+
return Condition(self, operator, self._serialize(other))
2637

27-
def between(self, value1, value2):
38+
def between(self, lower, upper):
2839
# This seemed preferable to other options such as merging value1 <= attribute & attribute <= value2
2940
# into one condition expression. DynamoDB only allows a single sort key comparison and having this
3041
# work but similar expressions like value1 <= attribute & attribute < value2 fail seems too brittle.
31-
return Between(self.path, value1, value2)
42+
return Between(self, self._serialize(lower), self._serialize(upper))
3243

3344
def startswith(self, prefix):
3445
# A 'pythonic' replacement for begins_with to match string behavior (e.g. "foo".startswith("f"))
35-
return BeginsWith(self.path, prefix)
46+
return BeginsWith(self, self._serialize(prefix))
47+
48+
def _serialize(self, value):
49+
# Allow subclasses to define value serialization.
50+
return value
51+
52+
def __str__(self):
53+
if self.attribute_name and '.' in self.path:
54+
# Quote the path to illustrate that the dot characters are not dereference operators.
55+
path, sep, rem = self.path.partition('[')
56+
return repr(path) + sep + rem
57+
return self.path
58+
59+
def __repr__(self):
60+
return "Path('{0}', attribute_name={1})".format(self.path, self.attribute_name)
3661

3762

3863
class Condition(object):
@@ -46,7 +71,8 @@ def __init__(self, path, operator, *values):
4671
self.other_condition = None
4772

4873
def serialize(self, placeholder_names, expression_attribute_values):
49-
path = substitute_names(self.path, placeholder_names)
74+
split = not self.path.attribute_name
75+
path = substitute_names(self.path.path, placeholder_names, split=split)
5076
values = [get_value_placeholder(value, expression_attribute_values) for value in self.values]
5177
condition = self.format_string.format(*values, path=path, operator=self.operator)
5278
if self.logical_operator:
@@ -58,10 +84,18 @@ def __and__(self, other):
5884
if not isinstance(other, Condition):
5985
raise TypeError("unsupported operand type(s) for &: '{0}' and '{1}'",
6086
self.__class__.__name__, other.__class__.__name__)
61-
self.logical_operator = 'AND'
87+
self.logical_operator = AND
6288
self.other_condition = other
6389
return self
6490

91+
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
98+
6599
def __nonzero__(self):
66100
# Prevent users from accidentally comparing the condition object instead of the attribute instance
67101
raise TypeError("unsupported operand type(s) for bool: '{0}'".format(self.__class__.__name__))
@@ -74,12 +108,12 @@ def __bool__(self):
74108
class Between(Condition):
75109
format_string = '{path} {operator} {0} AND {1}'
76110

77-
def __init__(self, attribute, value1, value2):
78-
super(Between, self).__init__(attribute, 'BETWEEN', value1, value2)
111+
def __init__(self, path, lower, upper):
112+
super(Between, self).__init__(path, BETWEEN, lower, upper)
79113

80114

81115
class BeginsWith(Condition):
82116
format_string = '{operator} ({path}, {0})'
83117

84-
def __init__(self, attribute, prefix):
85-
super(BeginsWith, self).__init__(attribute, 'begins_with', prefix)
118+
def __init__(self, path, prefix):
119+
super(BeginsWith, self).__init__(path, 'begins_with', prefix)

pynamodb/expressions/projection.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
1+
from pynamodb.attributes import Attribute
2+
from pynamodb.expressions.condition import Path
13
from pynamodb.expressions.util import substitute_names
24

35

46
def create_projection_expression(attributes_to_get, placeholders):
5-
expressions = [substitute_names(attribute, placeholders) for attribute in attributes_to_get]
7+
if not isinstance(attributes_to_get, list):
8+
attributes_to_get = [attributes_to_get]
9+
expression_split_pairs = [_get_expression_split_pair(attribute) for attribute in attributes_to_get]
10+
expressions = [substitute_names(expr, placeholders, split=split) for (expr, split) in expression_split_pairs]
611
return ', '.join(expressions)
12+
13+
14+
def _get_expression_split_pair(attribute):
15+
if isinstance(attribute, Attribute):
16+
return attribute.attr_name, False
17+
if isinstance(attribute, Path):
18+
return attribute.path, not attribute.attribute_name
19+
return attribute, True

pynamodb/expressions/util.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
PATH_SEGMENT_REGEX = re.compile(r'([^\[\]]+)((?:\[\d+\])*)$')
44

55

6-
def substitute_names(expression, placeholders):
6+
def substitute_names(expression, placeholders, split=True):
77
"""
88
Replaces names in the given expression with placeholders.
99
Stores the placeholders in the given dictionary.
1010
"""
11-
path_segments = expression.split('.')
11+
path_segments = expression.split('.') if split else [expression]
1212
for idx, segment in enumerate(path_segments):
1313
match = PATH_SEGMENT_REGEX.match(segment)
1414
if not match:

pynamodb/tests/test_expressions.py

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,36 @@
1-
from pynamodb.attributes import UnicodeAttribute
1+
from pynamodb.attributes import ListAttribute, UnicodeAttribute
22
from pynamodb.compat import CompatTestCase as TestCase
3+
from pynamodb.expressions.condition import Path
34
from pynamodb.expressions.projection import create_projection_expression
45

56

7+
class PathTestCase(TestCase):
8+
9+
def test_document_path(self):
10+
path = Path('foo.bar')
11+
assert str(path) == 'foo.bar'
12+
assert repr(path) == "Path('foo.bar', attribute_name=False)"
13+
14+
def test_attribute_name(self):
15+
path = Path('foo.bar', attribute_name=True)
16+
assert str(path) == "'foo.bar'"
17+
assert repr(path) == "Path('foo.bar', attribute_name=True)"
18+
19+
def test_index_document_path(self):
20+
path = Path('foo.bar')[0]
21+
assert str(path) == 'foo.bar[0]'
22+
assert repr(path) == "Path('foo.bar[0]', attribute_name=False)"
23+
24+
def test_index_attribute_name(self):
25+
path = Path('foo.bar', attribute_name=True)[0]
26+
assert str(path) == "'foo.bar'[0]"
27+
assert repr(path) == "Path('foo.bar[0]', attribute_name=True)"
28+
29+
def test_index_invalid(self):
30+
with self.assertRaises(TypeError):
31+
Path('foo.bar')['foo']
32+
33+
634
class ProjectionExpressionTestCase(TestCase):
735

836
def test_create_projection_expression(self):
@@ -25,6 +53,42 @@ def test_create_projection_expression_invalid_attribute_raises(self):
2553
with self.assertRaises(ValueError):
2654
create_projection_expression([attribute], {})
2755

56+
def test_create_project_expression_with_document_paths(self):
57+
attributes_to_get = [Path('foo.bar')[0]]
58+
placeholders = {}
59+
projection_expression = create_projection_expression(attributes_to_get, placeholders)
60+
assert projection_expression == "#0.#1[0]"
61+
assert placeholders == {'foo': '#0', 'bar': '#1'}
62+
63+
def test_create_project_expression_with_attribute_names(self):
64+
attributes_to_get = [Path('foo.bar', attribute_name=True)[0]]
65+
placeholders = {}
66+
projection_expression = create_projection_expression(attributes_to_get, placeholders)
67+
assert projection_expression == "#0[0]"
68+
assert placeholders == {'foo.bar': '#0'}
69+
70+
def test_create_projection_expression_with_attributes(self):
71+
attributes_to_get = [
72+
UnicodeAttribute(attr_name='ProductReviews.FiveStar'),
73+
UnicodeAttribute(attr_name='ProductReviews.ThreeStar'),
74+
UnicodeAttribute(attr_name='ProductReviews.OneStar')
75+
]
76+
placeholders = {}
77+
projection_expression = create_projection_expression(attributes_to_get, placeholders)
78+
assert projection_expression == "#0, #1, #2"
79+
assert placeholders == {
80+
'ProductReviews.FiveStar': '#0',
81+
'ProductReviews.ThreeStar': '#1',
82+
'ProductReviews.OneStar': '#2',
83+
}
84+
85+
def test_create_projection_expression_not_a_list(self):
86+
attributes_to_get = 'Description'
87+
placeholders = {}
88+
projection_expression = create_projection_expression(attributes_to_get, placeholders)
89+
assert projection_expression == "#0"
90+
assert placeholders == {'Description': '#0'}
91+
2892

2993
class ConditionExpressionTestCase(TestCase):
3094

@@ -88,9 +152,38 @@ def test_begins_with(self):
88152
assert expression_attribute_values == {':0': {'S' : 'bar'}}
89153

90154
def test_indexing(self):
91-
condition = self.attribute[0] == 'bar'
155+
condition = ListAttribute(attr_name='foo')[0] == 'bar'
92156
placeholder_names, expression_attribute_values = {}, {}
93157
expression = condition.serialize(placeholder_names, expression_attribute_values)
94158
assert expression == "#0[0] = :0"
95159
assert placeholder_names == {'foo': '#0'}
96-
assert expression_attribute_values == {':0': 'bar'} # TODO fix attribute value formatting
160+
assert expression_attribute_values == {':0': {'S' : 'bar'}}
161+
162+
def test_invalid_indexing(self):
163+
with self.assertRaises(TypeError):
164+
self.attribute[0]
165+
166+
def test_double_indexing(self):
167+
condition = ListAttribute(attr_name='foo')[0][1] == 'bar'
168+
placeholder_names, expression_attribute_values = {}, {}
169+
expression = condition.serialize(placeholder_names, expression_attribute_values)
170+
assert expression == "#0[0][1] = :0"
171+
assert placeholder_names == {'foo': '#0'}
172+
assert expression_attribute_values == {':0': {'S' : 'bar'}}
173+
174+
def test_list_comparison(self):
175+
condition = ListAttribute(attr_name='foo') == ['bar', 'baz']
176+
placeholder_names, expression_attribute_values = {}, {}
177+
expression = condition.serialize(placeholder_names, expression_attribute_values)
178+
assert expression == "#0 = :0"
179+
assert placeholder_names == {'foo': '#0'}
180+
assert expression_attribute_values == {':0': {'L': [{'S' : 'bar'}, {'S': 'baz'}]}}
181+
182+
def test_dotted_attribute_name(self):
183+
self.attribute.attr_name = 'foo.bar'
184+
condition = self.attribute == 'baz'
185+
placeholder_names, expression_attribute_values = {}, {}
186+
expression = condition.serialize(placeholder_names, expression_attribute_values)
187+
assert expression == "#0 = :0"
188+
assert placeholder_names == {'foo.bar': '#0'}
189+
assert expression_attribute_values == {':0': {'S': 'baz'}}

0 commit comments

Comments
 (0)