Как предотвратить сохранение связанных объектов в sqlalchemy? - PullRequest
1 голос
/ 15 октября 2019

Среда :

  • python 3.7
  • SqlAlchemy 1.3.10 (я также тестировал на 1.2.16, тот же результат)
  • PostgreSQL 11

TL, DR: У меня есть таблица и материализованное представление "1-1" (нет fk, отношение неявно на стороне sql). В SQLAlchemy это отношение объявляется как viewonly=True (на всякий случай тоже backref). Но если я назначу ему, сеанс попытается вставить назначенный объект просмотра матов в любом случае (что, очевидно, не удается, потому что это материализованное представление).

Я неправильно понимаю цель viewonly илиотношения установлены неправильно?

Подробнее (с примерами кода и реальным воспроизводимым контрольным примером):

Начнем с модели данных:

CREATE TABLE universe (
   id SERIAL PRIMARY KEY,
   name VARCHAR NOT NULL,
   is_perfect BOOLEAN NULL DEFAULT 'f'
);

CREATE MATERIALIZED VIEW answer AS (
    SELECT
        id,
        trunc(random() * 100)::INT AS computed
    FROM universe
)

IRL, просмотр матов выполняет гораздо больше работы и извлекает данные из многих таблиц для вычисления результатов. Но для целей этой иллюстрации это подойдет.

Теперь модели:

class Universe(Base):
    __tablename__ = 'universe'

    id = sa.Column(sa.Integer, primary_key=True)
    name = sa.Column(sa.String, nullable=False)
    is_perfect = sa.Column(sa.Boolean, nullable=False, server_default='f')

    answer: Answer = relationship('Answer',
                                  backref=backref('universe', uselist=False, viewonly=True),
                                  innerjoin=False,
                                  uselist=False,
                                  viewonly=True)

    def set_perfect(self):
        """
        You'll have to excuse this method, it is just for illustration purposes.
        I wouldn't code it like this IRL.
        """
        self.is_perfect = (self.answer.computed == 42)

        session = object_session(self)
        if session:
            session.commit()


class Answer(Base):
    __tablename__ = 'answer'

    id = sa.Column(sa.Integer, sa.ForeignKey('universe.id'), primary_key=True)
    computed = sa.Column(sa.Integer, nullable=False)

Класс Universe (таблица) имеет отношение к классу Answer (представление циновки). И отношения, и обратные ссылки: viewonly=True.

Теперь тест:

class UniverseTests(TestCase):
    def test__is_perfect(self):
        session = Session()
        universe: Universe = session.query(Universe).get(1)

        universe.answer = Answer(id=1, computed=42)
        universe.set_perfect()
        assert universe.is_perfect is True

Как видите, во время теста мне «нужно» назначить фальшивку Answer(id=1, computed=42) на universe.answer.

Получение реальных данных из представления mat во время тестов является трудной задачей, поскольку требует создания полной сети объектов, когда большую часть времени я тестирую только простой метод на одном объекте (да, я запускаю свои юнит-тесты противнастоящая база данных ... я знаю, что она нахмурилась, но мне так нравится).

Так что «потребность» на самом деле означает «я не хочу, потому что я ленивый, и я настроен»по-моему ... и издевательства лучше "(я знаю! Лицемерие! ?).

Тестовые ошибки во время функции set_perfect(), потому что вызов session.commit() пытается зафиксировать Answer(id=1, computed=42) объект вместе с обновленным полем объекта Universe.

Кроме того, в качестве дополнительного вопроса: я упускаю более простой, работающий способ сделать это?

Пример сообщения об ошибке

sqlalchemy.exc.ProgrammingError: (psycopg2.ProgrammingError) cannot change materialized view "answer"

