【问题标题】:SQLalchemy setting constraints on relationships in many-to-manySQLalchemy 在多对多关系中设置约束
【发布时间】:2019-03-04 04:35:10
【问题描述】:

假设我有一组用户,每个用户都可以访问一组工具。同一个工具可能有许多具有访问权限的用户,因此这是一种多对多关系:

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')))

注意用户名是唯一的,但工具名不是。所以User.name:Mike1User.name:Mike2 可能有权访问Tool.name:Hammer,并且,分别User.name:John1User.name:John2 可能有权访问Tool.name:Hammer,名称相同但每个Tool.ids 不同。

我想做出一个约束,即在User.tools 集合中永远不能有与另一个工具同名的工具,即

  • 如果已存在同名的Tool,则用户无法创建新的Tool 作为其集合的一部分。 Mike1 无法创建一个名为 Hammer 的新工具,该工具构成他的 tools 集合的一部分。
  • 如果集合中已存在同名用户,则无法将数据库中存在的Tool 附加到用户的tools 集合中,即无法与Mike1 共享John1 的Hammer,因为Mike1 已经拥有他自己的Hammer
  • 但是,James 可以创建一个新的Hammer,因为他还没有锤子。然后,数据库中将有 3 个名为 Hammer 的工具,每个工具都有一组不同的 Users
  • 请注意,在我的具体情况下,Tool 仅在至少有一个 User 时才会存在,但我也不知道如何在我的数据库中本地确保这一点。

这是否可以使用 SQLalchemy 本机自动配置我的数据库以保持完整性?我不想编写自己的验证器规则,因为我可能会错过一些东西并最终得到一个违反我规则的数据库。

