Skip to content

Commit 3487e7a

Browse files
committed
feat: Model Attribute Transformation
PynamoDB sometimes need to connect to a DynamoDB Table that is not managed by PynamoDB, and may have different naming conventions, such as Camel Case (which is used by JSON). PynamoDB can be configured to override individual attribute names, however in practice every attribute needs to be overridden to map between naming conventions. This commit introduces a Model Attribute Transformation, which effectively converts python snake case to another naming convention without overriding every attribute. This is an opt-in feature, and does not cause any breaking change to existing functionality.
1 parent c1d6222 commit 3487e7a

File tree

4 files changed

+184
-8
lines changed

4 files changed

+184
-8
lines changed

docs/tutorial.rst

+30
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,36 @@ Here is an example of customizing an attribute name:
201201
# This attribute will be called 'tn' in DynamoDB
202202
thread_name = UnicodeAttribute(null=True, attr_name='tn')
203203
204+
PynamoDB can also transform all the attribute names from Python's "Snake Case"
205+
(for example "forum_name") to another naming convention, such as Camel Case ("forumName"),
206+
or Pascal Case ("ForumName").
207+
Custom attribute names can still be applied to individual attributes, and take precedence
208+
over the attribute transform.
209+
The attribute transformation can be assigned to the model class as part of the definition.
210+
211+
PynamoDB comes with these built in attribute transformations:
212+
213+
* :py:class:`CamelCaseAttributeTransform <pynamodb.attributes.CamelCaseAttributeTransform>`
214+
* :py:class:`PascalCaseAttributeTransform <pynamodb.attributes.PascalCaseAttributeTransform>`
215+
216+
Here is example usage of both the attribute transformation, and custom attribute names:
217+
218+
.. code-block:: python
219+
220+
from pynamodb.models import Model
221+
from pynamodb.attributes import UnicodeAttribute, CamelCaseAttributeTransform
222+
223+
class Thread(Model, attribute_transform=CamelCaseAttributeTransform):
224+
class Meta:
225+
table_name = 'Thread'
226+
# This attribute will be called 'forumName' in DynamoDB
227+
forum_name = UnicodeAttribute(hash_key=True)
228+
229+
# This attribute will be called 'threadName' in DynamoDB
230+
thread_name = UnicodeAttribute(null=True)
231+
232+
# This attribute will be called 'author' in DynamoDB
233+
post_author = UnicodeAttribute(null=True, attr_name='author')
204234
205235
PynamoDB comes with several built in attribute types for convenience, which include the following:
206236

pynamodb/attributes.py

