Skip to content

Commit f93c737

Browse files
authored
Merge pull request pallets-eco#541 from davidism/tablename
rewrite tablename generation again
2 parents f1e5852 + 7134764 commit f93c737

File tree

2 files changed

+135
-53
lines changed

2 files changed

+135
-53
lines changed

flask_sqlalchemy/__init__.py

Lines changed: 54 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from sqlalchemy.orm.exc import UnmappedClassError
3131
from sqlalchemy.orm.session import Session as SessionBase
3232

33-
from ._compat import iteritems, itervalues, string_types, xrange
33+
from ._compat import itervalues, string_types, xrange
3434

3535
__version__ = '2.2.1'
3636

@@ -551,31 +551,39 @@ def get_engine(self):
551551

552552

553553
def _should_set_tablename(cls):
554-
"""Traverse the model's MRO. If a primary key column is found before a
555-
table or tablename, then a new tablename should be generated.
556-
557-
This supports:
558-
559-
* Joined table inheritance without explicitly naming sub-models.
560-
* Single table inheritance.
561-
* Inheriting from mixins or abstract models.
562-
563-
:param cls: model to check
564-
:return: True if tablename should be set
554+
"""Determine whether ``__tablename__`` should be automatically generated
555+
for a model.
556+
557+
* If no class in the MRO sets a name, one should be generated.
558+
* If a declared attr is found, it should be used instead.
559+
* If a name is found, it should be used if the class is a mixin, otherwise
560+
one should be generated.
561+
* Abstract models should not have one generated.
562+
563+
Later, :meth:`._BoundDeclarativeMeta.__table_cls__` will determine if the
564+
model looks like single or joined-table inheritance. If no primary key is
565+
found, the name will be unset.
565566
"""
567+
if (
568+
cls.__dict__.get('__abstract__', False)
569+
or not any(isinstance(b, DeclarativeMeta) for b in cls.__mro__[1:])
570+
):
571+
return False
566572

567573
for base in cls.__mro__:
568-
d = base.__dict__
574+
if '__tablename__' not in base.__dict__:
575+
continue
569576

570-
if '__tablename__' in d or '__table__' in d:
577+
if isinstance(base.__dict__['__tablename__'], declared_attr):
571578
return False
572579

573-
for name, obj in iteritems(d):
574-
if isinstance(obj, declared_attr):
575-
obj = getattr(cls, name)
580+
return not (
581+
base is cls
582+
or base.__dict__.get('__abstract__', False)
583+
or not isinstance(base, DeclarativeMeta)
584+
)
576585

577-
if isinstance(obj, sqlalchemy.Column) and obj.primary_key:
578-
return True
586+
return True
579587

580588

581589
def camel_to_snake_case(name):
@@ -591,20 +599,36 @@ def _join(match):
591599

592600

593601
class _BoundDeclarativeMeta(DeclarativeMeta):
594-
def __new__(cls, name, bases, d):
595-
# if tablename is set explicitly, move it to the cache attribute so
596-
# that future subclasses still have auto behavior
597-
if '__tablename__' in d:
598-
d['_cached_tablename'] = d.pop('__tablename__')
602+
def __init__(cls, name, bases, d):
603+
if _should_set_tablename(cls):
604+
cls.__tablename__ = camel_to_snake_case(cls.__name__)
605+
606+
bind_key = (
607+
d.pop('__bind_key__', None)
608+
or getattr(cls, '__bind_key__', None)
609+
)
599610

600-
return DeclarativeMeta.__new__(cls, name, bases, d)
611+
super(_BoundDeclarativeMeta, cls).__init__(name, bases, d)
601612

602-
def __init__(self, name, bases, d):
603-
bind_key = d.pop('__bind_key__', None) or getattr(self, '__bind_key__', None)
604-
DeclarativeMeta.__init__(self, name, bases, d)
613+
if bind_key is not None and hasattr(cls, '__table__'):
614+
cls.__table__.info['bind_key'] = bind_key
605615

606-
if bind_key is not None and hasattr(self, '__table__'):
607-
self.__table__.info['bind_key'] = bind_key
616+
def __table_cls__(cls, *args, **kwargs):
617+
"""This is called by SQLAlchemy during mapper setup. It determines the
618+
final table object that the model will use.
619+
620+
If no primary key is found, that indicates single-table inheritance,
621+
so no table will be created and ``__tablename__`` will be unset.
622+
"""
623+
for arg in args:
624+
if (
625+
(isinstance(arg, sqlalchemy.Column) and arg.primary_key)
626+
or isinstance(arg, sqlalchemy.PrimaryKeyConstraint)
627+
):
628+
return sqlalchemy.Table(*args, **kwargs)
629+
630+
if '__tablename__' in cls.__dict__:
631+
del cls.__tablename__
608632

609633

610634
def get_state(app):
@@ -638,18 +662,6 @@ class Model(object):
638662
#: Equivalent to ``db.session.query(Model)`` unless :attr:`query_class` has been changed.
639663
query = None
640664

641-
_cached_tablename = None
642-
643-
@declared_attr
644-
def __tablename__(cls):
645-
if (
646-
'_cached_tablename' not in cls.__dict__ and
647-
_should_set_tablename(cls)
648-
):
649-
cls._cached_tablename = camel_to_snake_case(cls.__name__)
650-
651-
return cls._cached_tablename
652-
653665

654666
class SQLAlchemy(object):
655667
"""This class is used to control the SQLAlchemy integration to one

tests/test_table_name.py

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import inspect
2+
13
from sqlalchemy.ext.declarative import declared_attr
24

35

