-
Notifications
You must be signed in to change notification settings - Fork 429
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Introduce DynamicMapAttribute #868
Changes from 8 commits
b2d3fb3
3db0b7e
072ca7b
c4bc955
654d854
edbd3f3
1ced2b8
21a8450
ce6a326
56cfad7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -862,20 +862,20 @@ def __getitem__(self, item: _KT) -> _VT: # type: ignore | |
# If this instance is being used as an Attribute, treat item access like the map dereference operator. | ||
# This provides equivalence between DynamoDB's nested attribute access for map elements (MyMap.nestedField) | ||
# and Python's item access for dictionaries (MyMap['nestedField']). | ||
if self.is_raw(): | ||
return Path(self.attr_path + [str(item)]) # type: ignore | ||
elif item in self._attributes: # type: ignore | ||
if item in self.get_attributes(): | ||
return getattr(self, item) | ||
elif self.is_raw(): | ||
return Path(self.attr_path + [str(item)]) # type: ignore | ||
else: | ||
raise AttributeError("'{}' has no attribute '{}'".format(self.__class__.__name__, item)) | ||
|
||
def __setitem__(self, item, value): | ||
if not self._is_attribute_container(): | ||
raise TypeError("'{}' object does not support item assignment".format(self.__class__.__name__)) | ||
if self.is_raw(): | ||
self.attribute_values[item] = value | ||
elif item in self._attributes: # type: ignore | ||
if item in self.get_attributes(): | ||
setattr(self, item, value) | ||
elif self.is_raw(): | ||
self.attribute_values[item] = value | ||
else: | ||
raise AttributeError("'{}' has no attribute '{}'".format(self.__class__.__name__, item)) | ||
|
||
|
@@ -988,6 +988,69 @@ def as_dict(self): | |
return result | ||
|
||
|
||
class DynamicMapAttribute(MapAttribute): | ||
""" | ||
A map attribute that supports declaring attributes (like an AttributeContainer) but will also store | ||
any other values that are set on it (like a raw MapAttribute). | ||
|
||
>>> class MyDynamicMapAttribute(DynamicMapAttribute): | ||
>>> a_date_time = UTCDateTimeAttribute() # raw map attributes cannot serialize/deserialize datetime values | ||
>>> | ||
>>> dynamic_map = MyDynamicMapAttribute() | ||
>>> dynamic_map.a_date_time = datetime.utcnow() | ||
>>> dynamic_map.a_number = 5 | ||
>>> dynamic_map.serialize() # {'a_date_time': {'S': 'xxx'}, 'a_number': {'N': '5'}} | ||
""" | ||
|
||
def __setattr__(self, name, value): | ||
# Set attributes via the Attribute descriptor if it exists. | ||
if name in self.get_attributes(): | ||
object.__setattr__(self, name, value) | ||
else: | ||
super().__setattr__(name, value) | ||
|
||
def serialize(self, values, *, null_check: bool = True): | ||
if not isinstance(values, type(self)): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this blob transforms a raw dict into a map attr object right? is this not shared with raw map attrs? |
||
# Copy the values onto an instance of the class for serialization. | ||
instance = type(self)() | ||
instance.attribute_values = {} # clear any defaults | ||
instance._set_attributes(**values) | ||
values = instance | ||
# this serializes the class defined attributes. | ||
# we do this first because we have type checks that validate the data | ||
rval = AttributeContainer._container_serialize(values, null_check=null_check) | ||
# this serializes the dynamically defined attributes | ||
# we have no real type safety here so we have to dynamically construct the type to write to dynamo | ||
for attr_name in values: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this loop serializes the left over, undefined attrs |
||
if attr_name not in self.get_attributes(): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we dedup this with |
||
v = values[attr_name] | ||
attr_class = _get_class_for_serialize(v) | ||
attr_type = attr_class.attr_type | ||
attr_value = attr_class.serialize(v) | ||
if attr_value is None: | ||
# When attribute values serialize to "None" (e.g. empty sets) we store {"NULL": True} in DynamoDB. | ||
attr_type = NULL | ||
attr_value = True | ||
rval[attr_name] = {attr_type: attr_value} | ||
return rval | ||
|
||
def deserialize(self, values): | ||
# this deserializes the class defined attributes | ||
# we do this first so that the we populate the defined object attributes fields properly with type safety | ||
instance = self._instantiate(values) | ||
# this deserializes the dynamically defined attributes | ||
for attr_name, value in values.items(): | ||
if instance._dynamo_to_python_attr(attr_name) not in instance.get_attributes(): | ||
attr_type, attr_value = next(iter(value.items())) | ||
instance[attr_name] = DESERIALIZE_CLASS_MAP[attr_type].deserialize(attr_value) | ||
return instance | ||
|
||
@classmethod | ||
def is_raw(cls): | ||
# All subclasses of DynamicMapAttribute should be treated like "raw" map attributes. | ||
return True | ||
|
||
|
||
def _get_class_for_serialize(value): | ||
if value is None: | ||
return NullAttribute() | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,7 +12,7 @@ | |
import pytest | ||
|
||
from pynamodb.attributes import ( | ||
BinarySetAttribute, BinaryAttribute, NumberSetAttribute, NumberAttribute, | ||
BinarySetAttribute, BinaryAttribute, DynamicMapAttribute, NumberSetAttribute, NumberAttribute, | ||
UnicodeAttribute, UnicodeSetAttribute, UTCDateTimeAttribute, BooleanAttribute, MapAttribute, | ||
ListAttribute, JSONAttribute, TTLAttribute, VersionAttribute) | ||
from pynamodb.constants import ( | ||
|
@@ -882,6 +882,37 @@ class OuterMapAttribute(MapAttribute): | |
assert serialized == {'inner_map': {'M': {'foo': {'S': 'bar'}}}} | ||
|
||
|
||
class TestDynamicMapAttribute: | ||
|
||
class CreatedAtTestModel(Model): | ||
class CreatedAtMap(DynamicMapAttribute): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we also test with a subclass of |
||
created_at = UTCDateTimeAttribute() | ||
test_map = CreatedAtMap(default=dict) | ||
|
||
def test_serialize(self): | ||
test_model = TestDynamicMapAttribute.CreatedAtTestModel() | ||
test_model.test_map.created_at = datetime(2017, 1, 1, tzinfo=timezone.utc) | ||
test_model.test_map.foo = 'bar' | ||
test_model.test_map.empty = None | ||
assert test_model.serialize() == {'test_map': {'M': { | ||
'created_at': {'S': '2017-01-01T00:00:00.000000+0000'}, | ||
'foo': {'S': 'bar'}, | ||
'empty': {'NULL': True}, | ||
}}} | ||
|
||
def test_deserialize(self): | ||
serialized = {'test_map': {'M': { | ||
'created_at': {'S': '2017-01-01T00:00:00.000000+0000'}, | ||
'foo': {'S': 'bar'}, | ||
'empty': {'NULL': True}, | ||
}}} | ||
test_model = TestDynamicMapAttribute.CreatedAtTestModel() | ||
test_model.deserialize(serialized) | ||
assert test_model.test_map.created_at == datetime(2017, 1, 1, tzinfo=timezone.utc) | ||
assert test_model.test_map.foo == 'bar' | ||
assert test_model.test_map.empty is None | ||
|
||
|
||
class TestListAttribute: | ||
|
||
def test_untyped_list(self): | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This applies only to
MapAttribute
subclasses, right? Might want to reorder theDynamicMapAttribute
content underneath so as not to confuse