+47-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
PynamoDB attributes
33
"""
4+
import abc
45
import base64
56
import calendar
67
import collections.abc
@@ -288,22 +289,30 @@ def __new__(cls, name, bases, namespace, discriminator=None):
288289
# Defined so that the discriminator can be set in the class definition.
289290
return super().__new__(cls, name, bases, namespace)
290291

291-
def __init__(self, name, bases, namespace, discriminator=None):
292+
def __init__(self, name, bases, namespace, discriminator=None, attribute_transform: Optional["AttributeTransform"] = None):
292293
super().__init__(name, bases, namespace)
293-
AttributeContainerMeta._initialize_attributes(self, discriminator)
294+
AttributeContainerMeta._initialize_attributes(self, discriminator, attribute_transform)
294295

295296
@staticmethod
296-
def _initialize_attributes(cls, discriminator_value):
297+
def _initialize_attributes(cls, discriminator_value, attribute_transform: Optional[Type["AttributeTransform"]] = None):
297298
"""
298299
Initialize attributes on the class.
299300
"""
300301
cls._attributes = {}
301302
cls._dynamo_to_python_attrs = {}
302303

304+
if attribute_transform is None or issubclass(attribute_transform, AttributeTransform):
305+
cls._attribute_transform = attribute_transform
306+
else:
307+
raise ValueError(f"Attribute Transform {type(attribute_transform)} is not a subclass of AttributeTransform")
308+
303309
for name, attribute in getmembers(cls, lambda o: isinstance(o, Attribute)):
304310
cls._attributes[name] = attribute
305311
if attribute.attr_name != name:
306312
cls._dynamo_to_python_attrs[attribute.attr_name] = name
313+
elif cls._attribute_transform is not None:
314+
attribute.attr_name = cls._attribute_transform.transform(name)
315+
cls._dynamo_to_python_attrs[attribute.attr_name] = name
307316

308317
# Register the class with the discriminator if necessary.
309318
discriminators = [name for name, attr in cls._attributes.items() if isinstance(attr, DiscriminatorAttribute)]
@@ -601,6 +610,41 @@ def deserialize(self, value):
601610
return self._discriminator_map[value]
602611

603612

613+
class AttributeTransform(abc.ABC):
614+
"""Base case for converting python attributes in to various cases"""
615+
616+
@classmethod
617+
@abc.abstractmethod
618+
def transform(cls, python_attr: str) -> str:
619+
...
620+
621+
622+
class CamelCaseAttributeTransform(AttributeTransform):
623+
"""Convert python attributes to camelCase"""
624+
625+
@classmethod
626+
def transform(cls, python_attr: str) -> str:
627+
if isinstance(python_attr, str):
628+
parts = python_attr.split("_")
629+
return parts[0] + "".join([part.title() for part in parts[1:]])
630+
631+
else:
632+
raise ValueError("Provided value is not a string")
633+
634+
635+
class PascalCaseAttributeTransform(AttributeTransform):
636+
"""Convert python attributes to PascalCase"""
637+
638+
@classmethod
639+
def transform(cls, python_attr: str) -> str:
640+
if isinstance(python_attr, str):
641+
parts = python_attr.split("_")
642+
return "".join([part.title() for part in parts])
643+
644+
else:
645+
raise ValueError("Provided value is not a string")
646+
647+
604648
class BinaryAttribute(Attribute[bytes]):
605649
"""
606650
An attribute containing a binary data object (:code:`bytes`).

pynamodb/models.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from pynamodb.exceptions import DoesNotExist, TableDoesNotExist, TableError, InvalidStateError, PutError, \
3737
AttributeNullError
3838
from pynamodb.attributes import (
39-
AttributeContainer, AttributeContainerMeta, TTLAttribute, VersionAttribute
39+
AttributeContainer, AttributeContainerMeta, AttributeTransform, TTLAttribute, VersionAttribute
4040
)
4141
from pynamodb.connection.table import TableConnection
4242
from pynamodb.expressions.condition import Condition
@@ -200,12 +200,12 @@ class MetaModel(AttributeContainerMeta):
200200
"""
201201
Model meta class
202202
"""
203-
def __new__(cls, name, bases, namespace, discriminator=None):
203+
def __new__(cls, name, bases, namespace, discriminator=None, attribute_transform: Optional[AttributeTransform] = None):
204204
# Defined so that the discriminator can be set in the class definition.
205205
return super().__new__(cls, name, bases, namespace)
206206

207-
def __init__(self, name, bases, namespace, discriminator=None) -> None:
208-
super().__init__(name, bases, namespace, discriminator)
207+
def __init__(self, name, bases, namespace, discriminator=None, attribute_transform: Optional[AttributeTransform] = None) -> None:
208+
super().__init__(name, bases, namespace, discriminator, attribute_transform)
209209
MetaModel._initialize_indexes(self)
210210
cls = cast(Type['Model'], self)
211211
for attr_name, attribute in cls.get_attributes().items():

tests/test_model.py

