SQLalchemy如何在多对多关系中设置约束条件

5
假设我有一组用户,每个用户都可以访问一组工具。同一种工具可能会被多个用户访问,因此这是一种多对多的关系:
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.ids 访问名为 Tool.name:Hammer 的工具。 我希望在 User.tools 集合中加入一个限制,即不能有两个同名的工具,即:
  • 如果已经存在一个同名的工具,则用户无法将新的 Tool 添加到他的集合中。例如,Mike1 无法创建一个名为 Hammer 的新工具,将其作为他的 tools 集合的一部分。
  • 如果集合中已经有一个同名的工具,则数据库中存在的 Tool 不能被添加到用户的 tools 集合中。例如,John1 的 Hammer 不能与 Mike1 共享,因为 Mike1 已经有了自己的 Hammer
  • 然而,James 可以创建一个新的 Hammer,因为他还没有一个同名的工具。那么,数据库中将有三个名为 Hammer 的工具,每个工具都有一个不同的 Users 集合。
  • 请注意,在我的特定情况下,只有在 Tool 至少有一个 User 时才会存在,但我也不知道如何在数据库中本地实现这一点。
是否可以使用 SQLalchemy 在数据库中自动配置以保持完整性?我不想编写自己的验证规则,因为我可能会忽略某些内容,最终导致违反规则的数据库。
2个回答

7
问题在于如何表达谓词“一个由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()

刚注意到你的最后一个限制条件,即工具不应该没有相应的用户或用户存在。 - Ilja Everilä
初始部分做得非常出色,最后一个限制条件有点奢侈,但如果可能的话,我仍然很想知道答案。我会尝试一下并回来。 - Attack68
我意识到我过于复杂化了这个问题(并在此过程中引入了异常):实际上,您原始的“Tool”模型的正确键是(id,name),因此在连接上进行映射是不必要的。关联表基本保持不变。稍后我会更新(重写)一下。 - Ilja Everilä
谢谢,我知道你在暗示什么,但看到最终版本会更好,以确保没有问题。我认为这些东西需要一些经验才能理解,而我在这个领域中缺乏经验,很遗憾。 - Attack68
我看到的唯一变化是对主键的扩展,并在关联表上添加特定约束 - 这很好。希望我可以按原样实现这个:但是User实际上是另一个BaseClass的多态子类,该类仅具有id本身的一个主键,因此这将是另一个要解决的问题。 - Attack68

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共同行为为组合外键 (请参阅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

这也会抛出异常。因此,我们已经在数据库层面上确保了您的需求得到解决。

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

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

如果您采取这种方法,您将拥有以下用户集合

Mike,James,John,George

和以下工具集合

锤子,螺丝刀,楔子,剪刀,刀子

并且您仍然可以在它们之间建立多对多的关系。在这种情况下,您唯一需要做的更改是在Tool.name列上设置unique=True,以便全局只有一个名为“锤子”的工具。

如果您需要让Mike的锤子具有与James的锤子不同的一些独特属性,那么您只需在关联表中添加一些额外的列。要访问用户.工具和工具.用户,您可以使用一个关联代理。

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) 不是一个键。将唯一于关联的属性移动到关联表上是个绝妙的点子。 - Ilja Everilä
我同意这很复杂,但是这个限制条件是一个相当特殊的情况,改变它会产生其他更严重的问题(这些问题从我的用户工具类比中并不明显)。非常感谢您的答案。 - Attack68
@IljaEverilä 我同意,复合外键最好建模为一个外键约束,映射到外部表上的两个独立主键。在这种情况下,Tool.name不是一个主键列,但仍然被用作外键约束的一部分。我对此感到困惑,因此尝试了各种操作,以查看解决方案是否按预期工作。它似乎可以正常工作(如上面答案中的控制台输出所示)。因此发布了它。 但是,是的,我同意这种方法可能会有一些副作用。虽然我不确定是什么。 - suryasankar

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接