diff --git a/CHANGES b/CHANGES index dc7aa7a0..08820168 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,33 @@ Changelog ========= +Version 2.3.0 +------------- + +In development + +- Multiple bugs with ``__tablename__`` generation are fixed. Names will be + generated for models that define a primary key, but not for single-table + inheritance subclasses. Names will not override a ``declared_attr``. + ``PrimaryKeyConstraint`` is detected. (`#541`_) +- Passing an existing ``declarative_base()`` as ``model_class`` to + ``SQLAlchemy.__init__`` will use this as the base class instead of creating + one. This allows customizing the metaclass used to construct the base. + (`#546`_) +- The undocumented ``DeclarativeMeta`` internals that the extension uses for + binds and table name generation have been refactored to work as mixins. + Documentation is added about how to create a custom metaclass that does not + do table name generation. (`#546`_) +- Model and metaclass code has been moved to a new ``models`` module. + ``_BoundDeclarativeMeta`` is renamed to ``DefaultMeta``; the old name will be + removed in 3.0. (`#546`_) +- Models have a default ``repr`` that shows the model name and primary key. + (`#530`_) + +.. _#530: https://github.com/mitsuhiko/flask-sqlalchemy/pull/530 +.. _#541: https://github.com/mitsuhiko/flask-sqlalchemy/pull/541 +.. _#546: https://github.com/mitsuhiko/flask-sqlalchemy/pull/546 + Version 2.2 ----------- diff --git a/docs/api.rst b/docs/api.rst index 4a753631..dea92574 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,9 +3,6 @@ API .. module:: flask_sqlalchemy -This part of the documentation documents all the public classes and -functions in Flask-SQLAlchemy. - Configuration ````````````` @@ -16,33 +13,33 @@ Models `````` .. autoclass:: Model - :members: + :members: - .. attribute:: __bind_key__ + .. attribute:: __bind_key__ - Optionally declares the bind to use. `None` refers to the default - bind. For more information see :ref:`binds`. + Optionally declares the bind to use. ``None`` refers to the default + bind. For more information see :ref:`binds`. - .. attribute:: __tablename__ + .. attribute:: __tablename__ - The name of the table in the database. This is required by SQLAlchemy; - however, Flask-SQLAlchemy will set it automatically if a model has a - primary key defined. If the ``__table__`` or ``__tablename__`` is set - explicitly, that will be used instead. + The name of the table in the database. This is required by SQLAlchemy; + however, Flask-SQLAlchemy will set it automatically if a model has a + primary key defined. If the ``__table__`` or ``__tablename__`` is set + explicitly, that will be used instead. .. autoclass:: BaseQuery - :members: + :members: Sessions ```````` .. autoclass:: SignallingSession - :members: + :members: Utilities ````````` .. autoclass:: Pagination - :members: + :members: .. autofunction:: get_debug_queries diff --git a/docs/customizing.rst b/docs/customizing.rst index 836279d1..ad94f36a 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -6,75 +6,81 @@ Customizing =========== Flask-SQLAlchemy defines sensible defaults. However, sometimes customization is -needed. Two major pieces to customize are the Model base class and the default -Query class. +needed. There are various ways to customize how the models are defined and +interacted with. -Both of these customizations are applied at the creation of the :class:`SQLAlchemy` +These customizations are applied at the creation of the :class:`SQLAlchemy` object and extend to all models derived from its ``Model`` class. + Model Class ----------- -Flask-SQLAlchemy allows defining a custom declarative base, just like SQLAlchemy, -that all model classes should extend from. For example, if all models should have -a custom ``__repr__`` method:: +SQLAlchemy models all inherit from a declarative base class. This is exposed +as ``db.Model`` in Flask-SQLAlchemy, which all models extend. This can be +customized by subclassing the default and passing the custom class to +``model_class``. - from flask_sqlalchemy import Model # this is the default declarative base - from flask_sqlalchemy import SQLAlchemy +The following example gives every model an integer primary key, or a foreign +key for joined-table inheritance. - class ReprBase(Model): - def __repr__(self): - return "<{0} id: {1}>".format(self.__class__.__name__, self.id) +.. note:: - db = SQLAlchemy(model_class=ReprBase) + Integer primary keys for everything is not necessarily the best database + design (that's up to your project's requirements), this is only an example. - class MyModel(db.Model): - ... +:: -.. note:: + from flask_sqlalchemy import Model, SQLAlchemy + import sqlalchemy as sa + from sqlalchemy.ext.declarative import declared_attr, has_inherited_table - While not strictly necessary to inherit from :class:`flask_sqlalchemy.Model` - it is encouraged as future changes may cause incompatibility. + class IdModel(Model): + @declared_attr + def id(cls): + for base in cls.__mro__[1:-1]: + if getattr(base, '__table__', None) is not None: + type = sa.ForeignKey(base.id) + break + else: + type = sa.Integer -.. note:: + return sa.Column(type, primary_key=True) - If behavior is needed in only some models, not all, a better strategy - is to use a Mixin, as exampled below. + db = SQLAlchemy(model_class=IdModel) -While this particular example is more useful for debugging, it is possible to -provide many augmentations to models that would otherwise be achieved with -mixins instead. The above example is equivalent to the following:: + class User(db.Model): + name = db.Column(db.String) - class ReprBase(object): - def __repr__(self): - return "<{0} id: {1}>".format(self.__class__.__name__, self.id) + class Employee(User): + title = db.Column(db.String) - db = SQLAlchemy() - class MyModel(db.Model, ReprBase): - ... +Model Mixins +------------ -It also possible to provide default columns and properties to all models as well:: +If behavior is only needed on some models rather than all models, use mixin +classes to customize only those models. For example, if some models should +track when they are created or updated:: - from flask_sqlalchemy import Model, SQLAlchemy - from sqlalchemy import Column, DateTime from datetime import datetime - class TimestampedModel(Model): - created_at = Column(DateTime, default=datetime.utcnow) + class TimestampMixin(object): + created = db.Column( + db.DateTime, nullable=False, default=datetime.utcnow) + updated = db.Column(db.DateTime, onupdate=datetime.utcnow) - db = SQLAlchemy(model_class=TimestampedModel) + class Author(db.Model): + ... - class MyModel(db.Model): + class Post(TimestampMixin, db.Model): ... -All model classes extending from ``db.Model`` will now inherit a -``created_at`` column. Query Class ----------- -It is also possible to customize what is availble for use on the +It is also possible to customize what is available for use on the special ``query`` property of models. For example, providing a ``get_or`` method:: @@ -86,19 +92,15 @@ special ``query`` property of models. For example, providing a db = SQLAlchemy(query_class=GetOrQuery) + # get a user by id, or return an anonymous user instance + user = User.query.get_or(user_id, anonymous_user) + And now all queries executed from the special ``query`` property on Flask-SQLAlchemy models can use the ``get_or`` method as part of their queries. All relationships defined with -``db.relationship`` (but not :func:`sqlalchemy.relationship`) +``db.relationship`` (but not :func:`sqlalchemy.orm.relationship`) will also be provided with this functionality. -.. warning:: - - Unlike a custom ``Model`` base class, it is required - to either inherit from either :class:`flask_sqlalchemy.BaseQuery` - or :func:`sqlalchemy.orm.Query` in order to define a custom - query class. - It also possible to define a custom query class for individual relationships as well, by providing the ``query_class`` keyword in the definition. This works with both ``db.relationship`` @@ -109,9 +111,8 @@ and ``sqlalchemy.relationship``:: .. note:: - If a query class is defined on a relationship, it will take - precedence over the query class attached to its corresponding - model. + If a query class is defined on a relationship, it will take precedence over + the query class attached to its corresponding model. It is also possible to define a specific query class for individual models by overriding the ``query_class`` class attribute on the model:: @@ -121,3 +122,69 @@ by overriding the ``query_class`` class attribute on the model:: In this case, the ``get_or`` method will be only availble on queries orginating from ``MyModel.query``. + + +Model Metaclass +--------------- + +.. warning:: + + Metaclasses are an advanced topic, and you probably don't need to customize + them to achieve what you want. It is mainly documented here to show how to + disable table name generation. + +The model metaclass is responsible for setting up the SQLAlchemy internals when +defining model subclasses. Flask-SQLAlchemy adds some extra behaviors through +mixins; its default metaclass, :class:`~model.DefaultMeta`, inherits them all. + +* :class:`~model.BindMetaMixin`: ``__bind_key__`` is extracted from the class + and applied to the table. See :ref:`binds`. +* :class:`~model.NameMetaMixin`: If the model does not specify a + ``__tablename__`` but does specify a primary key, a name is automatically + generated. + +You can add your own behaviors by defining your own metaclass and creating the +declarative base yourself. Be sure to still inherit from the mixins you want +(or just inherit from the default metaclass). + +Passing a declarative base class instead of a simple model base class, as shown +above, to ``base_class`` will cause Flask-SQLAlchemy to use this base instead +of constructing one with the default metaclass. :: + + from flask_sqlalchemy import SQLAlchemy + from flask_sqlalchemy.model import DefaultMeta, Model + + class CustomMeta(DefaultMeta): + def __init__(cls, name, bases, d): + # custom class setup could go here + + # be sure to call super + super(CustomMeta, cls).__init__(name, bases, d) + + # custom class-only methods could go here + + db = SQLAlchemy(model_class=declarative_base( + cls=Model, metaclass=CustomMeta, name='Model')) + +You can also pass whatever other arguments you want to +:func:`~sqlalchemy.ext.declarative.declarative_base` to customize the base +class as needed. + +Disabling Table Name Generation +``````````````````````````````` + +Some projects prefer to set each model's ``__tablename__`` manually rather than +relying on Flask-SQLAlchemy's detection and generation. The table name +generation can be disabled by defining a custom metaclass. :: + + from flask_sqlalchemy.model import BindMetaMixin, Model + from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base + + class NoNameMeta(BindMetaMixin, DeclarativeMeta): + pass + + db = SQLAlchemy(model_class=declarative_base( + cls=Model, metaclass=NoNameMeta, name='Model')) + +This creates a base that still supports the ``__bind_key__`` feature but does +not generate table names. diff --git a/flask_sqlalchemy/__init__.py b/flask_sqlalchemy/__init__.py index 99f8d1ff..aaee5244 100644 --- a/flask_sqlalchemy/__init__.py +++ b/flask_sqlalchemy/__init__.py @@ -12,7 +12,6 @@ import functools import os -import re import sys import time import warnings @@ -25,12 +24,13 @@ from flask.signals import Namespace from sqlalchemy import event, inspect, orm from sqlalchemy.engine.url import make_url -from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base, \ - declared_attr +from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base from sqlalchemy.orm.exc import UnmappedClassError from sqlalchemy.orm.session import Session as SessionBase +from flask_sqlalchemy.model import Model from ._compat import itervalues, string_types, to_str, xrange +from .model import DefaultMeta __version__ = '2.3.0' @@ -40,8 +40,6 @@ else: _timer = time.time -_camelcase_re = re.compile(r'([A-Z]+)(?=[a-z0-9])') - _signals = Namespace() models_committed = _signals.signal('models-committed') before_models_committed = _signals.signal('before-models-committed') @@ -550,87 +548,6 @@ def get_engine(self): return rv -def _should_set_tablename(cls): - """Determine whether ``__tablename__`` should be automatically generated - for a model. - - * If no class in the MRO sets a name, one should be generated. - * If a declared attr is found, it should be used instead. - * If a name is found, it should be used if the class is a mixin, otherwise - one should be generated. - * Abstract models should not have one generated. - - Later, :meth:`._BoundDeclarativeMeta.__table_cls__` will determine if the - model looks like single or joined-table inheritance. If no primary key is - found, the name will be unset. - """ - if ( - cls.__dict__.get('__abstract__', False) - or not any(isinstance(b, DeclarativeMeta) for b in cls.__mro__[1:]) - ): - return False - - for base in cls.__mro__: - if '__tablename__' not in base.__dict__: - continue - - if isinstance(base.__dict__['__tablename__'], declared_attr): - return False - - return not ( - base is cls - or base.__dict__.get('__abstract__', False) - or not isinstance(base, DeclarativeMeta) - ) - - return True - - -def camel_to_snake_case(name): - def _join(match): - word = match.group() - - if len(word) > 1: - return ('_%s_%s' % (word[:-1], word[-1])).lower() - - return '_' + word.lower() - - return _camelcase_re.sub(_join, name).lstrip('_') - - -class _BoundDeclarativeMeta(DeclarativeMeta): - def __init__(cls, name, bases, d): - if _should_set_tablename(cls): - cls.__tablename__ = camel_to_snake_case(cls.__name__) - - bind_key = ( - d.pop('__bind_key__', None) - or getattr(cls, '__bind_key__', None) - ) - - super(_BoundDeclarativeMeta, cls).__init__(name, bases, d) - - if bind_key is not None and hasattr(cls, '__table__'): - cls.__table__.info['bind_key'] = bind_key - - def __table_cls__(cls, *args, **kwargs): - """This is called by SQLAlchemy during mapper setup. It determines the - final table object that the model will use. - - If no primary key is found, that indicates single-table inheritance, - so no table will be created and ``__tablename__`` will be unset. - """ - for arg in args: - if ( - (isinstance(arg, sqlalchemy.Column) and arg.primary_key) - or isinstance(arg, sqlalchemy.PrimaryKeyConstraint) - ): - return sqlalchemy.Table(*args, **kwargs) - - if '__tablename__' in cls.__dict__: - del cls.__tablename__ - - def get_state(app): """Gets the state for the application""" assert 'sqlalchemy' in app.extensions, \ @@ -647,26 +564,6 @@ def __init__(self, db): self.connectors = {} -class Model(object): - """Base class for SQLAlchemy declarative base model. - - To define models, subclass :attr:`db.Model `, not this class. - To customize ``db.Model``, subclass this and pass it as ``model_class`` to :func:`SQLAlchemy`. - """ - - #: Query class used by :attr:`query`. - #: Defaults to :class:`SQLAlchemy.Query`, which defaults to :class:`BaseQuery`. - query_class = None - - #: Convenience property to query the database for instances of this model using the current session. - #: Equivalent to ``db.session.query(Model)`` unless :attr:`query_class` has been changed. - query = None - - def __repr__(self): - pk = ', '.join(to_str(value) for value in inspect(self).identity) - return '<{0} {1}>'.format(type(self).__name__, pk) - - class SQLAlchemy(object): """This class is used to control the SQLAlchemy integration to one or more Flask applications. Depending on how you initialize the @@ -815,16 +712,37 @@ class or a :class:`~sqlalchemy.orm.session.sessionmaker`. return orm.sessionmaker(class_=SignallingSession, db=self, **options) def make_declarative_base(self, model, metadata=None): - """Creates the declarative base.""" - base = declarative_base(cls=model, name='Model', - metadata=metadata, - metaclass=_BoundDeclarativeMeta) + """Creates the declarative base that all models will inherit from. + + :param model: base model class (or a tuple of base classes) to pass + to :func:`~sqlalchemy.ext.declarative.declarative_base`. Or a class + returned from ``declarative_base``, in which case a new base class + is not created. + :param: metadata: :class:`~sqlalchemy.MetaData` instance to use, or + none to use SQLAlchemy's default. + + .. versionchanged 2.3.0:: + ``model`` can be an existing declarative base in order to support + complex customization such as changing the metaclass. + """ + if not isinstance(model, DeclarativeMeta): + model = declarative_base( + cls=model, + name='Model', + metadata=metadata, + metaclass=DefaultMeta + ) - if not getattr(base, 'query_class', None): - base.query_class = self.Query + # if user passed in a declarative base and a metaclass for some reason, + # make sure the base uses the metaclass + if metadata is not None and model.metadata is not metadata: + model.metadata = metadata - base.query = _QueryProperty(self) - return base + if not getattr(model, 'query_class', None): + model.query_class = self.Query + + model.query = _QueryProperty(self) + return model def init_app(self, app): """This callback can be used to initialize an application for the @@ -1050,6 +968,15 @@ def __repr__(self): ) +class _BoundDeclarativeMeta(DefaultMeta): + def __init__(cls, name, bases, d): + warnings.warn(FSADeprecationWarning( + '"_BoundDeclarativeMeta" has been renamed to "DefaultMeta". The' + ' old name will be removed in 3.0.' + ), stacklevel=3) + super(_BoundDeclarativeMeta, cls).__init__(name, bases, d) + + class FSADeprecationWarning(DeprecationWarning): pass diff --git a/flask_sqlalchemy/model.py b/flask_sqlalchemy/model.py new file mode 100644 index 00000000..5e5ae797 --- /dev/null +++ b/flask_sqlalchemy/model.py @@ -0,0 +1,122 @@ +import re + +import sqlalchemy as sa +from sqlalchemy import inspect +from sqlalchemy.ext.declarative import DeclarativeMeta, declared_attr + +from ._compat import to_str + + +def should_set_tablename(cls): + """Determine whether ``__tablename__`` should be automatically generated + for a model. + + * If no class in the MRO sets a name, one should be generated. + * If a declared attr is found, it should be used instead. + * If a name is found, it should be used if the class is a mixin, otherwise + one should be generated. + * Abstract models should not have one generated. + + Later, :meth:`._BoundDeclarativeMeta.__table_cls__` will determine if the + model looks like single or joined-table inheritance. If no primary key is + found, the name will be unset. + """ + if ( + cls.__dict__.get('__abstract__', False) + or not any(isinstance(b, DeclarativeMeta) for b in cls.__mro__[1:]) + ): + return False + + for base in cls.__mro__: + if '__tablename__' not in base.__dict__: + continue + + if isinstance(base.__dict__['__tablename__'], declared_attr): + return False + + return not ( + base is cls + or base.__dict__.get('__abstract__', False) + or not isinstance(base, DeclarativeMeta) + ) + + return True + + +camelcase_re = re.compile(r'([A-Z]+)(?=[a-z0-9])') + + +def camel_to_snake_case(name): + def _join(match): + word = match.group() + + if len(word) > 1: + return ('_%s_%s' % (word[:-1], word[-1])).lower() + + return '_' + word.lower() + + return camelcase_re.sub(_join, name).lstrip('_') + + +class NameMetaMixin(object): + def __init__(cls, name, bases, d): + if should_set_tablename(cls): + cls.__tablename__ = camel_to_snake_case(cls.__name__) + + super(NameMetaMixin, cls).__init__(name, bases, d) + + def __table_cls__(cls, *args, **kwargs): + """This is called by SQLAlchemy during mapper setup. It determines the + final table object that the model will use. + + If no primary key is found, that indicates single-table inheritance, + so no table will be created and ``__tablename__`` will be unset. + """ + for arg in args: + if ( + (isinstance(arg, sa.Column) and arg.primary_key) + or isinstance(arg, sa.PrimaryKeyConstraint) + ): + return sa.Table(*args, **kwargs) + + if '__tablename__' in cls.__dict__: + del cls.__tablename__ + + +class BindMetaMixin(object): + def __init__(cls, name, bases, d): + bind_key = ( + d.pop('__bind_key__', None) + or getattr(cls, '__bind_key__', None) + ) + + super(BindMetaMixin, cls).__init__(name, bases, d) + + if bind_key is not None and hasattr(cls, '__table__'): + cls.__table__.info['bind_key'] = bind_key + + +class DefaultMeta(NameMetaMixin, BindMetaMixin, DeclarativeMeta): + pass + + +class Model(object): + """Base class for SQLAlchemy declarative base model. + + To define models, subclass :attr:`db.Model `, not this + class. To customize ``db.Model``, subclass this and pass it as + ``model_class`` to :class:`SQLAlchemy`. + """ + + #: Query class used by :attr:`query`. Defaults to + # :class:`SQLAlchemy.Query`, which defaults to :class:`BaseQuery`. + query_class = None + + #: Convenience property to query the database for instances of this model + # using the current session. Equivalent to ``db.session.query(Model)`` + # unless :attr:`query_class` has been changed. + query = None + + def __repr__(self): + pk = ', '.join(to_str(value) for value in inspect(self).identity) + return '<{0} {1}>'.format(type(self).__name__, pk) diff --git a/tests/test_model_class.py b/tests/test_model_class.py index e2e82ca8..c64d8ad3 100644 --- a/tests/test_model_class.py +++ b/tests/test_model_class.py @@ -1,13 +1,18 @@ # coding=utf8 +import pytest +from sqlalchemy.exc import InvalidRequestError +from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base + import flask_sqlalchemy as fsa from flask_sqlalchemy._compat import to_str +from flask_sqlalchemy.model import BindMetaMixin -def test_custom_query_class(app): +def test_custom_model_class(): class CustomModelClass(fsa.Model): pass - db = fsa.SQLAlchemy(app, model_class=CustomModelClass) + db = fsa.SQLAlchemy(model_class=CustomModelClass) class SomeModel(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -15,6 +20,18 @@ class SomeModel(db.Model): assert isinstance(SomeModel(), CustomModelClass) +def test_no_table_name(): + class NoNameMeta(BindMetaMixin, DeclarativeMeta): + pass + + db = fsa.SQLAlchemy(model_class=declarative_base( + cls=fsa.Model, metaclass=NoNameMeta, name='Model')) + + with pytest.raises(InvalidRequestError): + class User(db.Model): + pass + + def test_repr(db): class User(db.Model): name = db.Column(db.String, primary_key=True) @@ -42,3 +59,11 @@ class Report(db.Model): db.session.flush() assert repr(r) == '' assert repr(u) == str(u) + + +def test_deprecated_meta(): + class OldMeta(fsa._BoundDeclarativeMeta): + pass + + with pytest.warns(fsa.FSADeprecationWarning): + declarative_base(cls=fsa.Model, metaclass=OldMeta)