sqlalchemy generic foreign key (like in django ORM)
Asked Answered
A

2

22

Does sqlalchemy have something like django's GenericForeignKey? And is it right to use generic foreign fields.

My problem is: I have several models (for example, Post, Project, Vacancy, nothing special there) and I want to add comments to each of them. And I want to use only one Comment model. Does it worth to? Or should I use PostComment, ProjectComment etc.? Pros/cons of both ways?

Thanks!

Arching answered 17/7, 2013 at 14:57 Comment(0)
H
28

The simplest pattern which I use most often is that you actually have separate Comment tables for each relationship. This may seem frightening at first, but it doesn't incur any additional code versus using any other approach - the tables are created automatically, and the models are referred to using the pattern Post.Comment, Project.Comment, etc. The definition of Comment is maintained in one place. This approach from a referential point of view is the most simple and efficient, as well as the most DBA friendly as different kinds of Comments are kept in their own tables which can be sized individually.

Another pattern to use is a single Comment table, but distinct association tables. This pattern offers the use case that you might want a Comment linked to more than one kind of object at a time (like a Post and a Project at the same time). This pattern is still reasonably efficient.

Thirdly, there's the polymorphic association table. This pattern uses a fixed number of tables to represent the collections and the related class without sacrificing referential integrity. This pattern tries to come the closest to the Django-style "generic foreign key" while still maintaining referential integrity, though it's not as simple as the previous two approaches.

Imitating the pattern used by ROR/Django, where there are no real foreign keys used and rows are matched using application logic, is also possible.

The first three patterns are illustrated in modern form in the SQLAlchemy distribution under examples/generic_associations/.

The ROR/Django pattern, since it gets asked about so often, I will also add to the SQLAlchemy examples, even though I don't like it much. The approach I'm using is not exactly the same as what Django does as they seem to make use of a "contenttypes" table to keep track of types, that seems kind of superfluous to me, but the general idea of an integer column that points to any number of tables based on a discriminator column is present. Here it is:

from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy import create_engine, Integer, Column, \
                    String, and_
from sqlalchemy.orm import Session, relationship, foreign, remote, backref
from sqlalchemy import event


class Base(object):
    """Base class which provides automated table name
    and surrogate primary key column.

    """
    @declared_attr
    def __tablename__(cls):
        return cls.__name__.lower()
    id = Column(Integer, primary_key=True)
Base = declarative_base(cls=Base)

class Address(Base):
    """The Address class.

    This represents all address records in a
    single table.

    """
    street = Column(String)
    city = Column(String)
    zip = Column(String)

    discriminator = Column(String)
    """Refers to the type of parent."""

    parent_id = Column(Integer)
    """Refers to the primary key of the parent.

    This could refer to any table.
    """

    @property
    def parent(self):
        """Provides in-Python access to the "parent" by choosing
        the appropriate relationship.

        """
        return getattr(self, "parent_%s" % self.discriminator)

    def __repr__(self):
        return "%s(street=%r, city=%r, zip=%r)" % \
            (self.__class__.__name__, self.street,
            self.city, self.zip)

class HasAddresses(object):
    """HasAddresses mixin, creates a relationship to
    the address_association table for each parent.

    """

@event.listens_for(HasAddresses, "mapper_configured", propagate=True)
def setup_listener(mapper, class_):
    name = class_.__name__
    discriminator = name.lower()
    class_.addresses = relationship(Address,
                        primaryjoin=and_(
                                        class_.id == foreign(remote(Address.parent_id)),
                                        Address.discriminator == discriminator
                                    ),
                        backref=backref(
                                "parent_%s" % discriminator,
                                primaryjoin=remote(class_.id) == foreign(Address.parent_id)
                                )
                        )
    @event.listens_for(class_.addresses, "append")
    def append_address(target, value, initiator):
        value.discriminator = discriminator

class Customer(HasAddresses, Base):
    name = Column(String)

class Supplier(HasAddresses, Base):
    company_name = Column(String)

engine = create_engine('sqlite://', echo=True)
Base.metadata.create_all(engine)

session = Session(engine)

session.add_all([
    Customer(
        name='customer 1',
        addresses=[
            Address(
                    street='123 anywhere street',
                    city="New York",
                    zip="10110"),
            Address(
                    street='40 main street',
                    city="San Francisco",
                    zip="95732")
        ]
    ),
    Supplier(
        company_name="Ace Hammers",
        addresses=[
            Address(
                    street='2569 west elm',
                    city="Detroit",
                    zip="56785")
        ]
    ),
])

session.commit()

for customer in session.query(Customer):
    for address in customer.addresses:
        print(address)
        print(address.parent)
Homebred answered 20/7, 2013 at 1:19 Comment(7)
Thank you! I don't like first pattern - I think, it's not DRY. If I want to add some info to each comments (may be, edited flag) I should do It with all models/tables. I was thinking about second pattern for my Tag model. It can be linked to Project and Post at the same time. And third seems to be what I need for my Comment. ROR/Django like seems to be not so simple, so I will "study" It.Arching
it is totally DRY. DRY means, "don't repeat yourself". If you look at how the pattern works, you're not repeating yourself at all. Just because there are lots of similar tables in the DB doesn't mean you're repeating yourself; their creation is automated, as is the addition of some new column like "edited" (using a tool like Alembic). It is the most space/time efficient and DBA-friendly approach (as storage for different tables can be configured independently). It's too bad I have so much trouble convincing ex-djangoers of this.Homebred
I like the concept of your first solution, but I'm not clear on how you actually implement it. Could you give a short example?Esmerolda
examples of all the patterns here, including one that does more or less the ROR/Django generic foreign key, are in the SQLA distro under examples/generic_associations, as of 0.9 they've been updated: github.com/zzzeek/sqlalchemy/tree/master/examples/…Homebred
@Homebred I guess the thing is it's harder to run cleanup / stats / anything else that tries to analyse all of the comments when they're scattered across multiple tables. I guess creating a view on top would solve any read-only issues like that, though.Cate
@RobGrant can you give more details on how this view on top would work?Alleenallegation
@Homebred thanks for the examples! Just a question, are they fully compatible with version 1.4, is there something I should be aware while trying to do this nowadays?Alleenallegation
P
0

I know this is probably a terrible way to do this, but it was a quick fix for me.

class GenericRelation(object):
    def __init__(self, object_id, object_type):
        self.object_id = object_id
        self.object_type = object_type

    def __composite_values__(self):
        return (self.object_id, self.object_type)


class Permission(AbstractBase):

    #__abstract__ = True

    _object = None

    _generic = composite(
        GenericRelation,
        sql.Column('object_id', data_types.UUID, nullable=False),
        sql.Column('object_type', sql.String, nullable=False),
    )

    permission_type = sql.Column(sql.Integer)

    @property
    def object(self):
        session = object_session(self)
        if self._object or not session:
            return self._object
        else:
            object_class = eval(self.object_type)
            self._object = session.query(object_class).filter(object_class.id == self.object_id).first()
            return self._object

    @object.setter
    def object(self, value):
        self._object = value
        self.object_type = value.__class__.__name__
        self.object_id = value.id
Polacca answered 24/7, 2013 at 9:50 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.