Source code for core.orm.abstract.associable

""" Generic associations are an interesting SQLAlchemy design pattern
that allow for collections of records to be attached to other models by
mere inheritance.

Generic associations are useful in situations where a model should be
attachable to a number of third-party models. For example, we might say that
a comment model should be attacheable to any other model (say a page or a
ticket). In such an instance generic associations offer the benefit of
being simple to integrate on the third-party model.

Additionally we can define generic methods that work on all third-party
models that inherit from the associated model (in our example imagine a
"Commentable" class that leads to the automatic attachment of comments to
Page/Ticket models).

See also:
https://github.com/zzzeek/sqlalchemy/blob/master/examples/
generic_associations/table_per_association.py

Note that you do not want to use these associations in lieue of simple
relationships between two models. The standard way of doing this leads to a
strong relationship on the database and is easier to change and reason about.

Generic associations are meant for generic usecases. These currently include
payments (any model may be payable) and files (any model may have files
attached). Other generic associations should be introduced along these
lines.

A single model may be associated to any number of other models. For example::

                            ┌─────────────┐
                      ┌─────│ Reservation │
    ┌────────────┐    │     └─────────────┘
    │  Payment   │◀───┤
    └────────────┘    │     ┌─────────────┐
                      └─────│    Form     │
                            └─────────────┘

Here, ``Payment`` is associable (through the ``Payable`` mixin).
``Reservation`` and ``Form`` in turn inherit from ``Payable``.

This all is probably best understood in an example::

        class Payment(Base, Associable):
            __tablename__ == 'payments'

        class Payable:
            payment = associated(Payment, 'payment', 'one-to-one')

        class Product(Base, Payable):
            __tablename__ == 'products'

This results in a product model which has a payment attribute attached to it.
The payments are stored in the ``payments`` table, products in the ``products``
table. The link between the two is established in the automatically created
``payments_for_products`` table.

"""

from onegov.core.orm.utils import QueryChain
from sqlalchemy import Column
from sqlalchemy import ForeignKey
from sqlalchemy import UniqueConstraint
from sqlalchemy import Table
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import object_session
from sqlalchemy.orm import backref, relationship


from typing import overload, Any, ClassVar, Literal, NamedTuple, TypeVar
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from sqlalchemy.orm.query import Query
    from .. import Base

