Skip to content

Commit 80aef76

Browse files
authored
Enable polymorphism by serializing all attributes in subclasses. (#842)
1 parent 61271e8 commit 80aef76

File tree

3 files changed

+89
-61
lines changed

3 files changed

+89
-61
lines changed

pynamodb/attributes.py

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,24 @@ def _set_attributes(self, **attributes: Attribute) -> None:
313313
raise ValueError("Attribute {} specified does not exist".format(attr_name))
314314
setattr(self, attr_name, attr_value)
315315

316+
def _serialize(self, null_check=True) -> Dict[str, Dict[str, Any]]:
317+
"""
318+
Serialize attribute values for DynamoDB
319+
"""
320+
attribute_values: Dict[str, Dict[str, Any]] = {}
321+
for name, attr in self.get_attributes().items():
322+
value = getattr(self, name)
323+
if isinstance(value, MapAttribute) and not value.validate():
324+
raise ValueError("Attribute '{}' is not correctly typed".format(name))
325+
326+
attr_value = attr.serialize(value) if value is not None else None
327+
if null_check and attr_value is None and not attr.null:
328+
raise ValueError("Attribute '{}' cannot be None".format(name))
329+
330+
if attr_value is not None:
331+
attribute_values[attr.attr_name] = {attr.attr_type: attr_value}
332+
return attribute_values
333+
316334
def _deserialize(self, attribute_values: Dict[str, Dict[str, Any]]) -> None:
317335
"""
318336
Sets attributes sent back from DynamoDB on this object
@@ -807,25 +825,34 @@ def validate(self):
807825
return all(self.is_correctly_typed(k, v) for k, v in self.get_attributes().items())
808826

809827
def serialize(self, values):
810-
rval = {}
811-
for k in values:
812-
v = values[k]
813-
if self._should_skip(v):
814-
continue
815-
attr_class = self._get_serialize_class(k, v)
816-
if attr_class is None:
817-
continue
818-
819-
# If this is a subclassed MapAttribute, there may be an alternate attr name
820-
attr_name = attr_class.attr_name if not self.is_raw() else k
828+
if not self.is_raw():
829+
# This is a subclassed MapAttribute that acts as an AttributeContainer.
830+
# Serialize the values based on the attributes in the class.
821831

822-
serialized = attr_class.serialize(v)
823-
if self._should_skip(serialized):
824-
# Check after we serialize in case the serialized value is null
825-
continue
832+
if not isinstance(values, type(self)):
833+
# Copy the values onto an instance of the class for serialization.
834+
instance = type(self)()
835+
instance.attribute_values = {} # clear any defaults
836+
for name in values:
837+
if name in self.get_attributes():
838+
setattr(instance, name, values[name])
839+
values = instance
826840

827-
rval[attr_name] = {attr_class.attr_type: serialized}
841+
return values._serialize()
828842

843+
# Continue to serialize NULL values in "raw" map attributes for backwards compatibility.
844+
# This special case behavior for "raw" attributes should be removed in the future.
845+
rval = {}
846+
for attr_name in values:
847+
v = values[attr_name]
848+
attr_class = _get_class_for_serialize(v)
849+
attr_type = attr_class.attr_type
850+
attr_value = attr_class.serialize(v)
851+
if attr_value is None:
852+
# When attribute values serialize to "None" (e.g. empty sets) we store {"NULL": True} in DynamoDB.
853+
attr_type = NULL
854+
attr_value = True
855+
rval[attr_name] = {attr_type: attr_value}
829856
return rval
830857

831858
def deserialize(self, values):
@@ -853,17 +880,6 @@ def as_dict(self):
853880
result[key] = value.as_dict() if isinstance(value, MapAttribute) else value
854881
return result
855882

856-
def _should_skip(self, value):
857-
# Continue to serialize NULL values in "raw" map attributes for backwards compatibility.
858-
# This special case behavior for "raw" attributes should be removed in the future.
859-
return not self.is_raw() and value is None
860-
861-
@classmethod
862-
def _get_serialize_class(cls, key, value):
863-
if not cls.is_raw():
864-
return cls.get_attributes().get(key)
865-
return _get_class_for_serialize(value)
866-
867883

868884
def _get_class_for_serialize(value):
869885
if value is None:

pynamodb/models.py

Lines changed: 18 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -995,15 +995,15 @@ def _handle_version_attribute(self, serialized_attributes, actions=None):
995995
actions.append(version_attribute.add(1))
996996
elif snake_to_camel_case(ATTRIBUTES) in serialized_attributes:
997997
serialized_attributes[snake_to_camel_case(ATTRIBUTES)][version_attribute.attr_name] = self._serialize_value(
998-
version_attribute, version_attribute_value + 1, null_check=True
998+
version_attribute, version_attribute_value + 1
999999
)
10001000
else:
10011001
version_condition = version_attribute.does_not_exist()
10021002
if actions:
10031003
actions.append(version_attribute.set(1))
10041004
elif snake_to_camel_case(ATTRIBUTES) in serialized_attributes:
10051005
serialized_attributes[snake_to_camel_case(ATTRIBUTES)][version_attribute.attr_name] = self._serialize_value(
1006-
version_attribute, 1, null_check=True
1006+
version_attribute, 1
10071007
)
10081008

10091009
return version_condition
@@ -1107,53 +1107,40 @@ def _get_connection(cls) -> TableConnection:
11071107
aws_session_token=cls.Meta.aws_session_token)
11081108
return cls._connection
11091109

1110-
def _serialize(self, attr_map=False, null_check=True) -> Dict[str, Any]:
1110+
def _serialize(self, null_check=True, attr_map=False) -> Dict[str, Dict[str, Any]]:
11111111
"""
11121112
Serializes all model attributes for use with DynamoDB
11131113
1114-
:param attr_map: If True, then attributes are returned
11151114
:param null_check: If True, then attributes are checked for null
1115+
:param attr_map: If True, then attributes are returned
11161116
"""
11171117
attributes = snake_to_camel_case(ATTRIBUTES)
1118-
attrs: Dict[str, Dict] = {attributes: {}}
1119-
for name, attr in self.get_attributes().items():
1120-
value = getattr(self, name)
1121-
if isinstance(value, MapAttribute):
1122-
if not value.validate():
1123-
raise ValueError("Attribute '{}' is not correctly typed".format(attr.attr_name))
1124-
1125-
serialized = self._serialize_value(attr, value, null_check)
1126-
if NULL in serialized:
1127-
continue
1128-
1129-
if attr_map:
1130-
attrs[attributes][attr.attr_name] = serialized
1131-
else:
1132-
if attr.is_hash_key:
1133-
attrs[HASH] = serialized[attr.attr_type]
1134-
elif attr.is_range_key:
1135-
attrs[RANGE] = serialized[attr.attr_type]
1136-
else:
1137-
attrs[attributes][attr.attr_name] = serialized
1138-
1118+
attrs: Dict[str, Dict] = {attributes: super()._serialize(null_check)}
1119+
if not attr_map:
1120+
hash_key_attribute = self._hash_key_attribute()
1121+
hash_key_attribute_value = attrs[attributes].pop(hash_key_attribute.attr_name, None)
1122+
if hash_key_attribute_value is not None:
1123+
attrs[HASH] = hash_key_attribute_value[hash_key_attribute.attr_type]
1124+
range_key_attribute = self._range_key_attribute()
1125+
if range_key_attribute:
1126+
range_key_attribute_value = attrs[attributes].pop(range_key_attribute.attr_name, None)
1127+
if range_key_attribute_value is not None:
1128+
attrs[RANGE] = range_key_attribute_value[range_key_attribute.attr_type]
11391129
return attrs
11401130

11411131
@classmethod
1142-
def _serialize_value(cls, attr, value, null_check=True):
1132+
def _serialize_value(cls, attr, value):
11431133
"""
11441134
Serializes a value for use with DynamoDB
11451135
11461136
:param attr: an instance of `Attribute` for serialization
11471137
:param value: a value to be serialized
11481138
:param null_check: If True, then attributes are checked for null
11491139
"""
1150-
if value is None:
1151-
serialized = None
1152-
else:
1153-
serialized = attr.serialize(value)
1140+
serialized = attr.serialize(value)
11541141

11551142
if serialized is None:
1156-
if not attr.null and null_check:
1143+
if not attr.null:
11571144
raise ValueError("Attribute '{}' cannot be None".format(attr.attr_name))
11581145
return {NULL: True}
11591146

tests/test_attributes.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -614,19 +614,21 @@ def test_null_attribute_raw_map(self):
614614

615615
def test_null_attribute_subclassed_map(self):
616616
null_attribute = {
617-
'map_field': None
617+
'map_field': {},
618+
'string_set_field': None
618619
}
619620
attr = DefaultsMap()
620621
serialized = attr.serialize(null_attribute)
621-
assert serialized == {}
622+
assert serialized == {'map_field': {'M': {}}}
622623

623624
def test_null_attribute_map_after_serialization(self):
624625
null_attribute = {
626+
'map_field': {},
625627
'string_set_field': {},
626628
}
627629
attr = DefaultsMap()
628630
serialized = attr.serialize(null_attribute)
629-
assert serialized == {}
631+
assert serialized == {'map_field': {'M': {}}}
630632

631633
def test_map_of_map(self):
632634
attribute = {
@@ -889,6 +891,29 @@ class MyModel(Model):
889891
assert mid_map_b_map_attr.attr_name == 'dyn_map_attr'
890892
assert mid_map_b_map_attr.attr_path == ['dyn_out_map', 'mid_map_b', 'dyn_in_map_b', 'dyn_map_attr']
891893

894+
def test_required_elements(self):
895+
class InnerMapAttribute(MapAttribute):
896+
foo = UnicodeAttribute()
897+
898+
class OuterMapAttribute(MapAttribute):
899+
inner_map = InnerMapAttribute()
900+
901+
outer_map_attribute = OuterMapAttribute()
902+
with pytest.raises(ValueError):
903+
outer_map_attribute.serialize(outer_map_attribute)
904+
905+
outer_map_attribute = OuterMapAttribute(inner_map={})
906+
with pytest.raises(ValueError):
907+
outer_map_attribute.serialize(outer_map_attribute)
908+
909+
outer_map_attribute = OuterMapAttribute(inner_map=MapAttribute())
910+
with pytest.raises(ValueError):
911+
outer_map_attribute.serialize(outer_map_attribute)
912+
913+
outer_map_attribute = OuterMapAttribute(inner_map={'foo': 'bar'})
914+
serialized = outer_map_attribute.serialize(outer_map_attribute)
915+
assert serialized == {'inner_map': {'M': {'foo': {'S': 'bar'}}}}
916+
892917

893918
class TestListAttribute:
894919

0 commit comments

Comments
 (0)