diff --git a/docs/models.rst b/docs/models.rst index ae7e0ed..b113163 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -8,3 +8,11 @@ Timestamp .. module:: sqlalchemy_utils.models .. autoclass:: Timestamp + + +generic_repr +------------ + +.. module:: sqlalchemy_utils.models + +.. autofunction:: generic_repr diff --git a/sqlalchemy_utils/__init__.py b/sqlalchemy_utils/__init__.py index 93d9e9c..6884488 100644 --- a/sqlalchemy_utils/__init__.py +++ b/sqlalchemy_utils/__init__.py @@ -53,7 +53,7 @@ from .listeners import ( # noqa force_auto_coercion, force_instant_defaults ) -from .models import Timestamp # noqa +from .models import generic_repr, Timestamp # noqa from .observer import observes # noqa from .primitives import Country, Currency, Ltree, WeekDay, WeekDays # noqa from .proxy_dict import proxy_dict, ProxyDict # noqa diff --git a/sqlalchemy_utils/models.py b/sqlalchemy_utils/models.py index 80a85ec..89bddc5 100644 --- a/sqlalchemy_utils/models.py +++ b/sqlalchemy_utils/models.py @@ -1,6 +1,7 @@ from datetime import datetime import sqlalchemy as sa +from sqlalchemy.util.langhelpers import symbol class Timestamp(object): @@ -31,3 +32,67 @@ def timestamp_before_update(mapper, connection, target): # When a model with a timestamp is updated; force update the updated # timestamp. target.updated = datetime.utcnow() + + +NO_VALUE = symbol('NO_VALUE') +NOT_LOADED_REPR = '' + + +def _generic_repr_method(self, fields): + state = sa.inspect(self) + field_reprs = [] + if not fields: + fields = state.mapper.columns.keys() + for key in fields: + value = state.attrs[key].loaded_value + if value == NO_VALUE: + value = NOT_LOADED_REPR + else: + value = repr(value) + field_reprs.append('='.join((key, value))) + + return '%s(%s)' % (self.__class__.__name__, ', '.join(field_reprs)) + + +def generic_repr(*fields): + """Adds generic ``__repr__()`` method to a decalrative SQLAlchemy model. + + In case if some fields are not loaded from a database, it doesn't + force their loading and instead repesents them as ````. + + In addition, user can provide field names as arguments to the decorator + to specify what fields should present in the string representation + and in what order. + + Example:: + + + import sqlalchemy as sa + from sqlalchemy_utils import generic_repr + + + @generic_repr + class MyModel(Base): + __tablename__ = 'mymodel' + id = sa.Column(sa.Integer, primary_key=True) + name = sa.Column(sa.String) + category = sa.Column(sa.String) + + session.add(MyModel(name='Foo', category='Bar')) + session.commit() + foo = session.query(MyModel).options(sa.orm.defer('category')).one(s) + + assert repr(foo) == 'MyModel(id=1, name='Foo', category=)' + """ + if len(fields) == 1 and callable(fields[0]): + target = fields[0] + target.__repr__ = lambda self: _generic_repr_method(self, fields=None) + return target + else: + def decorator(cls): + cls.__repr__ = lambda self: _generic_repr_method( + self, + fields=fields + ) + return cls + return decorator diff --git a/tests/test_models.py b/tests/test_models.py index d0790b5..d40c030 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,21 +1,20 @@ +import sys from datetime import datetime import pytest import sqlalchemy as sa -from sqlalchemy_utils import Timestamp - - -@pytest.fixture -def Article(Base): - class Article(Base, Timestamp): - __tablename__ = 'article' - id = sa.Column(sa.Integer, primary_key=True) - name = sa.Column(sa.Unicode(255), default=u'Some article') - return Article +from sqlalchemy_utils import generic_repr, Timestamp class TestTimestamp(object): + @pytest.fixture + def Article(self, Base): + class Article(Base, Timestamp): + __tablename__ = 'article' + id = sa.Column(sa.Integer, primary_key=True) + name = sa.Column(sa.Unicode(255), default=u'Some article') + return Article def test_created(self, session, Article): then = datetime.utcnow() @@ -38,3 +37,52 @@ class TestTimestamp(object): session.commit() assert article.updated >= then and article.updated <= datetime.utcnow() + + +class TestGenericRepr: + @pytest.fixture + def Article(self, Base): + class Article(Base): + __tablename__ = 'article' + id = sa.Column(sa.Integer, primary_key=True) + name = sa.Column(sa.Unicode(255), default=u'Some article') + return Article + + def test_repr(self, Article): + """Representation of a basic model.""" + Article = generic_repr(Article) + article = Article(id=1, name=u'Foo') + if sys.version_info[0] == 2: + expected_repr = u'Article(id=1, name=u\'Foo\')' + elif sys.version_info[0] == 3: + expected_repr = u'Article(id=1, name=\'Foo\')' + else: + raise AssertionError + actual_repr = repr(article) + + assert actual_repr == expected_repr + + def test_repr_partial(self, Article): + """Representation of a basic model with selected fields.""" + Article = generic_repr('id')(Article) + article = Article(id=1, name=u'Foo') + expected_repr = u'Article(id=1)' + actual_repr = repr(article) + + assert actual_repr == expected_repr + + def test_not_loaded(self, session, Article): + """:py:func:`~sqlalchemy_utils.models.generic_repr` doesn't force + execution of additional queries if some fields are not loaded and + instead represents them as "". + """ + Article = generic_repr(Article) + article = Article(name=u'Foo') + session.add(article) + session.commit() + + article = session.query(Article).options(sa.orm.defer('name')).one() + actual_repr = repr(article) + + expected_repr = u'Article(id={}, name=)'.format(article.id) + assert actual_repr == expected_repr