+103-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
IncludeProjection, KeysOnlyProjection, Index
2727
)
2828
from pynamodb.attributes import (
29-
DiscriminatorAttribute, UnicodeAttribute, NumberAttribute, BinaryAttribute, UTCDateTimeAttribute,
29+
CamelCaseAttributeTransform, DiscriminatorAttribute, PascalCaseAttributeTransform, UnicodeAttribute, NumberAttribute, BinaryAttribute, UTCDateTimeAttribute,
3030
UnicodeSetAttribute, NumberSetAttribute, BinarySetAttribute, MapAttribute,
3131
BooleanAttribute, ListAttribute, TTLAttribute, VersionAttribute)
3232
from .data import (
@@ -211,6 +211,34 @@ class Meta:
211211
uid_index = CustomAttrIndex()
212212

213213

214+
class CamelCaseTransformAttrNameModel(Model, attribute_transform=CamelCaseAttributeTransform):
215+
"""
216+
Attribute names transformed in to Camel Case
217+
"""
218+
219+
class Meta:
220+
table_name = 'CustomAttrModel'
221+
222+
user_name = UnicodeAttribute(hash_key=True)
223+
user_id = UnicodeAttribute(range_key=True)
224+
enabled = UnicodeAttribute(null=True)
225+
overidden_attr = UnicodeAttribute(attr_name='foo_attr', null=True)
226+
227+
228+
class PascalCaseTransformAttrNameModel(Model, attribute_transform=PascalCaseAttributeTransform):
229+
"""
230+
Attribute names transformed in to Camel Case
231+
"""
232+
233+
class Meta:
234+
table_name = 'CustomAttrModel'
235+
236+
user_name = UnicodeAttribute(hash_key=True)
237+
user_id = UnicodeAttribute(range_key=True)
238+
enabled = UnicodeAttribute(null=True)
239+
overidden_attr = UnicodeAttribute(attr_name='foo_attr', null=True)
240+
241+
214242
class UserModel(Model):
215243
"""
216244
A testing model
@@ -687,13 +715,87 @@ def test_overridden_defaults(self):
687715
]
688716
)
689717

718+
schema = CamelCaseTransformAttrNameModel._get_schema()
719+
self.assertListEqual(
720+
schema['key_schema'],
721+
[
722+
{
723+
'KeyType': 'RANGE',
724+
'AttributeName': 'userId'
725+
},
726+
{
727+
'KeyType': 'HASH',
728+
'AttributeName': 'userName'
729+
},
730+
],
731+
)
732+
self.assertListEqual(
733+
schema['attribute_definitions'],
734+
[
735+
{
736+
'AttributeType': 'S',
737+
'AttributeName': 'userId'
738+
},
739+
{
740+
'AttributeType': 'S',
741+
'AttributeName': 'userName'
742+
},
743+
]
744+
)
745+
746+
schema = PascalCaseTransformAttrNameModel._get_schema()
747+
self.assertListEqual(
748+
schema['key_schema'],
749+
[
750+
{
751+
'KeyType': 'RANGE',
752+
'AttributeName': 'UserId'
753+
},
754+
{
755+
'KeyType': 'HASH',
756+
'AttributeName': 'UserName'
757+
},
758+
],
759+
)
760+
self.assertListEqual(
761+
schema['attribute_definitions'],
762+
[
763+
{
764+
'AttributeType': 'S',
765+
'AttributeName': 'UserId'
766+
},
767+
{
768+
'AttributeType': 'S',
769+
'AttributeName': 'UserName'
770+
},
771+
]
772+
)
773+
690774
def test_overridden_attr_name(self):
691775
user = UserModel(custom_user_name="bob")
692776
self.assertEqual(user.custom_user_name, "bob")
693777
self.assertRaises(AttributeError, getattr, user, "user_name")
694778

695779
self.assertRaises(ValueError, UserModel, user_name="bob")
696780

781+
def test_transformed_attr_name(self):
782+
"""
783+
Test transformed attributes names
784+
"""
785+
item = CamelCaseTransformAttrNameModel('foo', 'bar', overidden_attr='test', enabled="test")
786+
self.assertEqual(item.overidden_attr, 'test')
787+
attrs = item.get_attributes()
788+
self.assertEqual(attrs["user_name"].attr_name, "userName")
789+
self.assertEqual(attrs["user_id"].attr_name, "userId")
790+
self.assertEqual(attrs["enabled"].attr_name, "enabled")
791+
792+
item = PascalCaseTransformAttrNameModel('foo', 'bar', overidden_attr='test', enabled="test")
793+
self.assertEqual(item.overidden_attr, 'test')
794+
attrs = item.get_attributes()
795+
self.assertEqual(attrs["user_name"].attr_name, "UserName")
796+
self.assertEqual(attrs["user_id"].attr_name, "UserId")
797+
self.assertEqual(attrs["enabled"].attr_name, "Enabled")
798+
697799
def test_refresh(self):
698800
"""
699801
Model.refresh

0 commit comments

Comments
 (0)