SQLalchemy setting constraints on relationships in many-to-many - python

Suppose I have a set of users and each user has access to a collection of tools. The same tool might have many users with access so this is a many-to-many relationship:
class User(db.Model):
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, unique=True)
tools = db.relationship("Tool", secondary=user_tool_assoc_table,
back_populates='users')
class Tool(db.Model):
__tablename__ = 'tool'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, unique=False)
user_tool_assoc_table = db.Table('user_tool', db.Model.metadata,
db.Column('user', db.Integer, db.ForeignKey('user.id')),
db.Column('tool', db.Integer, db.ForeignKey('tool.id')))
Observe that user names are unique, but tool names are not. So User.name:Mike1 and User.name:Mike2 might have access to Tool.name:Hammer, and, separately, User.name:John1 and User.name:John2 might have access to Tool.name:Hammer by the same name but each with different Tool.ids.
I want to make a constraint that within the User.tools collection there can never be a tool with the same name as another, i.e.
A user cannot create a new Tool as part of his collection if one with that name already exists. Mike1 cannot create a new tool called Hammer that forms part of his tools collection.
A Tool that exists in the database cannot be appended to the tools collection of a user if one with the same name already exists in the set, i.e. John1's Hammer cannot be shared with Mike1 since Mike1 already has his own Hammer.
James, however, can create a new Hammer since he does not already have a hammer. There will then be 3 tools in the database called Hammer each with a distinct set of Users.
Note in my specific case a Tool will only exist if it has at least one User, but I also don't know how to ensure this natively in my database.
Is this possible natively with SQLalchemy to automatically configure my database to maintain integrity? I don't want to write my own validator rules since I will likely miss something and end up with a database which breaks my rules.

The problem is how to express the predicate "A user identified by ID has only one tool with the name NAME". This would of course be easy to express with a simple table such as:
db.Table('user_toolname',
db.Column('user', db.Integer, db.ForeignKey('user.id'), primary_key=True),
db.Column('toolname', db.String, primary_key=True))
It is also very clear that this alone is not nearly enough to uphold integrity, as there is no connection between the fact about user's toolnames and the actual tools. Your database could state that a user both has a hammer and doesn't have a hammer.
It would be nice to enforce this in your user_tool_assoc_table or something equivalent, but since Tool.name is not a part of the primary key of Tool, you cannot reference it. On the other hand since you do want to allow multiple tools with the same name to co-exist, the subset { id, name } is in fact the proper key for Tool:
class Tool(db.Model):
__tablename__ = 'tool'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String, primary_key=True)
The id now acts as a "discriminator" of sorts between the tools having the same name. Note that id need not be globally unique in this model, but locally to name. It's handy to have it auto increment still, but the default setting of autoincrement='auto' only treats a single-column integer primary key as having auto incrementing behavior by default, so it must be explicitly set.
It is now possible to define user_tool_assoc_table in terms of tool_name as well, with the additional constraint that a user can only have a single tool with a given name:
user_tool_assoc_table = db.Table(
'user_tool',
db.Column('user', db.Integer, db.ForeignKey('user.id')),
db.Column('tool', db.Integer),
db.Column('name', db.String),
db.ForeignKeyConstraint(['tool', 'name'],
['tool.id', 'tool.name']),
db.UniqueConstraint('user', 'name'))
With this model and the following setup:
john = User(name='John')
mark = User(name='Mark')
db.session.add_all([john, mark])
hammer1 = Tool(name='Hammer')
hammer2 = Tool(name='Hammer')
db.session.add_all([hammer1, hammer2])
db.session.commit()
This will succeed:
john.tools.append(hammer1)
hammer2.users.append(mark)
db.session.commit()
And this will fail after the above, since it violates the unique constraint:
john.tools.append(hammer2)
db.session.commit()

