Source code for sqlalchemy_unchained.base_model_metaclass
import re
import sqlalchemy as sa
from collections import defaultdict
from py_meta_utils import McsArgs, McsInitArgs, process_factory_meta_options
from sqlalchemy import Column
from sqlalchemy.ext.declarative import (
DeclarativeMeta as BaseDeclarativeMeta, declared_attr)
from sqlalchemy.schema import _get_table_key
from sqlalchemy_unchained.utils import snake_case
from .model_meta_options import ModelMetaOptionsFactory
VALIDATOR_RE = re.compile(r'^validates?_(?P<column>\w+)')
# copied from flask-sqlalchemy (BSD license)
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, BaseDeclarativeMeta) 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, BaseDeclarativeMeta)
)
return True
# copied from flask-sqlalchemy (BSD license)
class NameMetaMixin:
def __init__(cls, name, bases, d):
if should_set_tablename(cls):
cls.__tablename__ = snake_case(cls.__name__)
super(NameMetaMixin, cls).__init__(name, bases, d)
# __table_cls__ has run at this point
# if no table was created, use the parent table
if (
'__tablename__' not in cls.__dict__
and '__table__' in cls.__dict__
and cls.__dict__['__table__'] is None
):
del cls.__table__
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.
"""
# check if a table with this name already exists
# allows reflected tables to be applied to model by name
key = _get_table_key(args[0], kwargs.get('schema'))
if key in cls.metadata.tables:
return sa.Table(*args, **kwargs)
# if a primary key or constraint is found, create a table for
# joined-table inheritance
for arg in args:
is_pk_column = isinstance(arg, sa.Column) and arg.primary_key
is_pk_constraint = isinstance(arg, sa.PrimaryKeyConstraint)
if is_pk_column or is_pk_constraint:
return sa.Table(*args, **kwargs)
# if no base classes define a table, return one
# ensures the correct error shows up when missing a primary key
for base in cls.__mro__[1:-1]:
if '__table__' in base.__dict__:
break
else:
return sa.Table(*args, **kwargs)
# single-table inheritance, use the parent tablename
if '__tablename__' in cls.__dict__:
del cls.__tablename__
# copied from flask-sqlalchemy (BSD license)
class BindMetaMixin:
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
[docs]class DeclarativeMeta(NameMetaMixin, BindMetaMixin, BaseDeclarativeMeta):
"""
Base metaclass for models in SQLAlchemy Unchained. Sets up support for using
Meta options on models, automatically sets ``__tablename__`` if necessary,
and configures validation for concrete models.
"""
def __new__(mcs, name, bases, clsdict):
mcs_args = McsArgs(mcs, name, bases, clsdict)
from .model_registry import ModelRegistry
ModelRegistry()._ensure_correct_base_model(mcs_args)
Meta = process_factory_meta_options(
mcs_args, default_factory_class=ModelMetaOptionsFactory)
if Meta.abstract:
return super().__new__(*mcs_args)
validators = mcs_args.getattr('__validators__', defaultdict(list))
columns = {col_name: col for col_name, col in clsdict.items()
if isinstance(col, Column)}
for col_name, col in columns.items():
if not col.name:
col.name = col_name
if col.info:
for v in col.info.get('validators', []):
if v not in validators[col_name]:
validators[col_name].append(v)
for attr_name, attr in clsdict.items():
m = VALIDATOR_RE.match(attr_name)
column = m.groupdict()['column'] if m else None
if m and mcs_args.getattr(column, None) is not None:
attr.__validates__ = column
if attr_name not in validators[column]:
validators[column].append(attr_name)
clsdict['__validators__'] = validators
ModelRegistry().register_new(mcs_args)
return super().__new__(*mcs_args)
def __init__(cls, name, bases, clsdict):
# for some as-yet-not-understood reason, the arguments python passes
# to __init__ do not match those we gave to __new__ (namely, the
# bases parameter passed to __init__ is what the class was declared
# with, instead of the new bases the model_registry determined it
# should have. and in fact, __new__ does the right thing - it uses
# the correct bases, and the generated class has the correct bases,
# yet still, the ones passed to __init__ are wrong. however at this
# point (inside __init__), because the class has already been
# constructed, changing the bases argument doesn't seem to have any
# effect (and that agrees with what conceptually should be the case).
# Sooo, we're passing the correct arguments up the chain, to reduce
# confusion, just in case anybody needs to inspect them)
_, name, bases, clsdict = cls.Meta._mcs_args
if cls.Meta.abstract:
super().__init__(name, bases, clsdict)
return
if should_set_tablename(cls):
cls.__tablename__ = snake_case(cls.__name__)
from .model_registry import ModelRegistry
if not ModelRegistry().enable_lazy_mapping or not cls.Meta.lazy_mapped:
cls._pre_mcs_init()
super().__init__(name, bases, clsdict)
cls._post_mcs_init()
ModelRegistry().register(McsInitArgs(cls, name, bases, clsdict))
def _pre_mcs_init(cls):
"""
Callback for BaseModelMetaclass subclasses to run code just before a
concrete Model class gets registered with SQLAlchemy.
This is intended to be used for advanced meta options implementations.
"""
# technically you could also put a @classmethod with the same name on
# the Model class, if you prefer that approach
def _post_mcs_init(cls):
"""
Callback for BaseModelMetaclass subclasses to run code just after a
concrete Model class gets registered with SQLAlchemy.
This is intended to be used for advanced meta options implementations.
"""
__all__ = [
'BindMetaMixin',
'DeclarativeMeta',
'NameMetaMixin',
]