Skip to content

Commit ecc2af8

Browse files
authored
Support MapAttribute map dereferencing in condition expressions. (#343)
1 parent ce770d9 commit ecc2af8

File tree

9 files changed

+159
-48
lines changed

9 files changed

+159
-48
lines changed

pynamodb/attributes.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from six import add_metaclass
66
import json
77
from base64 import b64encode, b64decode
8+
from copy import deepcopy
89
from datetime import datetime
910
from dateutil.parser import parse
1011
from dateutil.tz import tzutc
@@ -36,15 +37,27 @@ def __init__(self,
3637
self.null = null
3738
self.is_hash_key = hash_key
3839
self.is_range_key = range_key
39-
self.attr_name = attr_name
40+
self.attr_path = [attr_name]
41+
42+
@property
43+
def attr_name(self):
44+
return self.attr_path[-1]
45+
46+
@attr_name.setter
47+
def attr_name(self, value):
48+
self.attr_path[-1] = value
4049

4150
def __set__(self, instance, value):
4251
if instance and not self._is_map_attribute_class_object(instance):
4352
attr_name = instance._dynamo_to_python_attrs.get(self.attr_name, self.attr_name)
4453
instance.attribute_values[attr_name] = value
4554

4655
def __get__(self, instance, owner):
47-
if instance and not self._is_map_attribute_class_object(instance):
56+
if self._is_map_attribute_class_object(instance):
57+
# MapAttribute class objects store a local copy of the attribute with `attr_path` set to the document path.
58+
attr_name = instance._dynamo_to_python_attrs.get(self.attr_name, self.attr_name)
59+
return instance.__dict__.get(attr_name, None) or self
60+
elif instance:
4861
attr_name = instance._dynamo_to_python_attrs.get(self.attr_name, self.attr_name)
4962
return instance.attribute_values.get(attr_name, None)
5063
else:
@@ -121,12 +134,14 @@ def contains(self, item):
121134
class AttributePath(Path):
122135

123136
def __init__(self, attribute):
124-
super(AttributePath, self).__init__(attribute.attr_name, attribute_name=True)
137+
super(AttributePath, self).__init__(attribute.attr_path)
125138
self.attribute = attribute
126139

127140
def __getitem__(self, idx):
128141
if self.attribute.attr_type != LIST: # only list elements support the list dereference operator
129142
raise TypeError("'{0}' object has no attribute __getitem__".format(self.attribute.__class__.__name__))
143+
# The __getitem__ call returns a new Path instance, not an AttributePath instance.
144+
# This is intended since the list element is not the same attribute as the list itself.
130145
return super(AttributePath, self).__getitem__(idx)
131146

132147
def contains(self, item):
@@ -145,7 +160,7 @@ def _serialize(self, value):
145160
return self.attribute.serialize([value])[0]
146161
if self.attribute.attr_type == MAP and not isinstance(value, dict):
147162
# Map attributes assume the values to be serialized are maps.
148-
return self.attribute.serialize({'': value})['']
163+
return super(AttributePath, self)._serialize(value)
149164
return {ATTR_TYPE_MAP[self.attribute.attr_type]: self.attribute.serialize(value)}
150165

151166

@@ -174,17 +189,24 @@ def _initialize_attributes(cls):
174189

175190
if issubclass(item_cls, Attribute):
176191
instance = getattr(cls, item_name)
192+
initialized = False
177193
if isinstance(instance, MapAttribute):
178194
# MapAttribute instances that are class attributes of an AttributeContainer class
179195
# should behave like an Attribute instance and not an AttributeContainer instance.
180-
instance._make_attribute()
196+
initialized = instance._make_attribute()
181197

182198
cls._attributes[item_name] = instance
183199
if instance.attr_name is not None:
184200
cls._dynamo_to_python_attrs[instance.attr_name] = item_name
185201
else:
186202
instance.attr_name = item_name
187203

204+
if initialized and isinstance(instance, MapAttribute):
205+
# To support creating expressions from nested attributes, MapAttribute instances
206+
# store local copies of the attributes in cls._attributes with `attr_path` set.
207+
# Prepend the `attr_path` lists with the dynamo attribute name.
208+
instance._update_attribute_paths(instance.attr_name)
209+
188210

189211
@add_metaclass(AttributeContainerMeta)
190212
class AttributeContainer(object):
@@ -599,7 +621,7 @@ def __init__(self, **attributes):
599621
# It is possible that attributes names can collide with argument names of Attribute.__init__.
600622
# Assume that this is the case if any of the following are true:
601623
# - the user passed in other attributes that did not match any argument names
602-
# - this is "raw" (i.e. non-subclassed) MapAttribute instance and attempting to store the attributes
624+
# - this is a "raw" (i.e. non-subclassed) MapAttribute instance and attempting to store the attributes
603625
# cannot raise a ValueError (if this assumption is wrong, calling `_make_attribute` removes them)
604626
# - the names of all attributes in self.attribute_kwargs match attributes defined on the class
605627
if self.attribute_kwargs and (
@@ -615,12 +637,30 @@ def _is_attribute_container(self):
615637
def _make_attribute(self):
616638
# WARNING! This function is only intended to be called from the AttributeContainerMeta metaclass.
617639
if not self._is_attribute_container():
618-
return
640+
# This instance has already been initialized by another AttributeContainer class.
641+
return False
619642
# During initialization the kwargs were stored in `attribute_kwargs`. Remove them and re-initialize the class.
620643
kwargs = self.attribute_kwargs
621644
del self.attribute_kwargs
622645
del self.attribute_values
623646
Attribute.__init__(self, **kwargs)
647+
for name, attr in self._get_attributes().items():
648+
# Set a local attribute with the same name that shadows the class attribute.
649+
# Because attr is a data descriptor and the attribute already exists on the class,
650+
# we have to store the local copy directly into __dict__ to prevent calling attr.__set__.
651+
# Use deepcopy so that `attr_path` and any local attributes are also copied.
652+
self.__dict__[name] = deepcopy(attr)
653+
return True
654+
655+
def _update_attribute_paths(self, path_segment):
656+
# WARNING! This function is only intended to be called from the AttributeContainerMeta metaclass.
657+
if self._is_attribute_container():
658+
raise AssertionError("MapAttribute._update_attribute_paths called before MapAttribute._make_attribute")
659+
for name in self._get_attributes().keys():
660+
local_attr = self.__dict__[name]
661+
local_attr.attr_path.insert(0, path_segment)
662+
if isinstance(local_attr, MapAttribute):
663+
local_attr._update_attribute_paths(path_segment)
624664

625665
def __iter__(self):
626666
return iter(self.attribute_values)

pynamodb/connection/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1433,7 +1433,7 @@ def _get_condition(self, table_name, attribute_name, operator, *values):
14331433
{self.get_attribute_type(table_name, attribute_name, value): self.parse_attribute(value)}
14341434
for value in values
14351435
]
1436-
return getattr(Path(attribute_name, attribute_name=True), operator)(*values)
1436+
return getattr(Path([attribute_name]), operator)(*values)
14371437

14381438
def _check_condition(self, name, condition, expected_or_filter, conditional_operator):
14391439
if condition is not None:

pynamodb/expressions/condition.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from copy import copy
21
from pynamodb.constants import (
32
AND, ATTR_TYPE_MAP, BETWEEN, BINARY_SHORT, IN, NUMBER_SHORT, OR, SHORT_ATTR_TYPES, STRING_SHORT
43
)
54
from pynamodb.expressions.util import get_value_placeholder, substitute_names
5+
from six import string_types
66
from six.moves import range
77

88

@@ -90,16 +90,17 @@ class Path(Operand):
9090
In addition to supporting comparisons, Path also supports creating conditions from functions.
9191
"""
9292

93-
def __init__(self, path, attribute_name=False):
94-
self.path = path
95-
self.attribute_name = attribute_name
93+
def __init__(self, path):
94+
if not path:
95+
raise ValueError("path cannot be empty")
96+
self.path = path.split('.') if isinstance(path, string_types) else list(path)
9697

9798
def __getitem__(self, idx):
9899
# list dereference operator
99100
if not isinstance(idx, int):
100101
raise TypeError("list indices must be integers, not {0}".format(type(idx).__name__))
101-
element_path = copy(self)
102-
element_path.path = '{0}[{1}]'.format(self.path, idx)
102+
element_path = Path(self.path) # copy the document path before indexing last element
103+
element_path.path[-1] = '{0}[{1}]'.format(self.path[-1], idx)
103104
return element_path
104105

105106
def exists(self):
@@ -119,14 +120,17 @@ def contains(self, item):
119120
return Contains(self, self._serialize(item))
120121

121122
def __str__(self):
122-
if self.attribute_name and '.' in self.path:
123-
# Quote the path to illustrate that the dot characters are not dereference operators.
124-
path, sep, rem = self.path.partition('[')
125-
return repr(path) + sep + rem
126-
return self.path
123+
# Quote the path to illustrate that any dot characters are not dereference operators.
124+
quoted_path = [self._quote_path(segment) if '.' in segment else segment for segment in self.path]
125+
return '.'.join(quoted_path)
127126

128127
def __repr__(self):
129-
return "Path('{0}', attribute_name={1})".format(self.path, self.attribute_name)
128+
return "Path({0})".format(self.path)
129+
130+
@staticmethod
131+
def _quote_path(path):
132+
path, sep, rem = path.partition('[')
133+
return repr(path) + sep + rem
130134

131135

132136
class Condition(object):
@@ -148,8 +152,7 @@ def serialize(self, placeholder_names, expression_attribute_values):
148152

149153
def _get_path(self, path, placeholder_names):
150154
if isinstance(path, Path):
151-
split = not path.attribute_name
152-
return substitute_names(path.path, placeholder_names, split=split)
155+
return substitute_names(path.path, placeholder_names)
153156
elif isinstance(path, Size):
154157
return "size ({0})".format(self._get_path(path.path, placeholder_names))
155158
else:

pynamodb/expressions/projection.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@
66
def create_projection_expression(attributes_to_get, placeholders):
77
if not isinstance(attributes_to_get, list):
88
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]
9+
expressions = [substitute_names(_get_document_path(attribute), placeholders) for attribute in attributes_to_get]
1110
return ', '.join(expressions)
1211

1312

14-
def _get_expression_split_pair(attribute):
13+
def _get_document_path(attribute):
1514
if isinstance(attribute, Attribute):
16-
return attribute.attr_name, False
15+
return [attribute.attr_name]
1716
if isinstance(attribute, Path):
18-
return attribute.path, not attribute.attribute_name
19-
return attribute, True
17+
return attribute.path
18+
return attribute.split('.')

pynamodb/expressions/update.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def __init__(self, path, value=None):
1010
self.value = value
1111

1212
def serialize(self, placeholder_names, expression_attribute_values):
13-
path = substitute_names(self.path, placeholder_names, split=True)
13+
path = substitute_names(self.path.split('.'), placeholder_names)
1414
value = get_value_placeholder(self.value, expression_attribute_values) if self.value else None
1515
return self.format_string.format(value, path=path)
1616

pynamodb/expressions/util.py

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

55

6-
def substitute_names(expression, placeholders, split=True):
6+
def substitute_names(document_path, placeholders):
77
"""
8-
Replaces names in the given expression with placeholders.
8+
Replaces all attribute names in the given document path with placeholders.
99
Stores the placeholders in the given dictionary.
10+
11+
:param document_path: list of path segments (an attribute name and optional list dereference)
12+
:param placeholders: a dictionary to store mappings from attribute names to expression attribute name placeholders
13+
14+
For example: given the document_path for some attribute "baz", that is the first element of a list attribute "bar",
15+
that itself is a map element of "foo" (i.e. ['foo', 'bar[0], 'baz']) and an empty placeholders dictionary,
16+
`substitute_names` will return "#0.#1[0].#2" and placeholders will contain {"foo": "#0", "bar": "#1", "baz": "#2}
1017
"""
11-
path_segments = expression.split('.') if split else [expression]
12-
for idx, segment in enumerate(path_segments):
18+
for idx, segment in enumerate(document_path):
1319
match = PATH_SEGMENT_REGEX.match(segment)
1420
if not match:
15-
raise ValueError('{0} is not a valid document path'.format(expression))
21+
raise ValueError('{0} is not a valid document path'.format('.'.join(document_path)))
1622
name, indexes = match.groups()
1723
if name in placeholders:
1824
placeholder = placeholders[name]
1925
else:
2026
placeholder = '#' + str(len(placeholders))
2127
placeholders[name] = placeholder
22-
path_segments[idx] = placeholder + indexes
23-
return '.'.join(path_segments)
28+
document_path[idx] = placeholder + indexes
29+
return '.'.join(document_path)
2430

2531

2632
def get_value_placeholder(value, expression_attribute_values):

pynamodb/models.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,22 +215,22 @@ class Model(AttributeContainer):
215215
_throttle = NoThrottle()
216216
DoesNotExist = DoesNotExist
217217

218-
def __init__(self, hash_key=None, range_key=None, **attrs):
218+
def __init__(self, hash_key=None, range_key=None, **attributes):
219219
"""
220220
:param hash_key: Required. The hash key for this object.
221221
:param range_key: Only required if the table has a range key attribute.
222222
:param attrs: A dictionary of attributes to set on this object.
223223
"""
224224
if hash_key is not None:
225-
attrs[self._dynamo_to_python_attr(self._get_meta_data().hash_keyname)] = hash_key
225+
attributes[self._dynamo_to_python_attr(self._get_meta_data().hash_keyname)] = hash_key
226226
if range_key is not None:
227227
range_keyname = self._get_meta_data().range_keyname
228228
if range_keyname is None:
229229
raise ValueError(
230230
"This table has no range key, but a range key value was provided: {0}".format(range_key)
231231
)
232-
attrs[self._dynamo_to_python_attr(range_keyname)] = range_key
233-
super(Model, self).__init__(**attrs)
232+
attributes[self._dynamo_to_python_attr(range_keyname)] = range_key
233+
super(Model, self).__init__(**attributes)
234234

235235
@classmethod
236236
def has_map_or_list_attributes(cls):

pynamodb/tests/test_attributes.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,53 @@ class ThingModel(Model):
730730
def test_metaclass(self):
731731
assert type(MapAttribute) == MapAttributeMeta
732732

733+
def test_attribute_paths_subclassing(self):
734+
class SubMapAttribute(MapAttribute):
735+
foo = UnicodeAttribute(attr_name='dyn_foo')
736+
737+
class SubSubMapAttribute(SubMapAttribute):
738+
bar = UnicodeAttribute(attr_name='dyn_bar')
739+
740+
class SubModel(Model):
741+
sub_map = SubMapAttribute(attr_name='dyn_sub_map')
742+
743+
class SubSubModel(SubModel):
744+
sub_sub_map = SubSubMapAttribute()
745+
746+
assert SubModel.sub_map.foo.attr_name == 'dyn_foo'
747+
assert SubModel.sub_map.foo.attr_path == ['dyn_sub_map', 'dyn_foo']
748+
assert SubSubModel.sub_map.foo.attr_name == 'dyn_foo'
749+
assert SubSubModel.sub_map.foo.attr_path == ['dyn_sub_map', 'dyn_foo']
750+
assert SubSubModel.sub_sub_map.foo.attr_name == 'dyn_foo'
751+
assert SubSubModel.sub_sub_map.foo.attr_path == ['sub_sub_map', 'dyn_foo']
752+
assert SubSubModel.sub_sub_map.bar.attr_name == 'dyn_bar'
753+
assert SubSubModel.sub_sub_map.bar.attr_path == ['sub_sub_map', 'dyn_bar']
754+
755+
def test_attribute_paths_wrapping(self):
756+
class InnerMapAttribute(MapAttribute):
757+
map_attr = MapAttribute(attr_name='dyn_map_attr')
758+
759+
class MiddleMapAttributeA(MapAttribute):
760+
inner_map = InnerMapAttribute(attr_name='dyn_in_map_a')
761+
762+
class MiddleMapAttributeB(MapAttribute):
763+
inner_map = InnerMapAttribute(attr_name='dyn_in_map_b')
764+
765+
class OuterMapAttribute(MapAttribute):
766+
mid_map_a = MiddleMapAttributeA()
767+
mid_map_b = MiddleMapAttributeB()
768+
769+
class MyModel(Model):
770+
outer_map = OuterMapAttribute(attr_name='dyn_out_map')
771+
772+
mid_map_a_map_attr = MyModel.outer_map.mid_map_a.inner_map.map_attr
773+
mid_map_b_map_attr = MyModel.outer_map.mid_map_b.inner_map.map_attr
774+
775+
assert mid_map_a_map_attr.attr_name == 'dyn_map_attr'
776+
assert mid_map_a_map_attr.attr_path == ['dyn_out_map', 'mid_map_a', 'dyn_in_map_a', 'dyn_map_attr']
777+
assert mid_map_b_map_attr.attr_name == 'dyn_map_attr'
778+
assert mid_map_b_map_attr.attr_path == ['dyn_out_map', 'mid_map_b', 'dyn_in_map_b', 'dyn_map_attr']
779+
733780

734781
class TestValueDeserialize:
735782
def test__get_value_for_deserialize(self):

0 commit comments

Comments
 (0)