If you want to model the domain by allowing tool names to be non-unique, then there is no easy way to accomplish this.
You can try adding a validator to the User model which will check the User.tools list during every append and make sure that it obeys a certain condition
from sqlalchemy.orm import validates
class User(db.Model):
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, unique=True)
tools = db.relationship("Tool", secondary=user_tool_assoc_table,
back_populates='users')
#validates('tools')
def validate_tool(self, key, tool):
assert tool.name not in [t.name for t in self.tools]
return tool
def __repr__(self):
return self.name
The above approach will make sure that if you add a new tool which has the same name as an existing tools in user.tools list it will throw an exception. But the problem is that you can still directly assign a new list with duplicate tools directly like this
mike.tools = [hammer1, hammer2, knife1]
This will work because validates works only during append operation. Not during assignment. If we want a solution that works even during assignment, then we will have to figure out a solution where user_id and tool_name will be in the same table.
We can do this by making the secondary association table have 3 columns user_id, tool_id and tool_name. We can then make tool_id and tool_name to behave as a Composite Foreign Key together (Refer https://docs.sqlalchemy.org/en/latest/core/constraints.html#defining-foreign-keys)
By this approach, the association table will have a standard foreign key to user_id and then a composite foreign key constraint which combines tool_id and tool_name. Now that both keys are there in the association table, we can then proceed to define an UniqueConstraint on the table which will make sure that user_id and tool_name will have to be an unique combination
Here is the code
from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from sqlalchemy.orm import validates
from sqlalchemy.schema import ForeignKeyConstraint, UniqueConstraint
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
db = SQLAlchemy(app)
user_tool_assoc_table = db.Table('user_tool', db.Model.metadata,
db.Column('user_id', db.Integer, db.ForeignKey('user.id')),
db.Column('tool_id', db.Integer),
db.Column('tool_name', db.Integer),
ForeignKeyConstraint(['tool_id', 'tool_name'], ['tool.id', 'tool.name']),
UniqueConstraint('user_id', 'tool_name', name='unique_user_toolname')
)
class User(db.Model):
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, unique=True)
tools = db.relationship("Tool", secondary=user_tool_assoc_table,
back_populates='users')
def __repr__(self):
return self.name
class Tool(db.Model):
__tablename__ = 'tool'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, unique=False)
users = db.relationship("User", secondary=user_tool_assoc_table,
back_populates='tools')
def __repr__(self):
return "{0} - ID: {1}".format(self.name, self.id)
db.create_all()
mike=User(name="Mike")
pete=User(name="Pete")
bob=User(name="Bob")
db.session.add_all([mike, pete, bob])
db.session.commit()
hammer1 = Tool(name="hammer")
hammer2 = Tool(name="hammer")
knife1 = Tool(name="knife")
knife2 = Tool(name="knife")
db.session.add_all([hammer1, hammer2, knife1, knife2])
db.session.commit()
Now let's try playing around
In [2]: users = db.session.query(User).all()
In [3]: tools = db.session.query(Tool).all()
In [4]: users
Out[4]: [Mike, Pete, Bob]
In [5]: tools
Out[5]: [hammer - ID: 1, hammer - ID: 2, knife - ID: 3, knife - ID: 4]
In [6]: users[0].tools = [tools[0], tools[2]]
In [7]: db.session.commit()
In [9]: users[0].tools.append(tools[1])
In [10]: db.session.commit()
---------------------------------------------------------------------------
IntegrityError Traceback (most recent call last)
<ipython-input-10-a8e4ec8c4c52> in <module>()
----> 1 db.session.commit()
/home/surya/Envs/inkmonk/local/lib/python2.7/site-packages/sqlalchemy/orm/scoping.pyc in do(self, *args, **kwargs)
151 def instrument(name):
152 def do(self, *args, **kwargs):
--> 153 return getattr(self.registry(), name)(*args, **kwargs)
154 return do
So appending a tool of the same name throws exception.
Now let's try assigning a list with duplicate tool names
In [14]: tools
Out[14]: [hammer - ID: 1, hammer - ID: 2, knife - ID: 3, knife - ID: 4]
In [15]: users[0].tools = [tools[0], tools[1]]
In [16]: db.session.commit()
---------------------------------------------------------------------------
IntegrityError Traceback (most recent call last)
<ipython-input-16-a8e4ec8c4c52> in <module>()
----> 1 db.session.commit()
/home/surya/Envs/inkmonk/local/lib/python2.7/site-packages/sqlalchemy/orm/scoping.pyc in do(self, *args, **kwargs)
151 def instrument(name):
152 def do(self, *args, **kwargs):
--> 153 return getattr(self.registry(), name)(*args, **kwargs)
154 return do
This throws an exception as well. So we have made sure at db level that your requirement is solved.
But in my opinion, taking such a convoluted approach usually indicates that we are needlessly complicating the design. If you are ok with changing the table design, please consider the following suggestion for a simpler approach.
In my opinion, it is better to have a set of unique tools and a set of unique users and then model a M2M relationship between them. Any property which is specific to Mike's hammer, but not present in James' hammer should be a property of that association between them.
If you take that approach, you have a set of users like this
Mike, James, John, George
and a set of tools like this
Hammer, Screwdriver, Wedge, Scissors, Knife
And you can still model a many to many relation between them. In this scenario, the only change you have to do is to set unique=True on the Tool.name column, so that there is only one hammer globally which can have that name.
If you need Mike's hammer to have some unique properties distinct from James's Hammer, then you can just add some extra columns in the association table. To access user.tools and tool.users, you can use an association_proxy.
from sqlalchemy.ext.associationproxy import association_proxy
class User(db.Model):
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, unique=True)
associated_tools = db.relationship("UserToolAssociation")
tools = association_proxy("associated_tools", "tool")
class Tool(db.Model):
__tablename__ = 'tool'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, unique=True)
associated_users = db.relationship("UserToolAssociation")
users = association_proxy("associated_users", "user")
class UserToolAssociation(db.Model):
__tablename__ = 'user_tool_association'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
tool_id = db.Column(db.Integer, db.ForeignKey('tool.id'))
property1_specific_to_this_user_tool = db.Column(db.String(20))
property2_specific_to_this_user_tool = db.Column(db.String(20))
user = db.relationship("User")
tool = db.relationship("Tool")
The above approach is better because of the proper separation of concerns. In future when you need to do something that will affect all hammers, you can just modify the hammer instance in the Tools table. If you keep all hammers as separate instances without any link between them, it will become cumbersome to do any modification on them as a whole in the future.

