简体   繁体   English

SQLalchemy设置多对多关系的约束

[英]SQLalchemy setting constraints on relationships in many-to-many

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 . 所以User.name:Mike1User.name:Mike2可能有权访问Tool.name:Hammer ,并且,另外, User.name:John1User.name:John2可以通过相同的名称访问Tool.name:Hammer但每个都有不同的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 , ie 我想制定一个约束,在User.tools集合中,永远不会有一个与另一个同名的工具 ,即

  • A user cannot create a new Tool as part of his collection if one with that name already exists. 如果已存在具有该名称的用户,则用户无法将新Tool创建为其集合的一部分。 Mike1 cannot create a new tool called Hammer that forms part of his tools collection. Mike1无法创建一个名为Hammer的新工具,它构成了他的tools集合的一部分。
  • 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, ie John1's Hammer cannot be shared with Mike1 since Mike1 already has his own Hammer . 一个Tool是存在于数据库中不能追加到tools ,如果一个具有相同名称的集合,即John1的已经存在的用户的集合Hammer不能Mike1共享。由于Mike1已经拥有了自己的Hammer
  • James , however, can create a new Hammer since he does not already have a hammer. 然而, James可以创造一个新的Hammer因为他还没有锤子。 There will then be 3 tools in the database called Hammer each with a distinct set of Users . 然后,数据库中将有3个工具名为Hammer每个工具都有一组不同的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. 请注意,在我的特定情况下,只有拥有至少一个UserTool才会存在,但我也不知道如何在我的数据库中本地确保这一点。

Is this possible natively with SQLalchemy to automatically configure my database to maintain integrity? SQLalchemy本身是否可以自动配置我的数据库以保持完整性? 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". 问题是如何表达谓词“由ID标识的用户只有一个名为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. user_tool_assoc_table或类似的东西中执行此操作会很好,但由于Tool.name不是Tool的主键的一部分,因此您无法引用它。 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 : 另一方面,由于您确实希望允许多个具有相同名称的工具共存,因此子集{id,name}实际上是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. id现在充当具有相同名称的工具之间的“鉴别器”。 Note that id need not be globally unique in this model, but locally to name . 请注意, id在此模型中不必全局唯一,但在本地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. 使其自动递增仍然很方便,但默认设置autoincrement='auto'仅将单列整数主键视为默认具有自动递增行为,因此必须明确设置。

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来讲tool_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 您可以尝试向User模型添加验证器,它将在每次追加期间检查User.tools列表并确保它符合某个条件

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. 上述方法将确保如果您添加一个与user.tools列表中的现有工具同名的新工具,它将引发异常。 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. 这将起作用,因为validates仅在追加操作期间validates 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. 如果我们想要一个即使在分配期间也能工作的解决方案,那么我们必须找出一个解决方案,其中user_idtool_name将在同一个表中。

We can do this by making the secondary association table have 3 columns user_id , tool_id and tool_name . 我们可以通过使辅助关联表有3列user_idtool_idtool_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 ) 然后我们可以使tool_idtool_name一起表现为Composite Foreign Key (请参阅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 . 通过这种方法,关联表将具有user_id的标准外键,然后是组合tool_idtool_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 既然关联表中存在两个键,我们就可以继续在表上定义一个UniqueConstraint ,这将确保user_idtool_name必须是唯一的组合

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. 所以我们确保在db级别上你的需求得到了解决。

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. 在我看来,最好有一组独特的工具和一组独特的用户,然后建立它们之间的M2M关系。 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. 在这种情况下,您唯一需要做的更改是在Tool.name列上设置unique=True ,这样全局只有一个可以具有该名称的锤子。

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. 如果你需要Mike的锤子来获得与James的Hammer不同的一些独特属性,那么你可以在关联表中添加一些额外的列。 To access user.tools and tool.users, you can use an association_proxy. 要访问user.tools和tool.users,您可以使用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. 将来,当您需要执行会影响所有锤子的操作时,您只需修改Tools表中的锤子实例即可。 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. 如果将所有锤子保持为单独的实例而它们之间没有任何链接,将来对它们进行任何修改将变得很麻烦。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM