Skip to content

Commit 6f77cb3

Browse files
authored
Support "raw" MapAttribute dereferencing in condition expressions. (#362)
1 parent 912b869 commit 6f77cb3

File tree

4 files changed

+92
-16
lines changed

4 files changed

+92
-16
lines changed

docs/conditional.rst

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,24 @@ for more details.
5454
OR, \|, (Thread.views < 1) | (Thread.views > 5)
5555
NOT, ~, ~Thread.subject.contains('foobar')
5656

57-
If necessary, you can use document paths to access nested list and map attributes:
57+
Conditions expressions using nested list and map attributes can be created with Python's item operator ``[]``:
5858

5959
.. code-block:: python
6060
61-
from pynamodb.expressions.condition import size
61+
from pynamodb.models import Model
62+
from pynamodb.attributes import (
63+
ListAttribute, MapAttribute, UnicodeAttribute
64+
)
65+
66+
class Container(Model):
67+
class Meta:
68+
table_name = 'Container'
69+
70+
name = UnicodeAttribute(hash_key = True)
71+
my_map = MapAttribute()
72+
my_list = ListAttribute()
6273
63-
print(size('foo.bar[0].baz') == 0)
74+
print(Container.my_map['foo'].exists() | Container.my_list[0].contains('bar'))
6475
6576
6677
Conditional Model.save

pynamodb/attributes.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,17 @@ def __iter__(self):
667667
return iter(self.attribute_values)
668668

669669
def __getitem__(self, item):
670-
return self.attribute_values[item]
670+
if self._is_attribute_container():
671+
return self.attribute_values[item]
672+
# If this instance is being used as an Attribute, treat item access like the map dereference operator.
673+
# This provides equivalence between DynamoDB's nested attribute access for map elements (MyMap.nestedField)
674+
# and Python's item access for dictionaries (MyMap['nestedField']).
675+
if self.is_raw():
676+
return Path(self.attr_path + [str(item)])
677+
elif item in self._attributes:
678+
return getattr(self, item)
679+
else:
680+
raise AttributeError("'{0}' has no attribute '{1}'".format(self.__class__.__name__, item))
671681

672682
def __getattr__(self, attr):
673683
# This should only be called for "raw" (i.e. non-subclassed) MapAttribute instances.

pynamodb/expressions/operand.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from pynamodb.constants import (
2-
ATTR_TYPE_MAP, BINARY_SET, LIST, LIST_SHORT, MAP, NUMBER_SET, NUMBER_SHORT, SHORT_ATTR_TYPES, STRING_SET, STRING_SHORT
2+
ATTR_TYPE_MAP, BINARY_SET, LIST, LIST_SHORT, MAP, MAP_SHORT,
3+
NUMBER_SET, NUMBER_SHORT, SHORT_ATTR_TYPES, STRING_SET, STRING_SHORT
34
)
45
from pynamodb.expressions.condition import (
56
BeginsWith, Between, Comparison, Contains, Exists, In, IsType, NotExists
@@ -8,6 +9,7 @@
89
AddAction, DeleteAction, RemoveAction, SetAction
910
)
1011
from pynamodb.expressions.util import get_path_segments, get_value_placeholder, substitute_names
12+
from six import string_types
1113

1214

1315
class _Operand(object):
@@ -239,17 +241,24 @@ def __init__(self, attribute_or_path):
239241
def path(self):
240242
return self.values[0]
241243

242-
def __getitem__(self, idx):
243-
# list dereference operator
244-
if self.attribute and self.attribute.attr_type != LIST:
245-
raise TypeError("'{0}' object has no attribute __getitem__".format(self.attribute.__class__.__name__))
246-
if not isinstance(idx, int):
247-
raise TypeError("list indices must be integers, not {0}".format(type(idx).__name__))
244+
def __getitem__(self, item):
248245
# The __getitem__ call returns a new Path instance without any attribute set.
249-
# This is intended since the list element is not the same attribute as the list itself.
250-
element_path = Path(self.path) # copy the document path before indexing last element
251-
element_path.path[-1] = '{0}[{1}]'.format(self.path[-1], idx)
252-
return element_path
246+
# This is intended since the nested element is not the same attribute as ``self``.
247+
if self.attribute and self.attribute.attr_type not in [LIST, MAP]:
248+
raise TypeError("'{0}' object has no attribute __getitem__".format(self.attribute.__class__.__name__))
249+
if self.short_attr_type == LIST_SHORT and not isinstance(item, int):
250+
raise TypeError("list indices must be integers, not {0}".format(type(item).__name__))
251+
if self.short_attr_type == MAP_SHORT and not isinstance(item, string_types):
252+
raise TypeError("map attributes must be strings, not {0}".format(type(item).__name__))
253+
if isinstance(item, int):
254+
# list dereference operator
255+
element_path = Path(self.path) # copy the document path before indexing last element
256+
element_path.path[-1] = '{0}[{1}]'.format(self.path[-1], item)
257+
return element_path
258+
if isinstance(item, string_types):
259+
# map dereference operator
260+
return Path(self.path + [item])
261+
raise TypeError("item must be an integer or string, not {0}".format(type(item).__name__))
253262

254263
def __or__(self, other):
255264
return _IfNotExists(self, self._to_operand(other))

pynamodb/tests/test_expressions.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,14 @@ def test_index_attribute_name(self):
2828
assert str(path) == "'foo.bar'[0]"
2929
assert repr(path) == "Path(['foo.bar[0]'])"
3030

31+
def test_index_map_attribute(self):
32+
path = Path(['foo.bar'])['baz']
33+
assert str(path) == "'foo.bar'.baz"
34+
assert repr(path) == "Path(['foo.bar', 'baz'])"
35+
3136
def test_index_invalid(self):
3237
with self.assertRaises(TypeError):
33-
Path('foo.bar')['foo']
38+
Path('foo.bar')[0.0]
3439

3540

3641
class ActionTestCase(TestCase):
@@ -326,6 +331,19 @@ def test_dotted_attribute_name(self):
326331
assert placeholder_names == {'foo.bar': '#0'}
327332
assert expression_attribute_values == {':0': {'S': 'baz'}}
328333

334+
def test_map_attribute_indexing(self):
335+
# Simulate initialization from inside an AttributeContainer
336+
my_map_attribute = MapAttribute(attr_name='foo.bar')
337+
my_map_attribute._make_attribute()
338+
my_map_attribute._update_attribute_paths(my_map_attribute.attr_name)
339+
340+
condition = my_map_attribute['foo'] == 'baz'
341+
placeholder_names, expression_attribute_values = {}, {}
342+
expression = condition.serialize(placeholder_names, expression_attribute_values)
343+
assert expression == "#0.#1 = :0"
344+
assert placeholder_names == {'foo.bar': '#0', 'foo': '#1'}
345+
assert expression_attribute_values == {':0': {'S': 'baz'}}
346+
329347
def test_map_attribute_dereference(self):
330348
class MyMapAttribute(MapAttribute):
331349
nested_string = self.attribute
@@ -342,6 +360,34 @@ class MyMapAttribute(MapAttribute):
342360
assert placeholder_names == {'foo.bar': '#0', 'foo': '#1'}
343361
assert expression_attribute_values == {':0': {'S': 'baz'}}
344362

363+
def test_map_attribute_dereference_via_indexing(self):
364+
class MyMapAttribute(MapAttribute):
365+
nested_string = self.attribute
366+
367+
# Simulate initialization from inside an AttributeContainer
368+
my_map_attribute = MyMapAttribute(attr_name='foo.bar')
369+
my_map_attribute._make_attribute()
370+
my_map_attribute._update_attribute_paths(my_map_attribute.attr_name)
371+
372+
condition = my_map_attribute['nested_string'] == 'baz'
373+
placeholder_names, expression_attribute_values = {}, {}
374+
expression = condition.serialize(placeholder_names, expression_attribute_values)
375+
assert expression == "#0.#1 = :0"
376+
assert placeholder_names == {'foo.bar': '#0', 'foo': '#1'}
377+
assert expression_attribute_values == {':0': {'S': 'baz'}}
378+
379+
def test_map_attribute_dereference_via_indexing_missing_attribute(self):
380+
class MyMapAttribute(MapAttribute):
381+
nested_string = self.attribute
382+
383+
# Simulate initialization from inside an AttributeContainer
384+
my_map_attribute = MyMapAttribute(attr_name='foo.bar')
385+
my_map_attribute._make_attribute()
386+
my_map_attribute._update_attribute_paths(my_map_attribute.attr_name)
387+
388+
with self.assertRaises(AttributeError):
389+
my_map_attribute['missing_attribute'] == 'baz'
390+
345391

346392
class UpdateExpressionTestCase(TestCase):
347393

0 commit comments

Comments
 (0)