SQLAlchemy:用新的子行更新版本化的父行会孤立旧的子行
Posted
技术标签:
【中文标题】SQLAlchemy:用新的子行更新版本化的父行会孤立旧的子行【英文标题】:SQLAlchemy: Updating a versioned parent row with new child rows orphans the old child rows 【发布时间】:2020-10-22 15:53:48 【问题描述】:我在 SQLAlchemy 1.3.19(postgresql 12.3,python 3.8.5)中按照here 的描述创建了一个版本化表:使用时间行,版本化表中的每条记录都有一个由指定的主键id
和 version_id
,后者在每次更新期间递增 1
。
版本化的类表,称为Parent
,与另一个类表具有一对多关系,称为Child
,它通过parent_id
指定的外键链接到给定的父级和parent_version_id
。
我在使用我想要的用例时遇到问题
创建新的子代,并将它们分配给父代(并碰撞父代的version_id
),但是
让旧的子级仍然引用旧版本的父级。
相反,老孩子成为孤儿,这违反了我对他们的parent_id
和parent_version_id
列的非空要求。从概念上讲:
^ desired behavior actual behavior on session.commit() of v2
|
| parent v2 ___child 4 parent v2 ___child 4
| \__child 3 \__child 3
|
|
| parent v1 ___child 2 parent v1 child 2 (orphaned)
| \__child 1 child 1 (orphaned)
这是我的代码的一个提炼示例。我暂时禁用了任何 before_flush
事件挂钩,以便尽可能手动测试:
from sqlalchemy import (
Column,
create_engine,
ForeignKeyConstraint,
Integer,
PrimaryKeyConstraint,
UniqueConstraint,
)
from sqlalchemy.orm import (
make_transient,
relationship,
Session,
sessionmaker,
)
Base = declarative_base()
class Parent(Base):
__tablename__ = "parents"
id = Column(Integer, autoincrement=True)
version_id = Column(Integer, default=1)
__table_args__ = (
UniqueConstraint('id', 'version_id', name='_id_version_id_uc'),
PrimaryKeyConstraint('id', 'version_id', name='versioned_pk'),
)
children = relationship("Child", back_populates="parent")
class Child(Base):
__tablename__ = "children"
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, nullable=False, index=True)
parent_version_id = Column(Integer, nullable=False, index=True)
__table_args__ = (
ForeignKeyConstraint(
["parent_id", "parent_version_id"], ["parents.id", "parents.version_id"]
),
)
parent = relationship(
"Parent",
back_populates="children",
foreign_keys=[parent_id, parent_version_id],
)
engine = create_engine("<db-url>")
SessionMaker = sessionmaker(bind=engine)
session = SessionMaker()
# create parent and v1 children, commit
parent = Parent()
child1 = Child(parent=parent)
child2 = Child(parent=parent)
session.add(parent)
session.commit()
parent.children # correctly shows [child1, child2]
# update parent with new (v2) children, commit
make_transient(parent)
parent.version_id += 1 # parent.version_id is now 2
child3 = Child(parent=parent)
child4 = Child(parent=parent)
session.add(parent)
session.commit()
当我调用session.commit()
时,我得到以下IntegrityError
异常:
IntegrityError: (psycopg2.errors.NotNullViolation) null value in column "parent_id" violates not-null constraint
DETAIL: Failing row contains (1, null, null).
[SQL: UPDATE children SET parent_id=%(parent_id)s, parent_version_id=%(parent_version_id)s WHERE children.id = %(children_id)s]
[parameters: ('parent_id': None, 'parent_version_id': None, 'children_id': 1, 'parent_id': None, 'parent_version_id': None, 'children_id': 2)]
(Background on this error at: http://sqlalche.me/e/13/gkpj)
我尝试更明确地说明原始 children
与 v1 父级的关系,例如与:
...
make_transient(parent)
with session.no_autoflush:
old_parent = session.query(Parent).filter_by(id=parent.id, version_id=parent.version_id).one()
child1.parent = old_parent
child2.parent = old_parent
session.commit()
parent.version_id += 1
...
这通过这个新的、中间的session.commit()
起作用。但是当我尝试提交原始的、更新的parent
及其新子代时,我得到了与上面相同的IntegrityError
。
实现预期行为的正确方法是什么?理想情况下,我还想在before_flush
事件中更不可知地实现这一点,而不是我在这里的做法。
谢谢!
更新:如果我将 Child
中的引用列设为可空
parent_id = Column(Integer, nullable=True, index=True)
parent_version_id = Column(Integer, nullable=True, index=True)
那么,如果没有IntegrityError
投诉,以下内容当然可以工作:
...
make_transient(parent)
parent.version_id += 1 # parent.version_id is now 2
child3 = Child(parent=parent)
child4 = Child(parent=parent)
session.add(parent)
session.commit() # child1 and child2 are now orphaned, but that is allowed
# re-assign child1 and child2 to the old parent version
old_parent = session.query(Parent).filter_by(id=parent.id, version_id=parent.version_id - 1).one()
old_parent.children = [child1, child2]
session.commit()
但这是不理想的;我仍然想在 before_flush
等事件中实现版本碰撞。
【问题讨论】:
【参考方案1】:好吧,我想我解决了这个问题。答案在我作为参考发布的original link 中,在一个类似的例子中。
在运行make_transient(parent)
之前,我们需要通过session.expire(parent, ['children'])
使父子关系失效:
# update parent with new (v2) children, commit
session.expire(parent, ['children']) # expires the relationship
make_transient(parent)
parent.version_id += 1 # parent.version_id is now 2
child3 = Child(parent=parent)
child4 = Child(parent=parent)
session.add(parent)
session.commit()
有了以上内容,child1
和child2
对parent
v1 的引用保持不变。
【讨论】:
以上是关于SQLAlchemy:用新的子行更新版本化的父行会孤立旧的子行的主要内容,如果未能解决你的问题,请参考以下文章