Properly cascade delete with sqlalchemy association proxy - python

I have a self-referential relationship in sqlalchemy that is based heavily on the example found in this answer.
I have a table of users, and an association table that links a primary user to a secondary user. User A can be primary for user B, and B may or may not also be a primary for user A. It works exactly like the twitter analogy in the answer I linked above.
This works fine, except that I don't know how to establish cascade rules for an association proxy. Currently, if I delete a user, the association record remains, but it nulls out any FKs to the deleted user. I would like the delete to cascade to the association table and remove the record.
I also need to be able to disassociate users, which would only remove the association record, but would propagate to the "is_primary_of" and "is_secondary_of" association proxies of the users.
Can anyone help me figure out how to integrate these behaviors into the models that I have? Code is below. Thanks!
import sqlalchemy
import sqlalchemy.orm
import sqlalchemy.ext.declarative
import sqlalchemy.ext.associationproxy
# This is the base class from which all sqlalchemy table objects must inherit
SAModelBase = sqlalchemy.ext.declarative.declarative_base()
class UserAssociation(SAModelBase):
__tablename__ = 'user_associations'
# Columns
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
# Foreign key columns
primary_user_id = sqlalchemy.Column(sqlalchemy.Integer,
sqlalchemy.ForeignKey('users.id', name='user_association_primary_user_fk'))
secondary_user_id = sqlalchemy.Column(sqlalchemy.Integer,
sqlalchemy.ForeignKey('users.id', name='user_association_secondary_user_fk'))
# Foreign key relationships
primary_user = sqlalchemy.orm.relationship('User',
foreign_keys=primary_user_id,
backref='secondary_users')
secondary_user = sqlalchemy.orm.relationship('User',
foreign_keys=secondary_user_id,
backref='primary_users')
def __init__(self, primary, secondary, **kwargs):
self.primary_user = primary
self.secondary_user = secondary
for kw,arg in kwargs.items():
setattr(self, kw, arg)
class User(SAModelBase):
__tablename__ = 'users'
# Columns
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
first_name = sqlalchemy.Column(sqlalchemy.String)
last_name = sqlalchemy.Column(sqlalchemy.String)
is_primary_of = sqlalchemy.ext.associationproxy.association_proxy('secondary_users', 'secondary_user')
is_secondary_of = sqlalchemy.ext.associationproxy.association_proxy('primary_users', 'primary_user')
def associate(self, user, **kwargs):
UserAssociation(primary=self, secondary=user, **kwargs)

Turns out to be pretty straightforward. The backrefs in the original code were just strings, but they can instead be backref objects. This allows you to set cascade behavior. See the sqlalchemy documentation on backref arguments.
The only changes required here are in the UserAssociation object. It now reads:
# Foreign key columns
primary_user_id = sqlalchemy.Column(sqlalchemy.Integer,
sqlalchemy.ForeignKey('users.id',
name='user_association_primary_user_fk'),
nullable=False)
secondary_user_id = sqlalchemy.Column(sqlalchemy.Integer,
sqlalchemy.ForeignKey('users.id',
name='user_association_associated_user_fk'),
nullable=False)
# Foreign key relationships
primary_user = sqlalchemy.orm.relationship('User',
foreign_keys=primary_user_id,
backref=sqlalchemy.orm.backref('secondary_users',
cascade='all, delete-orphan'))
secondary_user = sqlalchemy.orm.relationship('User',
foreign_keys=secondary_user_id,
backref=sqlalchemy.orm.backref('primary_users',
cascade='all, delete-orphan'))
The backref keyword argument is now a backref object instead of a string. I was also able to make the foreign key columns non-nullable, since it now cascades deleted users such that the associations are deleted as well.

Related

How can I set attributes on a "parent" object if a "child" object is assigned to a "relation" in SQLAlchemy?

When I have two objects, linked with a "relation" in SQLAlchemy, I realised that simply assigning to that relation is not enough to propagate the values to the other object. For example (see below), if I have a "user" table and a "contact" table (both are highly contrived, but demonstrate the issue well), and a "user" can have multiple "contacts". In that case I will have a foreign key between the users and contacts. If I create both an instance of User and Contact and later assign the user to the contact, I would expect the attributes of the FK to be updated (even without a DB flush) but they are not. Why? And how can I tell SA to do this automatically?
This would be something I would expect to work, but as you can see in the full example below, it does not:
user = User(name='a', lname='b')
contact(type='email', value='foo#bar.com')
contact.user = user
assert contact.username == 'a' # <-- Fails because the attribute is still `None`
Full runnable example:
"""
This code example shows two tables related to each other by a composite key,
using an SQLAlchemy "relation" to allow easy access to related items.
However, as the last few lines show, simply assigning an object A to the
relation of object B does not update the attributes of object B until at least
a "flush" is called.
"""
from sqlalchemy import Column, ForeignKeyConstraint, Unicode, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relation, sessionmaker
Base = declarative_base()
class User(Base):
__tablename__ = "user"
name = Column(Unicode, primary_key=True)
lname = Column(Unicode, primary_key=True)
class Contact(Base):
__tablename__ = "contact"
__table_args__ = (
ForeignKeyConstraint(
['username', 'userlname'],
['user.name', 'user.lname']
),
)
username = Column(Unicode, primary_key=True)
userlname = Column(Unicode, primary_key=True)
type = Column(Unicode)
value = Column(Unicode)
user = relation(User)
engine = create_engine('sqlite://')
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
user = User(name="John", lname="Doe")
contact = Contact(type='email', value='john.doe#example.com')
contact.user = user # <-- How can I tell SA to set the FKs on *contact* here?
session.add(contact)
print('Before flush: contact.username user=%r' % contact.username)
session.flush()
print('After flush : contact.username user=%r' % contact.username)
According to this answer - https://stackoverflow.com/a/52911047/4981223 it is not possible:
The FK of the child object isn't updated until you issue a flush() either explicitly or through a commit(). I think the reason for this is that if the parent object of a relationship is also a new instance with an auto-increment PK, SQLAlchemy needs to get the PK from the database before it can update the FK on the child object (but I stand to be corrected!).

