SQLalchemy在多对多关系上设置约束条件

时间:2018-09-28 08:00:56

标签: python database sqlalchemy many-to-many

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

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

SQLalchemy是否可以自动配置数据库以保持完整性?我不想编写自己的验证器规则,因为我可能会遗漏某些东西,并最终得到破坏我的规则的数据库。

2 个答案:

答案 0 :(得分:4)

问题在于如何表达谓词“由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'仅将单列整数主键视为具有默认的自动递增行为,因此必须对其进行显式设置。

现在还可以根据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'))

使用此模型和以下设置:

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

答案 1 :(得分:2)

如果要通过允许工具名称不唯一来对域进行建模,则没有简单的方法来完成此操作。

您可以尝试向用户模型添加验证器,该验证器将在每次追加过程中检查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必须是唯一的组合< / p>

这是代码

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锤子中的财产,应属于它们之间的关联。

如果采用这种方法,那么会有这样的一组用户

  

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

和一组类似的工具

  

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

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

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

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