[SQL: INSERT INTO answer (id, computed) VALUES (%(id)s, %(computed)s)]
[parameters: {'id': 1, 'computed': 42}]
(Background on this error at: http://sqlalche.me/e/f405)

Наконец, полный пример кода (тот же код, что и выше, но готов к запуску):

  • настройка python 3.7 venv с SQLalchemy 1.3.10
  • создать новый(пусто) база данных postgresql
  • сохранить этот пример кода в файле и изменить значение DB_URI на значение, которое вы только что создали
  • выполнить файл в env (python <path-to-script>)
from __future__ import annotations

import unittest
from unittest import TestCase

import sqlalchemy as sa
from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta
from sqlalchemy.orm import relationship, backref, sessionmaker, object_session

create_sql = """
CREATE TABLE universe (
   id SERIAL PRIMARY KEY,
   name VARCHAR NOT NULL,
   is_perfect BOOLEAN NULL DEFAULT 'f'
);

CREATE MATERIALIZED VIEW answer AS (
    SELECT
        id,
        trunc(random() * 100)::INT AS computed
    FROM universe
)
"""


Base: DeclarativeMeta = declarative_base()
metadata = Base.metadata


class Universe(Base):
    __tablename__ = 'universe'

    id = sa.Column(sa.Integer, primary_key=True)
    name = sa.Column(sa.String, nullable=False)
    is_perfect = sa.Column(sa.Boolean, nullable=False, server_default='f')

    answer: Answer = relationship('Answer',
                                  backref=backref('universe', uselist=False, viewonly=True),
                                  innerjoin=False,
                                  lazy='select',
                                  uselist=False,
                                  viewonly=True)

    def set_perfect(self):
        self.is_perfect = (self.answer.computed == 42)

        session = object_session(self)
        if session:
            session.commit()


class Answer(Base):
    __tablename__ = 'answer'

    id = sa.Column(sa.Integer, sa.ForeignKey('universe.id'), primary_key=True)
    computed = sa.Column(sa.Integer, nullable=False)


DB_URI = 'postgresql://user:pass@localhost:5432/db_name'  # Fill in your own
engine = sa.create_engine(DB_URI)
Session = sessionmaker(bind=engine, expire_on_commit=True)


class UniverseTests(TestCase):
    def setUp(self) -> None:
        session = Session()
        session.execute(create_sql)
        session.execute("INSERT INTO universe (id, name) VALUES (1, 'HG2G');")
        session.commit()
        session.close()

    def tearDown(self) -> None:
        session = Session()
        session.execute("DROP MATERIALIZED VIEW answer")
        session.execute("DROP TABLE universe")
        session.commit()
        session.close()

    def test__is_perfect(self):
        session = Session()
        universe: Universe = session.query(Universe).get(1)

        universe.answer = Answer(id=1, computed=42)
        universe.set_perfect()
        assert universe.is_perfect is True


if __name__ == '__main__':
    unittest.main()

1 Ответ

0 голосов
/ 15 октября 2019

Не совсем решение, но хороший обходной путь для моего сценария использования - позвонить session.expunge(related_object) перед совершением.

Для иллюстрации на примере теста в вопросе:

class UniverseTests(TestCase):
    def test__is_perfect(self):
        session = Session()
        universe: Universe = session.query(Universe).get(1)

        universe.answer = Answer(id=1, computed=42)
        session.expunge(universe.answer)

        universe.set_perfect()
        assert universe.is_perfect is True

Это подтверждает то, что я думал: viewonly=True в отношении не препятствует добавлению sqlalchemy объекта в сеанс, когда он назначен для отношения.

Независимо от того, был ли это дизайн или ошибка,Я не знаю ... Если я буду руководствоваться моим пониманием документации и (что я надеюсь, это) здравым смыслом, я бы ожидал, что объект будет добавлен в сеанс и сохранен в незапятнанном состоянии, так чтофиксация сеанса не вызывает INSERT / UPDATE связанного объекта.

Так что я вижу это как ошибку, но я просто не понимаю цели этой опции. Если кто-нибудь сможет меня просветить, я буду признателен.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...