Related

Two-way foreign keys on different attributes, one-to-one and one-to-many

I have two models: User and ReferralKey, with these requirements:
On creation of User, a ReferralKey is automatically created and added to the DB
ReferralKey keeps track of which users were referred by it
ReferralKey keeps track of which user owns it
As per the answer to this question, the best solution seems to be to create the ReferralKey within the constructor of User. The solution to the other two require foreign keys, and seems really messy—entangling the tables together in such a way that I might as well put them in the same table.
The solution to the first looks like this:
def User(model):
id = Column(BigInteger(), autoincrement=True, primary_key=True)
referral_key = relationship('ReferralKey', uselist=False)
...
def __init__(self):
self.referral_key = ReferralKey()
def ReferralKey(model):
id = Column(BigInteger(), autoincrement=True, primary_key=True)
user_id = Column(BigInteger(), ForeignKey('user.id', ondelete='SET NULL'), nullable=True)
This works as intended, and solves the first and third points. The problem arises when trying to solve the 2nd. This (for some reason) necessitates a new foreign key in User, which necessitates the declaration of a relationship in both User and ReferralKey to (I guess) disambiguate the foreign keys:
def User(model):
id = Column(BigInteger(), autoincrement=True, primary_key=True)
referral_key = relationship('ReferralKey', uselist=False)
referrer_id = Column(BigInteger(), ForeignKey('referral_key.id', ondelete='SET NULL'))
referrer = relationship('ReferralKey', foreign_keys=['referrer_id'], backref='used_by')
...
def __init__(self):
self.referral_key = ReferralKey()
def ReferralKey(model):
__tablename__='referral_key'
id = Column(BigInteger(), autoincrement=True, primary_key=True)
user_id = Column(BigInteger(), ForeignKey('user.id', ondelete='SET NULL'), nullable=True)
user = relationship('User', foreign_keys=['user_id'])
I've tried all different permutations of relationship and ForeignKey, and always get the same error:
sqlalchemy.exc.CircularDependencyError: Can't sort tables for DROP; an unresolvable foreign key dependency exists between tables: referral_key, users. Please ensure that the ForeignKey and ForeignKeyConstraint objects involved in the cycle have names so that they can be dropped using DROP CONSTRAINT.
Ultimately, my problem is that I just don't understand what I'm doing. Why do I need to change the User table at all in order to keep track of things on the ReferralKey table? What purpose does the relationship declaration serve—why is it ambiguous without this declaration? If User has a foreign key referencing ReferralKey and ReferralKey has a foreign key referencing User—and either of these should be set to NULL in case of deletion, why does SQL need more information than that?
Why can't I just have:
def User(model):
id = Column(BigInteger(), autoincrement=True, primary_key=True)
def __init__(self):
ReferralKey(user_id=self.id)
def ReferralKey(model):
__tablename__='referral_key'
id = Column(BigInteger(), autoincrement=True, primary_key=True)
user_id = Column(BigInteger(), ForeignKey('user.id', ondelete='SET NULL'), nullable=True)
used_by = [list of user IDs]
def __init__(self, user_id):
if user_id:
self.user_id == user_id
This feels to me so much cleaner and more intuitive. If I want to add (or remove!) referral keys, I hardly have to worry about adding things to User because it's mostly independent of the functioning of the referral keys. Why do I need to add a column in the user table to keep track of something that I want the ReferralKey to keep track of?
I'm totally ignorant of this, basically. Would anyone mind helping me out?
Try the following.
It relies on the information:
A user creates his own master key on registration.
A user may register a slave key when he registers, but maybe not.
This way the foreign keys remain in one table but you need to differentiate between them..
from sqlalchemy import *
from sqlalchemy.orm import *
from sqlalchemy.ext.declarative import declarative_base
Base= declarative_base()
class User(Base):
__tablename__ = "user"
id = Column(Integer, primary_key=True)
own_referral_id = Column(Integer, ForeignKey('referral_key.id'))
own_referral_key = relationship('ReferralKey', foreign_keys=[own_referral_id], back_populates='owner')
signup_referral_id = Column(Integer, ForeignKey('referral_key.id'))
signup_referral_key = relationship('ReferralKey', foreign_keys=[signup_referral_id], back_populates='signups')
def __init__(self, **kwargs):
self.own_referral_key = ReferralKey()
super().__init__(**kwargs)
class ReferralKey(Base):
__tablename__ = "referral_key"
id = Column(Integer, primary_key=True)
owner = relationship('User', foreign_keys=[User.own_referral_id], back_populates='own_referral_key', uselist=False)
signups = relationship('User', foreign_keys=[User.signup_referral_id], back_populates='signup_referral_key', uselist=True)
e = create_engine("sqlite://")
Base.metadata.create_all(e)
s = Session(e)
u1 = User()
s.add(u1)
s.commit()
u2 = User(signup_referral_id = u1.own_referral_id)
u3 = User(signup_referral_id = u1.own_referral_id)
s.add(u2)
s.add(u3)
s.commit()
print(u1.own_referral_key.signups)