[docs] Cardinality = Literal['one-to-many', 'one-to-one', 'many-to-many']
rel = relationship
[docs] _M = TypeVar('_M', bound='Associable')
@overload
[docs] def associated( associated_cls: type[_M], attribute_name: str, cardinality: Literal['one-to-many', 'many-to-many'] = ..., *, uselist: Literal['auto'] = ..., backref_suffix: str = ..., onupdate: str | None = ... ) -> 'rel[list[_M]]': ...
@overload def associated( associated_cls: type[_M], attribute_name: str, cardinality: Literal['one-to-one'], *, uselist: Literal['auto'] = ..., backref_suffix: str = ..., onupdate: str | None = ... ) -> 'rel[_M | None]': ... @overload def associated( associated_cls: type[_M], attribute_name: str, cardinality: 'Cardinality' = ..., *, uselist: Literal[True], backref_suffix: str = ..., onupdate: str | None = ... ) -> 'rel[list[_M]]': ... @overload def associated( associated_cls: type[_M], attribute_name: str, cardinality: 'Cardinality' = ..., *, uselist: Literal[False], backref_suffix: str = ..., onupdate: str | None = ... ) -> 'rel[_M | None]': ... def associated( associated_cls: type[_M], attribute_name: str, cardinality: 'Cardinality' = 'one-to-many', uselist: Literal['auto'] | bool = 'auto', backref_suffix: str = '__tablename__', onupdate: str | None = None ) -> 'rel[list[_M]] | rel[_M | None]': """ Creates an associated attribute. This attribute is supposed to be defined on the mixin class that will establish the generic association if inherited by a model. :param associated_cls: The class which the model will be associated with. :param attribute_name: The name of the attribute used on the mixin class. :param cardinality: May be 'one-to-one', 'one-to-many' or 'many-to-many'. Cascades are used automatically, unless 'many-to-many' is used, in which case cascades are disabled. :param uselist: True if the attribute on the inheriting model is a list. Use 'auto' if this should be automatically decided depending on the cardinality. :param backref_suffix: Individual suffix used for the backref. :param onupdate: The 'onupdate' constraint of the foreign key column. Example:: class Adress(Base, Associable): pass class Addressable: address = associated(Address, 'address', 'one-to-one') class Company(Base, Addressable): pass """ assert cardinality in ('one-to-one', 'one-to-many', 'many-to-many') cascade: str | bool if cardinality in ('one-to-one', 'one-to-many'): cascade = 'all, delete-orphan' single_parent = True passive_deletes = False else: cascade = False single_parent = False passive_deletes = True if uselist == 'auto': uselist = not cardinality.endswith('to-one') def descriptor(cls: type['Base']) -> 'rel[list[_M]] | rel[_M | None]': # HACK: forms is one of the only tables which doesn't use id as # its primary key, we probably should just use id everywhere # consistently if cls.__tablename__ == 'forms': pk_name = 'name' else: pk_name = 'id' name = '{}_for_{}_{}'.format( associated_cls.__tablename__, cls.__tablename__, attribute_name ) key = f'{cls.__tablename__}_{pk_name}' target = f'{cls.__tablename__}.{pk_name}' if backref_suffix == '__tablename__': backref_name = f'linked_{cls.__tablename__}' else: backref_name = f'linked_{backref_suffix}' association_key = associated_cls.__name__.lower() + '_id' association_id = associated_cls.id # to be more flexible about polymorphism and where the mixin # has to get inserted we only create a new instance for the # association table, if we haven't already created it, we # also only need to create the backref once, since we punt # on polymorphism anyways, but in the case were we don't create # a backref we need to set back_populates so introspection # is aware that the two relationships are inverses of one # another, otherwise things like @observes won't work. association_table = cls.metadata.tables.get(name) if association_table is None: association_table = Table( name, cls.metadata, Column( key, ForeignKey(target, onupdate=onupdate), nullable=False ), Column( association_key, ForeignKey(association_id), nullable=False ), UniqueConstraint( key, association_key, name=f'uq_assoc_{name}' ) ) # The reference from the files class back to the target class fails # to account for polymorphic identities. # # I think this cannot be fixed, as the file class would have to # keep track of the polymorphic class in question through a # separate column and a loading strategy that takes that into # account. # # As a result we disable type-checks here. Note that the target # class may have to override __eq__ and __hash__ to get cascades # to work properly. # # Have a look at onegov.chat.models.Message to see how that has # been done. file_backref = backref( backref_name, enable_typechecks=False ) back_populates = None else: file_backref = None back_populates = backref_name assert issubclass(associated_cls, Associable) associated_cls.register_link( backref_name, cls, association_table, key, attribute_name, cardinality ) return relationship( argument=associated_cls, secondary=association_table, backref=file_backref, back_populates=back_populates, single_parent=single_parent, cascade=cascade, uselist=uselist, passive_deletes=passive_deletes ) return declared_attr(descriptor) # type:ignore[return-value]
[docs] class Associable: """ Mixin to enable associations on a model. Only models which are associable may be targeted by :func:`associated`. """ if TYPE_CHECKING: # FIXME: This should probably be abstract in some way so that # we can enforce that the class that is Associable has # an id column...
[docs] id: Column[Any]
# HACK: let mypy know that this will have a __tablename__ set __tablename__: ClassVar[str] @classmethod
[docs] def association_base(cls) -> type['Associable']: """ Returns the model which directly inherits from Associable. """ for parent in cls.__bases__: if parent is Associable: return cls if issubclass(parent, Associable): return parent.association_base() return cls
@classmethod @property