mixin 的可变列不跟踪突变

Mutable column from mixin does not track mutation

在尝试诊断我遇到的 social-app-flask-sqlalchemy 问题时,我发现 sqlalchemy 的行为有点不直观,我不确定这是预期行为还是错误。

考虑以下片段:

from sqlalchemy import create_engine, Column, Integer, PickleType
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.orm import sessionmaker

Base = declarative_base()

class A(Base):
    __abstract__ = True

class B(Base):
    id = Column(Integer, primary_key=True)
    __tablename__ = 'some_table'
    my_data = Column(MutableDict.as_mutable(PickleType))

class C(A, B):
    pass

engine = create_engine('sqlite://')
session_factory = sessionmaker(bind=engine)
db_session = session_factory()

Base.metadata.create_all(engine)

assert B.my_data.type.__class__ is PickleType

c_instance = C(my_data={'foo': 'bar'})
db_session.add(c_instance)
db_session.commit()
loaded_instance = db_session.query(C).first()
loaded_instance.my_data.update(baz=1)

assert loaded_instance.my_data['baz'] == 1

assert loaded_instance in db_session.dirty

这运行没有问题。现在,如果我们 B 的 superclass 更改为 object,最后的断言就会出错。在此之前,一切正常。

事实证明,通过不直接 B subclass declarative_basemy_data 的类型不再强制为 MutableDict ,但是我们在实例化对象时给出的任何类型(在本例中为 dict)。显然,这也意味着不再跟踪对 my_data 的更改。但是,my_data 仍然使用 PickleType 作为其数据类型,因此效果不会立即可见。

我最初是在 social-app-flask-sqlalchemy 中对 extra_data 的更改未写入数据库时​​偶然发现的。 social-app-flask-sqlalchemy 使用与上图类似的 ORM——包含 extra_data 列的 SQLAlchemyUserMixin class 不是 subclassing declarative_base,但它的子 class UserSocialAuth 通过 _AppSession.

现在我不确定在哪里报告问题,sqlalchemysocial-app-flask-sqlalchemy。有什么想法吗?

你应该declare complex mixin columns such as yours using the declared_attr decorator。将从 mixin 中复制简单的列声明:

To accomplish this, the declarative extension creates a copy of each Column object encountered on a class that is detected as a mixin.

显然这与 mutation tracking 配合不佳。文档对于哪些结构应该使用装饰器可能有点含糊。

This copy mechanism is limited to simple columns that have no foreign keys, as a ForeignKey itself contains references to columns which can’t be properly recreated at this level. For columns that have foreign keys, as well as for the variety of mapper-level constructs that require destination-explicit context, the declared_attr decorator is provided so that patterns common to many classes can be defined as callables

添加了强调,尽管我可能误解了 "destination-explicit context" 在这种情况下的含义。所以这样做:

class B(object):
    ...
    @declared_attr
    def my_data(cls):
        return Column(MutableDict.as_mutable(PickleType))

相反,如果 B 是一个混入 class。失败的简单声明和 declared_attr:

的示例
In [2]: from sqlalchemy.ext.mutable import MutableDict

In [3]: class MixinA:
   ...:     extra = Column(MutableDict.as_mutable(PickleType))
   ...:

In [4]: from sqlalchemy.ext.declarative import declared_attr

In [5]: class MixinB:
   ...:     @declared_attr
   ...:     def extra(cls):
   ...:         return Column(MutableDict.as_mutable(PickleType))
   ...:

In [6]: class A(MixinA, Base):
   ...:     __tablename__ = 'a'
   ...:     id = Column(Integer, primary_key=True, autoincrement=True)
   ...:

In [7]: class B(MixinB, Base):
   ...:     __tablename__ = 'b'
   ...:     id = Column(Integer, primary_key=True, autoincrement=True)
   ...:

In [8]: metadata.create_all()

并在行动:

In [9]: a = A(extra={})

In [10]: b = B(extra={})

In [11]: session.add(a)

In [12]: session.add(b)

In [13]: session.commit()

In [14]: session.query(A.extra).first()
Out[14]: ({})

In [15]: session.query(B.extra).first()
Out[15]: ({})

In [16]: b.extra['b'] = 1

In [17]: session.commit()

In [18]: session.query(B.extra).first()
Out[18]: ({'b': 1})

In [19]: a.extra['a'] = 1

In [20]: session.commit()

In [21]: session.query(A.extra).first()
Out[21]: ({})

In [22]: b.extra['bb'] = 2

In [23]: assert b in session.dirty