Custom operations when creating an SQLAlchemy object

Say I have a set of users, a set of games, and I track whether a user has finished a game in a separate table (name: 'game_progress'). I want it to be that whenever a user is created, the 'game_progress' table is auto-populated with her ID and a 'No' against all the available games. (I know that I can wait until she starts a game to create the record, but, I need this for an altogether different purpose.) How would I go about doing this?
I tried using the after_insert() event. But, then I can't retrieve the ID of the User to insert into 'game_progress'. I don't want to use after_flush (even if I can figure out how to do it) because it may be a bit of an overkill, as the user creation operation doesn't happen that often.
class Game(db.Model):
__tablename__ = 'games'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(30))
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(30))
class GameProgress(db.Model):
__tablename__ = 'game_progress'
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), primary_key=True)
game_id = db.Column(db.Integer, db.ForeignKey('games.id'), primary_key=True)
game_finished = db.Column(db.Boolean)
#event.listens_for(User, "after_insert")
def after_insert(mapper, connection, target):
progress_table = GameProgress.__table__
user_id = target.id
connection.execute(
progress_table.insert().\
values(user_id=user_id, game_id=1, game_finished=0)
)
db.create_all()
game = Game(name='Solitaire')
db.session.add(game)
db.session.commit()
user = User(name='Alice')
db.session.add(user)
db.session.commit()
You don't need to do anything fancy with triggers or event listeners at all, you can just set up the relations and then make related objects in the constructor for User. As long as you have defined relationships (which you're not doing at present, you'd only added the foreign keys), then you don't need User to have an id to set up the associated objects. Your constructor can just do something like this:
class User(db.Model):
def __init__(self, all_games, **kwargs):
for k,v in kwargs.items():
setattr(self, k, v)
for game in all_games:
self.game_progresses.append( GameProgress(game=game, \
user=self, game_finished=False) )
When you commit the user, you'll also commit a list of GameProgress objects, one for each game. But the above depends on you setting up relationships on all your objects. You need to add the below to GameProgress class
game = relationship("Game", backref="game_progresses")
user = relationship("User", backref="game_progresses")
And pass in a list of games to user when you make your user:
all_games = dbs.query(Game).all()
new_user = User(all_games=all_games, name="Iain")
Once that's done you can just add GameProgress objects to the instrumented list user.game_progresses and you don't need to have committed anything before the first commit. SQLA will chase through all the relationships. Basically any time you need to muck with an id directly, ask yourself if you're using the ORM right, you rarely need to. The ORM tutorial on the SQLA docs goes through this very well. There are lots of options you can pass to relationships and backrefs to get the cascading doing what you want.

How to create a field with a list of foreign keys in SQLAlchemy?