【问题讨论】:

    标签: python database sqlalchemy many-to-many


    【解决方案1】:

    问题是如何表达谓词“由ID标识的用户只有一个名称为NAME的工具”。这当然很容易用一个简单的表格来表达,例如:

    db.Table('user_toolname',
             db.Column('user', db.Integer, db.ForeignKey('user.id'), primary_key=True),
             db.Column('toolname', db.String, primary_key=True))
    

    也很清楚,仅此一点不足以维护完整性,因为关于用户工具名的事实与实际工具之间没有任何联系。您的数据库可以声明用户既有锤子又有锤子。

    最好在您的user_tool_assoc_table 或等效项中强制执行此操作,但由于Tool.name 不是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)
    

    id 现在充当同名工具之间的“鉴别器”。请注意,id 在此模型中不必是全局唯一的,而是对于 name 是本地唯一的。让它自动递增仍然很方便,但默认设置 autoincrement='auto' 仅将单列整数主键视为默认具有自动递增行为,因此必须显式设置。

    现在也可以根据tool_name 定义user_tool_assoc_table,附加限制是用户只能拥有一个具有给定名称的工具:

    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'))
    

    使用此模型和以下设置:

    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()
    

    这会成功:

    john.tools.append(hammer1)
    hammer2.users.append(mark)
    db.session.commit()
    

    而这将在上述之后失败,因为它违反了唯一约束:

    john.tools.append(hammer2)
    db.session.commit()
    

    【讨论】:

    • 刚刚注意到您的最后一个限制,即如果没有一个或多个伴随用户,一个工具不应该存在。
    • 最初部分的出色工作,最后的约束有点奢侈,如果可能的话,我仍然很想知道答案。我会试一试,然后回来
    • 我意识到我过度复杂化了这一点(并在此过程中引入了一个异常):事实上,您原来的 Tool 模型的正确键是 (id, name) i>,因此不需要对连接进行映射。关联表基本保持不变。我稍后会更新(重写)这个。
    • 谢谢,我想我知道你在暗示什么,但很高兴看到最终版本以确保。经过我的一些经验,我认为这些东西是有道理的,不幸的是,我在这方面缺乏经验。
    • 我看到的唯一变化是对主键的扩展,并在关联表上添加了特定的约束——这很好。希望我可以按原样实现:但 User 实际上是另一个 BaseClass 的多态子类,它在 id 本身上只有一个主键,所以这将是另一个需要解决的问题。
    【解决方案2】:

    如果您想通过允许工具名称不唯一来对域进行建模,那么就没有简单的方法来实现这一点。

    您可以尝试向 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
    

    上述方法将确保如果您添加一个与user.tools 列表中现有工具同名的新工具,它将引发异常。但问题是你仍然可以像这样直接使用重复工具直接分配一个新列表

    mike.tools = [hammer1, hammer2, knife1]

    这将起作用,因为validates 仅在追加操作期间起作用。不是在分配期间。如果我们想要一个即使在分配期间也有效的解决方案,那么我们必须找出一个解决方案,其中user_idtool_name 将在同一个表中。

    我们可以通过使辅助关联表具有 3 列 user_idtool_idtool_name 来实现这一点。然后我们可以让tool_idtool_name 一起作为Composite Foreign Key 运行(参考https://docs.sqlalchemy.org/en/latest/core/constraints.html#defining-foreign-keys

    通过这种方法,关联表将有一个指向user_id 的标准外键,然后是一个组合了tool_idtool_name 的复合外键约束。现在两个键都在关联表中,我们可以继续在表上定义一个UniqueConstraint,这将确保user_idtool_name 必须是唯一的组合

    这里是代码

    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()
    

    现在让我们试试吧

    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
    

    所以附加同名工具会抛出异常。

    现在让我们尝试分配具有重复工具名称的列表

    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
    

    这也会引发异常。因此,我们已确保在 db 级别解决了您的要求。

    但在我看来,采用这种复杂的方法通常表明我们不必要地使设计复杂化。如果您可以更改表格设计,请考虑以下建议以获得更简单的方法。

    在我看来,最好拥有一组独特的工具和一组独特的用户,然后对它们之间的 M2M 关系进行建模。任何特定于 Mike 的锤子但不存在于 James 的锤子中的属性都应该是它们之间关联的属性。

    如果你采用这种方法,你就会拥有一组这样的用户

    迈克、詹姆斯、约翰、乔治

    还有一套这样的工具

    锤子、螺丝刀、楔子、剪刀、刀

    您仍然可以对它们之间的多对多关系进行建模。在这种情况下,您唯一需要做的更改是在Tool.name 列上设置unique=True,以便全局只有一个锤子可以使用该名称。

    如果您需要 Mike 的锤子具有与 James 的锤子不同的一些独特属性,那么您可以在关联表中添加一些额外的列。要访问 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")
    

    由于适当的关注点分离,上述方法更好。以后当你需要做一些会影响所有锤子的事情时,你可以在工具表中修改锤子实例。如果您将所有锤子作为单独的实例,它们之间没有任何链接,那么将来对它们进行任何整体修改都会变得很麻烦。

    【讨论】:

    • 复合外键不应该引用一个键吗?不过,在问题的那一部分中,Tool(id, name) 不是关键。将独特的关联属性移动到关联表的要点非常好。
    • 我同意它是令人费解的,但约束是一个相当具体的情况,改变会产生其他更主要的问题(从我的用户工具类比中不明显)我非常感谢答案。跨度>
    • @IljaEverilä 我同意复合外键应该理想地建模为外键约束映射到外表上的 2 个独立主键。在这种情况下 Tool.name 不是主键列,但仍被用作外键约束的一部分。我对此感到疑惑,因此尝试了各种操作,以查看解决方案是否按预期工作。它似乎有效(如上述答案中的控制台输出所示)。于是贴出来。但是,是的,我同意这种方法可能会有一些副作用。我不确定它是什么。
    猜你喜欢
    • 2021-07-10
    • 2015-02-27
    • 2013-03-12
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-01-19
    相关资源
    最近更新 更多