Adding a comments field to database with flask/SQLAlchemy?

I'm working on a flask application that has a database that is set up like this and am using SQLAlchemy. (display_name was renamed to persona_name since that's what the steam api calls it.)
I'm still in the process of learning how work with databases, and what I have now works fine, I can track all the lists a user has made, and I can get suspects from all lists that one user has made. Also, removing a suspect from the suspect table also seems to remove the suspect from all lists it appears on.
This seems to work well, List and Suspect are classes that inherit from db.Model while suspect_list is an auxiliary table that is not itself a class.
The way it is set up I want to be able to have a suspect show up on multiple lists, but now I want to be able to add comments for individual suspects on individual lists.
I'm not sure how to go about this, but I have a few ideas and am not sure if they work or would have potential downsides.
1) Can I add a comment field to suspect_list?
2) Do I make a comment model as a class that inherits from db.Model and then add it to the auxiliary table instead?
3) Should I create a new id field for the suspect table, make it the primary key instead of steam_id , and then add a comment field so there can be duplicate suspects that have differing comments?
I probably could implement 3 but I don't think it is a good idea because duplicates of the same suspect probably is something that should be avoided.
As for 1 and 2 I don't know if they would work and if they did I'm not sure how to properly implement them.
This is the code that I have for my Models.py if it is needed
My question is how would I properly add comments to this database model I have set up?
Rather then an association table, you really need an association object. Here's a working example.
from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.secret_key = 'MOO.'
app.config["SQLALCHEMY_DATABASE_URI"] = 'sqlite://' # In memory.
db = SQLAlchemy(app)
class User(db.Model):
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String)
def __repr__(self):
return '<User {}>'.format(self.name)
class Suspect(db.Model):
__tablename__ = 'suspect'
id = db.Column(db.Integer, primary_key=True)
steam_name = db.Column(db.String)
def __repr__(self):
return '<Suspect {}>'.format(self.steam_name)
class List(db.Model):
__tablename__ = 'list'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String())
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
user = db.relationship('User', backref=db.backref('lists'))
suspects = db.relationship('SuspectList', backref='list')
def __repr__(self):
return '<List {}>'.format(self.name)
class SuspectList(db.Model):
__tablename__ = 'suspect_list'
list_id = db.Column(db.Integer, db.ForeignKey('list.id'), primary_key=True)
suspect_id = db.Column(db.Integer, db.ForeignKey('suspect.id'), primary_key=True)
comment = db.Column(db.String)
suspect = db.relationship('Suspect', backref=db.backref("suspect_list"))
def __repr__(self):
return '<SL: %s %s %s>' % (self.list, self.suspect, self.comment)
if __name__ == '__main__':
with app.app_context():
db.create_all()
hacker = Suspect(steam_name='Bob')
cracker = Suspect(steam_name='Alice')
carol = User(name='Carol')
carols_list = List(name='Carols', user=carol)
hacker_suspect = SuspectList(suspect=hacker, comment='RED HAIR')
cracker_suspect = SuspectList(suspect=cracker, comment='RED EYES')
carols_list.suspects.append(hacker_suspect)
carols_list.suspects.append(cracker_suspect)
db.session.add(carols_list) # Trust SQLAlchemy to add all related.
db.session.commit()
## Querying:
my_list = List.query.filter(User.name == 'Carol').first()
for record in my_list.suspects:
print '{} reported {} with comment {}'.format(
record.list.user.name,
record.suspect.steam_name,
record.comment
)
# Carol reported Bob with comment RED HAIR
# Carol reported Alice with comment RED EYES
Two side-notes-- I found it a bit ugly to be use List as a class name, because when you want to query it, your default name would be list = List.query.fi.... which is a bit of a no-go :). Also, what program did you use to generate your ERD?
if i correctly understand, i would just create a new model related to your List Model
class Comments(db.Model):
id = db.Column(db.Integer, primary_key=True)
comment = db.Column(db.String(300))
list_id = db.Column(db.Integer, db.ForeignKey('list.id'))