@@ -25,6 +27,7 @@ class Duck(db.Model):
2527
class Mallard(Duck):
2628
pass
2729

30+
assert '__tablename__' not in Mallard.__dict__
2831
assert Mallard.__tablename__ == 'duck'
2932

3033

@@ -39,8 +42,10 @@ class Donald(Duck):
3942
assert Donald.__tablename__ == 'donald'
4043

4144

42-
def test_mixin_name(db):
43-
"""Primary key provided by mixin should still allow model to set tablename."""
45+
def test_mixin_id(db):
46+
"""Primary key provided by mixin should still allow model to set
47+
tablename.
48+
"""
4449
class Base(object):
4550
id = db.Column(db.Integer, primary_key=True)
4651

@@ -51,28 +56,57 @@ class Duck(Base, db.Model):
5156
assert Duck.__tablename__ == 'duck'
5257

5358

59+
def test_mixin_attr(db):
60+
"""A declared attr tablename will be used down multiple levels of
61+
inheritance.
62+
"""
63+
class Mixin(object):
64+
@declared_attr
65+
def __tablename__(cls):
66+
return cls.__name__.upper()
67+
68+
class Bird(Mixin, db.Model):
69+
id = db.Column(db.Integer, primary_key=True)
70+
71+
class Duck(Bird):
72+
# object reference
73+
id = db.Column(db.ForeignKey(Bird.id), primary_key=True)
74+
75+
class Mallard(Duck):
76+
# string reference
77+
id = db.Column(db.ForeignKey('DUCK.id'), primary_key=True)
78+
79+
assert Bird.__tablename__ == 'BIRD'
80+
assert Duck.__tablename__ == 'DUCK'
81+
assert Mallard.__tablename__ == 'MALLARD'
82+
83+
5484
def test_abstract_name(db):
55-
"""Abstract model should not set a name. Subclass should set a name."""
85+
"""Abstract model should not set a name. Subclass should set a name."""
5686
class Base(db.Model):
5787
__abstract__ = True
5888
id = db.Column(db.Integer, primary_key=True)
5989

6090
class Duck(Base):
6191
pass
6292

63-
assert Base.__tablename__ == 'base'
93+
assert '__tablename__' not in Base.__dict__
6494
assert Duck.__tablename__ == 'duck'
6595

6696

6797
def test_complex_inheritance(db):
68-
"""Joined table inheritance, but the new primary key is provided by a mixin, not directly on the class."""
98+
"""Joined table inheritance, but the new primary key is provided by a
99+
mixin, not directly on the class.
100+
"""
69101
class Duck(db.Model):
70102
id = db.Column(db.Integer, primary_key=True)
71103

72104
class IdMixin(object):
73105
@declared_attr
74106
def id(cls):
75-
return db.Column(db.Integer, db.ForeignKey(Duck.id), primary_key=True)
107+
return db.Column(
108+
db.Integer, db.ForeignKey(Duck.id), primary_key=True
109+
)
76110

77111
class RubberDuck(IdMixin, Duck):
78112
pass
@@ -81,18 +115,55 @@ class RubberDuck(IdMixin, Duck):
81115

82116

83117
def test_manual_name(db):
118+
"""Setting a manual name prevents generation for the immediate model. A
119+
name is generated for joined but not single-table inheritance.
120+
"""
84121
class Duck(db.Model):
85122
__tablename__ = 'DUCK'
86123
id = db.Column(db.Integer, primary_key=True)
124+
type = db.Column(db.String)
125+
126+
__mapper_args__ = {
127+
'polymorphic_on': type
128+
}
87129

88130
class Daffy(Duck):
89131
id = db.Column(db.Integer, db.ForeignKey(Duck.id), primary_key=True)
90132

133+
__mapper_args__ = {
134+
'polymorphic_identity': 'Warner'
135+
}
136+
137+
class Donald(Duck):
138+
__mapper_args__ = {
139+
'polymorphic_identity': 'Disney'
140+
}
141+
91142
assert Duck.__tablename__ == 'DUCK'
92143
assert Daffy.__tablename__ == 'daffy'
144+
assert '__tablename__' not in Donald.__dict__
145+
assert Donald.__tablename__ == 'DUCK'
146+
# polymorphic condition for single-table query
147+
assert 'WHERE "DUCK".type' in str(Donald.query)
148+
149+
150+
def test_primary_constraint(db):
151+
"""Primary key will be picked up from table args."""
152+
class Duck(db.Model):
153+
id = db.Column(db.Integer)
154+
155+
__table_args__ = (
156+
db.PrimaryKeyConstraint(id),
157+
)
158+
159+
assert Duck.__table__ is not None
160+
assert Duck.__tablename__ == 'duck'
93161

94162

95163
def test_no_access_to_class_property(db):
164+
"""Ensure the implementation doesn't access class properties or declared
165+
attrs while inspecting the unmapped model.
166+
"""
96167
class class_property(object):
97168
def __init__(self, f):
98169
self.f = f
@@ -106,14 +177,13 @@ class Duck(db.Model):
106177
class ns(object):
107178
accessed = False
108179

109-
# Since there's no id provided by the following model,
110-
# _should_set_tablename will scan all attributes. If it's working
111-
# properly, it won't access the class property, but will access the
112-
# declared_attr.
113-
114180
class Witch(Duck):
115181
@declared_attr
116182
def is_duck(self):
183+
# declared attrs will be accessed during mapper configuration,
184+
# but make sure they're not accessed before that
185+
info = inspect.getouterframes(inspect.currentframe())[2]
186+
assert info[3] != '_should_set_tablename'
117187
ns.accessed = True
118188

119189
@class_property

0 commit comments

Comments
 (0)