sqlalchemy, как генерировать (многие-ко-многим) отношения с automap_base

В качестве фона: я создаю ORM на основе схемы уже существующей базы данных. - Это связано с тем, что приложение python не будет "владельцем" указанной базы данных.

Теперь в этой базе данных есть таблица под названием " task " и таблица под названием " task_notBefore__task_relatedTasks " - эта последняя является отношением "многие ко многим" между разными элементами таблицы " task ".

теперь automap_base() имеет автоматическое обнаружение этих отношений, как описано здесь. Однако это не подходит для моего случая, и никакие отношения не строятся.

Затем я пытаюсь вручную создать отношения:

from sqlalchemy.ext.automap import automap_base
from sqlalchemy.ext.automap import generate_relationship
from sqlalchemy.orm import sessionmaker, interfaces, relationship
from sqlalchemy import create_engine

class DBConnection:
    def __init__(self, connection_url, **kwargs):
        self.engine = create_engine(connection_url, **kwargs)
        self._Base = automap_base()

        self._Base.prepare(self.engine, reflect=True)

        self.Task = self._Base.classes.task
        self.Order = self._Base.classes.order
        self.Poller = self._Base.classes.poller

        rel = generate_relationship(self._Base, interfaces.MANYTOMANY, relationship, 'related', self.Task, self.Task,
                                    secondary=self._Base.classes.task_notBefore__task_relatedTasks, backref='notBefore')

        self._Session = sessionmaker()
        self._Session.configure(bind=self.engine)

        self.session = self._Session()

Однако это все еще не "ничего" делает: оно ничего не добавляет к самому себе. self.Task "класс".

Как это сделать?

Ответ 1

Основная проблема в этом случае - это не только отношения "многие ко многим", но и то, что это самореферентное отношение "многие ко многим". Поскольку automap просто переводит сопоставленные имена классов в имена отношений, он task_collection одно и то же имя, например task_collection, для обоих направлений отношения, а столкновение имен порождает ошибку. Этот недостаток automap чувствует себя значимым в том, что самореференциальные, многие-ко-многим отношения не являются редкостью.

Явное добавление отношений, которые вы хотите, используя свои собственные имена, не решит проблему, потому что automap все равно попытается создать отношения task_collection. Чтобы справиться с этой проблемой, нам необходимо переопределить task_collection.

Если вы поддерживаете имя task_collection для прямого направления отношений, мы можем просто предварительно определить взаимосвязь - указать любое имя, которое мы хотим для backref. Если automap находит ожидаемое свойство уже на месте, он предположит, что отношение переопределено и не пытается его добавить.

Здесь приведенный пример, а также база данных sqlite для тестирования.

База данных Sqlite

CREATE TABLE task (
    id INTEGER, 
    name VARCHAR,
    PRIMARY KEY (id)
);

CREATE TABLE task_task (
    tid1 INTEGER,
    tid2 INTEGER,
    FOREIGN KEY(tid1) REFERENCES task(id),
    FOREIGN KEY(tid2) REFERENCES task(id)
);

-- Some sample data
INSERT INTO task VALUES (0, 'task_0');
INSERT INTO task VALUES (1, 'task_1');
INSERT INTO task VALUES (2, 'task_2');
INSERT INTO task VALUES (3, 'task_3');
INSERT INTO task VALUES (4, 'task_4');

INSERT INTO task_task VALUES (0, 1);
INSERT INTO task_task VALUES (0, 2);

INSERT INTO task_task VALUES (2, 4);
INSERT INTO task_task VALUES (3, 4);

INSERT INTO task_task VALUES (3, 0);

Вводя его в файл setup_self.sql, мы можем сделать:

sqlite3 self.db < setup_self.sql

Код Python

from sqlalchemy.ext.automap import automap_base
from sqlalchemy.orm import Session
from sqlalchemy import create_engine

from sqlalchemy import Table, Column, Integer, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base

DeclBase = declarative_base()

task_task = Table('task_task', DeclBase.metadata,
                  Column('tid1', Integer, ForeignKey('task.id')),
                  Column('tid2', Integer, ForeignKey('task.id')))

Base = automap_base(DeclBase)

class Task(Base):
    __tablename__ = 'task'

    task_collection = relationship('Task', 
                                   secondary=task_task, 
                                   primaryjoin='Task.id==task_task.c.tid1',
                                   secondaryjoin='Task.id==task_task.c.tid2',
                                   backref='backward')

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