SQLAlchemy DELETE Error caused by having a both lazy-load AND a dynamic version of the same relationship

Here is some example code:
users_groups = Table('users_groups', Model.metadata,
Column('user_id', Integer, ForeignKey('users.id')),
Column('group_id', Integer, ForeignKey('groups.id'))
)
class User(Model):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
class Group(Model):
__tablename__ = 'groups'
id = Column(Integer, primary_key=True)
users = relationship('User', secondary=users_groups, lazy='select', backref='groups')
users_dynamic = relationship('User', secondary=users_groups, lazy='dynamic')
So what happens here is that if you add a bunch of users to a group like so:
g = Group()
g.users = [User(), User(), User()]
session.add(g)
session.commit()
and then try to delete the group
session.delete(g)
session.commit()
You will get some form of this error:
DELETE statement on table 'users_groups' expected to delete 3 row(s); Only 0 were matched.
Removing the 2nd version of the relationship (the dynamic one in my case) fixes this problem. I am not even sure where to begin in terms of understanding why this is happening. I have been using 2 versions of various relationships in many cases throughout my SQLAlchemy models in order to make it easy to use the most appropriate query-strategy given a situation. This is the first time it has caused an unexpected issue.
Any advice is welcome.
both the Group.users and Group.users_dynamic relationships are attempting to reconcile the fact that the Group is being deleted along with being able to manage the User() objects they refer to; one relationship succeeds while the second one fails, as the rows in the association table were already deleted. The most straightforward solution is to mark all but one of the identical relationships as viewonly:
class Group(Base):
__tablename__ = 'groups'
id = Column(Integer, primary_key=True)
users = relationship('User', secondary=users_groups, lazy='select', backref='groups')
users_dynamic = relationship('User', viewonly=True, secondary=users_groups, lazy='dynamic')
if you're still wanting to have both relationships handle some degree of mutations, you'd need to do this carefully as SQLAlchemy doesn't know how to coordinate among changes in two relationships at the same time, so conflicts like this can continue to happen (like double inserts, etc) if you make equivalent mutations on both relationships. To just take care of the "delete" issue by itself, you can also try setting Group.users_dynamic to passive_deletes=True:
class Group(Base):
__tablename__ = 'groups'
id = Column(Integer, primary_key=True)
users = relationship('User', secondary=users_groups, lazy='select', backref='groups')
users_dynamic = relationship('User', passive_deletes=True, secondary=users_groups, lazy='dynamic')
I just add another simple workaround.
You can delete the collections before deleting the item itself:
>>> for user in group.users:
group.users.remove(user)
>>> db.session.delete(group)
>>> db.session.commit()
Alternatively, you can also set it as an empty list:
>>> group.users = []
>>> db.session.commit()
>>> db.session.delete(group)
>>> db.session.commit()