I am trying to store a list of models within the field of another model. Here is a trivial example below, where I have an existing model, Actor, and I want to create a new model, Movie, with the field Movie.list_of_actors:
import uuid
from sqlalchemy import Boolean, Column, Integer, String, DateTime
from sqlalchemy.schema import ForeignKey
rom sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
class Actor(Base):
__tablename__ = 'actors'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String)
nickname = Column(String)
academy_awards = Column(Integer)
# This is my new model:
class Movie(Base):
__tablename__ = 'movies'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String)
# How do I make this a list of foreign keys???
list_of_actors = Column(UUID(as_uuid=True), ForeignKey('actors.id'))
I understand that this can be done with a many-to-many relationship, but is there a more simple solution? Note that I don't need to look up which Movie's an Actor is in - I just want to create a new Movie model and access the list of my Actor's. And ideally, I would prefer not to add any new fields to my Actor model.
I've gone through the tutorials using the relationships API, which outlines the various one-to-many/many-to-many combinations using back_propagates and backref here: http://docs.sqlalchemy.org/en/latest/orm/basic_relationships.html But I can't seem to implement my list of foreign keys without creating a full-blown many-to-many implementation.
But if a many-to-many implementation is the only way to proceed, is there a way to implement it without having to create an "association table"? The "association table" is described here: http://docs.sqlalchemy.org/en/latest/orm/basic_relationships.html#many-to-many ? Either way, an example would be very helpful!
Also, if it matters, I am using Postgres 9.5. I see from this post there might be support for arrays in Postgres, so any thoughts on that could be helpful.
Update
It looks like the only reasonable approach here is to create an association table, as shown in the selected answer below. I tried using ARRAY from SQLAlchemy's Postgres Dialect but it doesn't seem to support Foreign Keys. In my example above, I used the following column:
list_of_actors = Column('actors', postgresql.ARRAY(ForeignKey('actors.id')))
but it gives me an error. It seems like support for Postgres ARRAY with Foreign Keys is in progress, but still isn't quite there. Here is the most up to date source of information that I found: http://blog.2ndquadrant.com/postgresql-9-3-development-array-element-foreign-keys/
If you want many actors to be associated to a movie, and many movies be associated to an actor, you want a many-to-many. This means you need an association table. Otherwise, you could chuck away normalisation and use a NoSQL database.
An association table solution might resemble:
class Actor(Base):
__tablename__ = 'actors'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String)
nickname = Column(String)
academy_awards = Column(Integer)
class Movie(Base):
__tablename__ = 'movies'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String)
actors = relationship('ActorMovie', uselist=True, backref='movies')
class ActorMovie(Base):
__tablename__ = 'actor_movies'
actor_id = Column(UUID(as_uuid=True), ForeignKey('actors.id'))
movie_id = Column(UUID(as_uuid=True), ForeignKey('movies.id'))
If you don't want ActorMovie to be an object inheriting from Base, you could use sqlachlemy.schema.Table.

How can I create a many-to-many relationship with SQLAlchemy using a SQL function?

I want to create a relationship between two models that have Geometry columns. For example:
from geoalchemy2.types import Geometry
from flask.ext.sqlalchemy import SQLAlchemy
from myapp import app
db = SQLAlchemy(app)
class Property(db.Model):
id = db.Column(db.Integer, primary_key=True)
street_address = db.Column(db.Text)
geom = db.Column(Geometry(geometry_type='POINT'))
service_areas = db.relationship(
'ServiceArea',
primaryjoin='ServiceArea.geom.ST_Contains(Geocode.geom)',
lazy='joined',
uselist=True,
viewonly=True,
)
class ServiceArea (db.Model):
name = db.Column(db.Text)
value = db.Column(db.Text)
geom = db.Column(Geometry(geometry_type='MULTIPOLYGON'))
In this example, a Property may be associated with many ServiceAreas, and a ServiceArea may be associated with many properties. However, there's not a secondary table for me to use for the relationship -- it is all determined by the ST_Contains function.
Whenever I run this code, I get an sqlalchemy.exc.ArgumentError exception telling me to "Ensure that referencing columns are associated with a ForeignKey or ForeignKeyConstraint, or are annotated in the join condition with the foreign() annotation."
When I add foreign around the ServiceArea.geom (even though it's not a foreign key), I get an error suggesting that I "Consider using the remote() annotation to accurately mark those elements of the join condition that are on the remote side of the relationship."
I've tried using foreign and remote separately as well as together (e.g., foreign(remote(ServiceArea.geom)) and remote(foreign(ServiceArea.geom))) but always get back one of the above errors. What am I doing wrong?

Automatically create related model

With SQLAlchemy I have a 1-1 relationship:
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
class UserProfile(Base):
__tablename__ = 'user_profiles'
user_id = Column(Integer, ForeignKey(User.id), primary_key=True)
user = relationship(User,
backref=backref('profile', uselist=False),
foreign_keys=did)
It's required that every User always has an associated UserProfile, even if created like this:
user = User()
session.add(user)
session.commit(user)
Is there a way to automatically create and associate a related entity?
Currently, I'm doing it this way:
#event.listens_for(User, 'init')
def on_user_init(target, args, kwargs):
if not target.profile:
target.profile = UserProfile()
However this sometimes results in
IntegrityError: (IntegrityError) duplicate key value violates unique
constraint "user_profile_pkey" DETAIL: Key (user_id)=(1) already
exists. 'INSERT INTO user_profiles ...
since UserProfile is assigned to a User which already exists.
ORM Event "before_insert" is not applicable since the docs clearly state it is not allowed to add new or modify current instances.
Any better way to achieve that?
The quick-and-easy way is to use "init" event, but filter out instances which have a primary key. Note that at the moment of instance initialization, it's empty, and kwargs argument contains the fields SqlAlchemy (or your code) is going to set on it:
#event.listens_for(User, 'init')
def on_user_init(target, args, kwargs):
if not target.profile and 'id' not in kwargs:
target.profile = UserProfile()
This still won't work when you save instances with id set manually (e.g. User(id=1)), but will cover most of other cases.
As for now, I don't see a better way.

Categories