Base.prepare(engine, reflect=True)

session = Session(engine)

task_0 = session.query(Task).filter_by(name ='task_0').first()
task_4 = session.query(Task).filter_by(name ='task_4').first()

print("task_0.task_collection = {}".format([x.name for x in task_0.task_collection]))
print("task_4.backward        = {}".format([x.name for x in task_4.backward]))

Результаты

task_0.task_collection = ['task_1', 'task_2']
task_4.backward        = ['task_2', 'task_3']

Использование другого имени

Если вы хотите иметь имя, отличное от task_collection, вам нужно использовать функцию automap для переопределения имен связей коллекции:

name_for_collection_relationship(base, local_cls, referred_cls, constraint)

Аргументы local_cls и referred_cls являются экземплярами отображенных классов таблиц. Для отношений с самореференцией, "многие-ко-многим" они являются одинаковыми. Мы можем использовать аргументы для построения ключа, который позволяет идентифицировать переопределения.

Вот пример реализации этого подхода.

from sqlalchemy.ext.automap import automap_base, name_for_collection_relationship
from sqlalchemy.orm import Session
from sqlalchemy import create_engine

from sqlalchemy import Table, Column, Integer, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base


DeclBase = declarative_base()

task_task = Table('task_task', DeclBase.metadata,
                  Column('tid1', Integer, ForeignKey('task.id')),
                  Column('tid2', Integer, ForeignKey('task.id')))

Base = automap_base(DeclBase)

class Task(Base):
    __tablename__ = 'task'

    forward = relationship('Task', 
                           secondary=task_task, 
                           primaryjoin='Task.id==task_task.c.tid1',
                           secondaryjoin='Task.id==task_task.c.tid2',
                           backref='backward')


# A dictionary that maps relationship keys to a method name
OVERRIDES = {
    'Task_Task' : 'forward'
    }

def _name_for_collection_relationship(base, local_cls, referred_cls, constraint):

    # Build the key
    key = '{}_{}'.format(local_cls.__name__, referred_cls.__name__)

    # Did we have an override name?
    if key in OVERRIDES:
        # Yes, return it
        return OVERRIDES[key]

    # Default to the standard automap function
    return name_for_collection_relationship(base, local_cls, referred_cls, constraint)


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

Base.prepare(engine, reflect=True, name_for_collection_relationship=_name_for_collection_relationship)

Обратите внимание, что переопределение name_for_collection_relationship просто изменяет имя, которое automap использует для отношения. В нашем случае связь по-прежнему определяется Task. Но переопределение говорит automap искать forward а не task_collection, который он находит и, следовательно, прекращает определять отношения.

Другие подходы

В некоторых случаях было бы неплохо, если бы мы могли переопределить имена отношений, не предварительно определяя фактические отношения. При первом рассмотрении это должно быть возможно с помощью name_for_collection_relationship. Однако я не мог заставить этот подход работать для самореферентных отношений "многие ко многим" из-за сочетания двух причин.

  • name_for_collection_relationship и связанное с ним generate_relationship дважды, один раз для каждого направления отношений "многие ко многим". В обоих случаях, local_cls и referred_cls такие же, из - за собственной референциальности. Более того, другие аргументы name_for_collection_relationship фактически эквивалентны. Поэтому мы не можем, из контекста вызова функции, определить, какое направление мы переопределяем.

  • Вот еще более удивительная часть проблемы. Кажется, мы не можем даже рассчитывать на одно направление, происходящее перед другим. Другими словами, два вызова name_for_collection_relationship и generate_relationship очень похожи. Аргументом, который фактически определяет направленность отношения, является constraint, которое является одним из двух ограничений внешнего ключа для отношения; эти ограничения загружаются из Base.metadata в переменную, называемую m2m_const. В этом и заключается проблема. Порядок, в котором ограничения заканчиваются в m2m_const является недетерминированным, т. m2m_const Иногда это будет один порядок; в других случаях это будет наоборот (по крайней мере, при использовании sqlite3). Из-за этого направленность отношения является недетерминированной.

С другой стороны, когда мы предварительно определяем взаимосвязь, следующие аргументы создают необходимый детерминизм.

primaryjoin='Task.id==task_task.c.tid1',
secondaryjoin='Task.id==task_task.c.tid2',

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

Последние мысли

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