Проблема с SQLAlchemy — вложенный объект Marshmallow, выводящий внешний ключ

Я пытаюсь заставить Marshmallow-SQLAlchemy десериализовать объект с вложенным объектом без указания внешнего ключа для вложенного объекта (который должен быть первичным ключом родительского объекта). Вот отдельный пример:

# Python version == 3.8.2
from datetime import datetime
import re

# SQLAlchemy == 1.3.23
from sqlalchemy import func, create_engine, Column, ForeignKey, Text, DateTime
from sqlalchemy.ext.declarative import as_declarative, declared_attr
from sqlalchemy.orm import relationship, sessionmaker

# marshmallow==3.10.0
# marshmallow-sqlalchemy==0.24.2
from marshmallow import fields
from marshmallow.fields import Nested
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema

################################################################################
# Set up
################################################################################

engine = create_engine("sqlite:///test.db")

Session = sessionmaker()
Session.configure(bind=engine)
session = Session()


################################################################################
# Models
################################################################################

@as_declarative()
class Base(object):

    @declared_attr
    def __tablename__(cls):
        # From https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case
        name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', cls.__name__)
        return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()

    @declared_attr
    def updated(cls):
        return Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)


class Account(Base):
    id = Column(Text, primary_key=True)
    name = Column(Text, nullable=False)
    tags = relationship("AccountTag", backref="account")


class AccountTag(Base):
    account_id = Column(Text, ForeignKey('account.id'), primary_key=True)
    Key = Column(Text, primary_key=True)
    Value = Column(Text, nullable=False)


################################################################################
# Schemas
################################################################################

class AutoSchemaWithUpdate(SQLAlchemyAutoSchema):
    class Meta:
        load_instance = True
        sqla_session = session
    updated = fields.DateTime(default=lambda: datetime.now())


class AccountSchema(AutoSchemaWithUpdate):
    class Meta:
        model = Account
        include_relationships = True

    tags = Nested("AccountTagSchema", many=True)


class AccountTagSchema(AutoSchemaWithUpdate):
    class Meta:
        model = AccountTag
        include_fk = True


################################################################################
# Test
################################################################################

Base.metadata.create_all(engine)

account_object = AccountSchema().load({
        "id": "ABC1234567",
        "name": "Account Name",
        "tags": [
            {
                "Value": "Color",
                "Key": "Blue"
            }
        ]
    })

session.merge(account_object)

session.commit()

И вот ошибка, которую я получаю:

Traceback (most recent call last):
  File "example.py", line 88, in <module>
    account_object = AccountSchema().load({
  File "C:\python\site-packages\marshmallow_sqlalchemy\schema\load_instance_mixin.py", line 92, in load
    return super().load(data, **kwargs)
  File "C:\python\site-packages\marshmallow\schema.py", line 727, in load
    return self._do_load(
  File "C:\python\site-packages\marshmallow\schema.py", line 909, in _do_load
    raise exc
marshmallow.exceptions.ValidationError: {'tags': {0: {'account_id': ['Missing data for required field.']}}}

Я чувствую, что пытаюсь сделать что-то интуитивное, но я больше не уверен. Я уверен, что я близко здесь, но мне не повезло заставить это работать. Помощь очень ценится.


person TeejMonster    schedule 06.03.2021    source источник


Ответы (1)


Вы получаете сообщение об ошибке, потому что указали include_fk в классе Meta для AccountTagSchema.

Вы можете проверить поля, созданные для схемы:

print(AccountTagSchema._declared_fields["account_id"])
# <fields.String(default=<marshmallow.missing>, attribute=None, validate=[], required=True, load_only=False, dump_only=False, missing=<marshmallow.missing>, allow_none=False, error_messages={'required': 'Missing data for required field.', 'null': 'Field may not be null.', 'validator_failed': 'Invalid value.', 'invalid': 'Not a valid string.', 'invalid_utf8': 'Not a valid utf-8 string.'})>

Обратите внимание, что он генерирует account_id с required=True, это связано с тем, что столбец sqlalchemy, который он представляет, является NOT NULL, поскольку он является частью первичного ключа.

Итак, самое простое — удалить include_fk из метасхемы:

class AccountTagSchema(AutoSchemaWithUpdate):
    class Meta(AutoSchemaWithUpdate.Meta):
        model = AccountTag
        #  include_fk = True  <--- remove

... однако запустите скрипт, и вы столкнетесь с другой проблемой:

sqlalchemy.orm.exc.UnmappedInstanceError: класс 'builtins.dict' не сопоставлен

Это означает, что в конечном итоге мы передаем сеанс dict в SQLAlchemy, где он ожидает сопоставленный подкласс Base.

Причина этого в том, что когда дочерний класс наследует базовую схему, например, AutoSchemaWithUpdate в данном случае, дочерний класс не наследует автоматически метаконфигурацию родителя. Документы предоставляют несколько стратегий для этого, простейшим из которых является то, что дочерний класс Meta также должен наследоваться от родительского класса Meta:

class AccountSchema(AutoSchemaWithUpdate):
    class Meta(AutoSchemaWithUpdate.Meta):  # <--- this here
        model = Account
        include_relationships = True

    tags = Nested("AccountTagSchema", many=True)

Как только мы сделаем это для AccountSchema и AccountTagSchema, мы будем готовы снова запустить скрипт, и он заработает... с первого раза. Сразу запускаем скрипт снова, и возникает другая ошибка:

AssertionError: правило зависимости пыталось очистить столбец первичного ключа «account_tag.account_id» в экземпляре «‹AccountTag at 0x7f14b0f9b670›»

Это является следствием проектного решения сделать загруженные экземпляры AccountTag неидентифицируемыми (т. е. исключить первичный ключ из полезной нагрузки) и решения включить поле внешнего ключа как часть первичного ключа для AccountTag.

SQLAlchemy не может определить, что вновь созданные экземпляры AccountTag совпадают с уже существующими, поэтому сначала он пытается разъединить исходные теги учетной записи с учетной записью, установив значение поля внешнего ключа в None. Однако это не разрешено, так как внешний ключ также является первичным ключом и не может быть установлен NULL.

Решение для этого описано здесь и включает настройку явный cascade на relationship:

class Account(Base):
    id = Column(Text, primary_key=True)
    name = Column(Text, nullable=False)
    tags = relationship("AccountTag", backref="account", cascade="all,delete-orphan")

Теперь снова запустите скрипт, и он будет работать каждый раз.

person SuperShoot    schedule 06.03.2021
comment
1000x да. Спасибо за ПОДРОБНОЕ объяснение, отличное понимание и (в конечном счете) за то, что помогли мне заставить это работать. Это более информативно/ясно, чем фактические документы, поскольку я думаю, что это крайний случай/непреднамеренное использование модулей. - person TeejMonster; 07.03.2021
comment
Пожалуйста, я согласен с тем, что в некоторых областях документации для этой библиотеки немного мало, но, как всегда с открытым исходным кодом, требуются мотивированные люди со временем, чтобы активизировать и улучшить эти вещи! - person SuperShoot; 08.03.2021