Sqlalchemy: One to Many relationship combined with Many to Many relationship

I've got a User and Group table with a many to many relationship
_usergroup_table = db.Table('usergroup_table', db.metadata,
db.Column('user_id', db.Integer, db.ForeignKey('user.id')),
db.Column('group_id', db.Integer, db.ForeignKey('group.id')))
class User(db.Model):
"""Handles the usernames, passwords and the login status"""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(60), nullable=False, unique=True)
class Group(db.Model):
"""Used for unix-style access control."""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(60), nullable=False)
users = db.relationship('User', secondary=_usergroup_table,
backref='groups')
Now i'd like to add a primary group to the user class. Of course I could just add a group_id column and a relationship to the Group class, but this has drawbacks. I'd like to get all groups when calling User.group, including primary_group. The primary group should always be part of the groups relationship.
Edit:
It seems the way to go is the association object
class User(db.Model, UserMixin):
"""Handles the usernames, passwords and the login status"""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(60), nullable=False, unique=True)
primary_group = db.relationship(UserGroup,
primaryjoin="and_(User.id==UserGroup.user_id,UserGroup.primary==True)")
class Group(db.Model):
"""Used for unix-style access control."""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(60), nullable=False)
class UserGroup(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
group_id = db.Column(db.Integer, db.ForeignKey('group.id'))
active = db.Column(db.Boolean, default=False)
user = db.relationship(User, backref='groups', primaryjoin=(user_id==User.id))
group = db.relationship(Group, backref='users', primaryjoin=(group_id==Group.id))
I could simplify this with the AssociationProxy, but how do I force only a single primary group per user?
The group_id approach you originally thought of has several advantages here to the "boolean flag" approach.
For one thing, it is naturally constrained so that there is only one primary group per user. For another, loading user.primary_group means the ORM can identify this related row by it's primary key, and can look locally in the identity map for it, or emit a simple SELECT by primary key, instead of emitting a query that has a hard-to-index WHERE clause with a boolean inside of it. Yet another is there's no need to get into the association object pattern which simplifies the usage of the association table and allows SQLAlchemy to handle loads and updates from/to this table more efficiently.
Below we use events, including a new version (as of 0.7.7) of #validates that catches "remove" events, to ensure object-level modifications to User.groups and User.primary_group are kept in sync. (If on an older version of 0.7 you can use the attribute "remove" event or the "AttributeExtension.remove" extension method if you're still on 0.6 or earlier). If you wanted to enforce this at the DB level you could possibly use triggers to verify the integrity you're looking for:
from sqlalchemy import *
from sqlalchemy.orm import *
from sqlalchemy.ext.declarative import declarative_base
Base= declarative_base()
_usergroup_table = Table('usergroup_table', Base.metadata,
Column('user_id', Integer, ForeignKey('user.id')),
Column('group_id', Integer, ForeignKey('group.id')))
class User(Base):
__tablename__ = 'user'
id = Column(Integer, primary_key=True)
name = Column(String(60), nullable=False, unique=True)
group_id = Column(Integer, ForeignKey('group.id'), nullable=False)
primary_group = relationship("Group")
#validates('primary_group')
def _add_pg(self, key, target):
self.groups.add(target)
return target
#validates('groups', include_removes=True)
def _modify_groups(self, key, target, is_remove):
if is_remove and target is self.primary_group:
del self.primary_group
return target
class Group(Base):
__tablename__ = 'group'
id = Column(Integer, primary_key=True)
name = Column(String(60), nullable=False)
users = relationship('User', secondary=_usergroup_table,
backref=backref('groups', collection_class=set))
e = create_engine("sqlite://", echo=True)
Base.metadata.create_all(e)
s = Session(e)
g1, g2, g3 = Group(name='g1'), Group(name='g2'), Group(name='g3')
u1 = User(name='u1', primary_group=g1)
u1.groups.update([g2, g3])
s.add_all([
g1, g2, g3, u1
])
s.commit()
u1.groups.remove(g1)
assert u1.primary_group is None
u1.primary_group = g2
s.commit()
How about a GroupMemberships model to hold the association, instead of _usergroup_table? A user could have many groups through Group Memberships, and a group membership can hold additional attributes, such as whether a given Group is the associated User's primary group.
EDIT
In order to enforce a limit of one primary group per user, I would use a validation in the User model, such that any attempt to assign more (or fewer) than one primary group would result in an error when the record is saved. I am not aware of a way of achieving the same result relying purely on the database's integrity system. There are any number of ways of coding the validation check - the documentation shows a nice approach using the validates() decorator.

How to set the foreign key to a default value on delete?

How to auto set the product category_id to a default value when category is deleted? For example 1 to point to the first category.
class Product(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80))
content = db.Column(db.Text(), unique=True)
category_id = db.Column(db.Integer, db.ForeignKey('category.id'))
atime = db.Column(db.DateTime())
def __init__(self, name, content, category_id):
self.name = name
self.content = content
self.category_id = category_id
self.atime = datetime.now()
def __repr__(self):
return '<Product %r>' % self.id
class Category(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80))
products = db.relationship('Product', backref='category', cascade="all, delete, delete-orphan")
def __init__(self, *args, **kwargs):
if len(kwargs) > 0:
self.name = kwargs['name']
def __repr__(self):
return '<Category %r>' % self.name
I don't want to use cascade to delete them!
There are two things that need to be performed in orchestration here:
Define the Foreign Key with proper referential action
Configure the cascade option of the SA relationship
I think the cleanest way would be to set the category_id to a NULL value when its Category is deleted: SET NULL is one of the possible Referential Actions of the ON DELETE clause, which you can add to your ForeignKey definition:
category_id = db.Column(db.Integer, db.ForeignKey('category.id', ondelete='SET NULL'))
In the same way you can use an option SET DEFAULT, but in this case you need to also configure the default value for the column category_id: category_id = Column(..., server_default=1). Note that implementations of these differ between different RDBMS.
As to the cascade option: You should basically remove the cascade="all, delete, delete-orphan" from your products relationship definition. In fact, you want to ensure that delete, delete-orphan are not there.
Having said that, you really need to test your code to cover different scenarios, as two different deletions of the Category object might produce different results depending on your RDBMS and SA configuration:
# scenario-1: delete in session: SA might set category_id of all chilren Products to None
c1 = session.query(Category).get(1)
session.delete(c1)
session.commit()
# scenario-2: delete without loading an object into the session: SA will perform no additional logic
session.query(Category).filter(Category.id == 2).delete()
session.commit()
Hope all this points you in the right direction. As usual, enabled SQL logging in your test code using echo=True or just by configuring logging module, and you will see what SA is doing to your database. Other changes which which you did not see in the SQL were done by RDBMS itself given your Referential Action.

Categories

Resources