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 的描述创建了一个版本化表:使用时间行,版本化表中的每条记录都有一个由指定的主键idversion_id,后者在每次更新期间递增 1

版本化的类表,称为Parent,与另一个类表具有一对多关系,称为Child,它通过parent_id 指定的外键链接到给定的父级和parent_version_id

我在使用我想要的用例时遇到问题

创建新的子代,并将它们分配给父代(并碰撞父代的version_id),但是 让旧的子级仍然引用旧版本的父级。

相反,老孩子成为孤儿,这违反了我对他们的parent_idparent_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()

有了以上内容,child1child2parent v1 的引用保持不变。

【讨论】:

以上是关于SQLAlchemy:用新的子行更新版本化的父行会孤立旧的子行的主要内容,如果未能解决你的问题,请参考以下文章

根据之前删除的子行删除父行

选择所有子行都符合条件的父行

R中的父/子行

R闪亮包中的父/子行

C# SQL Row 删除带有子行的父级

核心D0M-Node:节点对象