Меня зовут Алексей Казаков, я техлид команды «Клиентские коммуникации» в Домклик. По моему опыту подавляющее большинство приложений, взаимодействующих с базой данных, использовали для этого Object Relational Mapper. В этой статье я продолжу знакомить вас с популярными ORM, которые встречались мне в продовых проектах. В прошлый раз мы рассматривали Django ORM , а сегодня на очереди всемогущий SQLAlchemy.

Будем придерживаться привычного плана:

  • посмотрим на схему данных (сырой SQL);

  • опишем эту схему с помощью alchemy-моделей;

  • познакомимся с несколькими хитростями для удобной отладки;

  • а в основной части изучим примеры запросов.

Особое внимание уделим сырому SQL, который будет генерировать библиотека. Ведь именно от него зависит производительность и количество запросов, которые зачастую определяют эффективность работы всего приложения.

Схема

Схему мы оставляем неизменной на протяжении всех статей. Пусть у нас есть приложение, в котором пользователи (User) могут задавать вопросы (Question) внутри разных тем (Topic). У топика есть картинка (Image). Доступ пользователя к топику определяется через many-to-many отношение TopicUser, которое кроме ForeignKey (FK) для пользователя и FK для топика содержит дополнительную информацию — роль пользователя в топике.

Создать базу данных можно таким SQL-ем:

CREATE TABLE "image"
(
    "id"   serial NOT NULL PRIMARY KEY,
    "name" text   NOT NULL
);

CREATE TABLE "topic"
(
    "id"       serial  NOT NULL PRIMARY KEY,
    "title"    text    NOT NULL,
    "image_id" integer REFERENCES "image" ("id") NOT NULL
);

CREATE TABLE "user"
(
    "id"   serial NOT NULL PRIMARY KEY,
    "name" text   NOT NULL
);

CREATE TABLE "topic_user"
(
    "id"       serial  NOT NULL PRIMARY KEY,
    "role"     text    NOT NULL,
    "topic_id" integer REFERENCES "topic" ("id") NOT NULL,
    "user_id"  integer REFERENCES "user" ("id") NOT NULL
);

CREATE TABLE "question"
(
    "id"       serial  NOT NULL PRIMARY KEY,
    "text"     text    NOT NULL,
    "topic_id" integer REFERENCES "topic" ("id") NOT NULL 
);

Alchemy models

Чтобы создать модели, которые будут представлять SQL-таблицы в нашем Python-коде, предварительно необходимо создать базовый класс для всех моделей. Обычно его называют Base, и все модели приложения наследуют от этого класса. Этот declarative base class содержит справочник всех «своих» таблиц и соответствующих ему классов. Обычно Base один на приложение, его заводят в общем модуле. С помощью разных базовых классов можно организовать подключение к разным базам данных.

Код
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()


class Image(Base):
    __tablename__ = 'image'

    id = sa.Column(sa.Integer, primary_key=True, autoincrement=True)
    name = sa.Column(sa.Text, nullable=False)


class Topic(Base):
    __tablename__ = 'topic'

    id = sa.Column(sa.Integer, primary_key=True, autoincrement=True)
    title = sa.Column(sa.Text, nullable=False)
    image_id = sa.Column(sa.Integer, sa.ForeignKey('image.id'), nullable=False)
    image = sa.orm.relationship(Image)  # innerjoin=True для JOIN
    questions = sa.orm.relationship('Question')

    users = sa.orm.relationship('User', secondary='topic_user')
    # association
    # users = sa.orm.relationship('TopicUser', back_populates='topic')


class User(Base):
    __tablename__ = 'user'

    id = sa.Column(sa.Integer, primary_key=True, autoincrement=True)
    name = sa.Column(sa.Text, nullable=False)

    # association
    # topics = sa.orm.relationship('TopicUser', back_populates='user')


class TopicUser(Base):
    __tablename__ = 'topic_user'

    id = sa.Column(sa.Integer, primary_key=True, autoincrement=True)
    topic_id = sa.Column(sa.Integer, sa.ForeignKey('topic.id'))
    user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'))
    role = sa.Column(sa.Text)

    # association
    # user = sa.orm.relationship(User, back_populates='topics')
    # topic = sa.orm.relationship(Topic, back_populates='users')


class Question(Base):
    __tablename__ = 'question'

    id = sa.Column(sa.Integer, primary_key=True, autoincrement=True)
    text = sa.Column(sa.Text)
    topic_id = sa.Column(sa.Integer, sa.ForeignKey('topic.id'), nullable=False)
    topic = sa.orm.relationship(Topic)  # innerjoin=True для использования JOIN вместо LEFT JOIN

Подготовка

Подключение к базе выполняется с помощью движка, при создании которого необходимо указать строку подключения. Также мы указываем параметр echo=True, который позволит нам видеть все те запросы, которые алхимия будет формировать из нашего Python-кода и выполнять в БД.

Примеры будем запускать в контексте сессий. Благодаря контекстному менеджеру все выполненные в ней операции сессия будет:

  • коммитить, если не было ошибок при исполнении запросов;

  • откатывать, если возникло исключение.

Подробнее про сессии и рекомендации по их использованию можно прочитать здесь.

Код
from contextlib import contextmanager

import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker

main_engine = sa.create_engine(
    'postgres://localhost:5432/habr_sql?sslmode=disable',
    echo=True,
)

DBSession = sessionmaker(
    binds={
        Base: main_engine,
    },
    expire_on_commit=False,
)


@contextmanager
def session_scope():
    """Provides a transactional scope around a series of operations."""
    session = DBSession()
    try:
        yield session
        session.commit()
    except Exception as e:
        session.rollback()
        raise e
    finally:
        session.close()

if __name__ == '__main__':
    with session_scope() as s:
        <actual_code>

Приведенных фрагментов кода достаточно для запуска всех следующих примеров. Напомню, что примеры в статьях сквозные, то есть «Пример n» в любой статье будет про одно и то же.

Пример 1

Хотим получить десять последних вопросов из определенного топика.

SELECT *
FROM question
WHERE topic_id = 1
ORDER BY id DESC
LIMIT 10;

Чтобы добиться такого запроса в алхимии:

questions = s.query(Question).filter(
    Question.topic_id == t1_id,
).order_by(Question.id.desc()).limit(10).all()

Сразу учимся стандартному паттерну фильтрации через обращение к полям класса. В Django иной подход: в filter мы передавали значение для фильтрации по именованному аргументу, совпадающему с названием колонки в базе. Здесь же в результате Question.topic_id == t1_id в функцию передастся экземпляр класса BinaryExpression.

Пример 2

Теперь мы хотим найти такие вопросы из первого топика, в тексте которых либо содержится подстрока fart, либо не содержится упоминания dog, независимо от их регистра. Мы легко изобразим это на SQL:

SELECT *
FROM question
WHERE (
  topic_id = 1 AND (
      text ILIKE '%fart%' OR
      NOT (text ILIKE '%dog%')
  )
);

В алхимии существенное отличие от Django-запроса я вижу лишь в использовании условия «или»:

questions = s.query(Question).filter(
    Question.topic_id == 1,
    sa.or_(Question.text.ilike('%fart%'), ~Question.text.ilike('%dog%'))
)

for q in questions:
    print(q.id)

В консоли увидим следующий SQL-запрос:

SELECT question.id AS question_id, question.text AS question_text, question.topic_id AS question_topic_id 
FROM question 
WHERE question.topic_id = %(topic_id_1)s AND (
  question.text ILIKE %(text_1)s OR question.text NOT ILIKE %(text_2)s
)

Обратите внимание, что запрос исполнится в тот момент, когда мы начнем итерироваться по questions. До этого запросов в базу не уходило, а в questions лежал объект Query, который можно ещё как-то модифицировать, добавив, например, лимит. Похожее мы видели и в Django. Это называется ленивой загрузкой (lazy loading).

Альтернативное написание Python-кода:

questions = s.query(Question).filter(
  (Question.topic_id == 1) &
  (Question.text.ilike('%fart%') | ~Question.text.ilike('%dog%'))
)

...приведет к исполнению идентичного запроса.

Пример 3

Этот пример подводит нас к теме оптимизации запросов. Допустим, мы хотим получить данные не только самого вопроса, но и связанного с ним топика. В алхимии действует схожий принцип. Работа «в лоб»:

q = s.query(Question).filter(Question.id == 1).first()  # sa.3.1
print(q.topic.title)  # sa.3.2

...приведет к тому что, sa.3.1 отправит запрос за вопросом, а sa.3.2 — за топиком. Чтобы отправить в базу только один запрос, нужно воспользоваться опциями:

q = s.query(Question).filter(
    Question.id == 1,
).options(joinedload(Question.topic)).first()
print(q.topic.title)

В алхимии этот подход носит название Eager Loading, вот ссылка на примеры. Опции загрузки можно указывать не только при формировании запроса, но и при описании модели. Но мне больше нравится явно указывать опции загрузки именно при формировании запроса.

Давайте убедимся, что сгенерированный SQL соответствует нашим ожиданиям. Советую делать так при любом запросе, который вы добавляете в приложение. Времени это займёт пару минут, зато придаст вам уверенности и спасёт от большого количества проблем с производительностью.

SELECT question.id       AS question_id,
       question.text     AS quetion_text,
       question.topic_id AS question_topic_id,
       topic_1.id        AS topic_1_id,
       topic_1.title     AS topic_1_title,
       topic_1.image_id  AS topic_1_image_id
FROM question
         LEFT OUTER JOIN topic AS topic_1 ON topic_1.id = question.topic_id
WHERE question.id = %(id_1)s
LIMIT %(param_1)s;

Всё неплохо, ведь мы получим данные топика и не придется повторно ходить в базу. Но LEFT JOIN в нашей схеме данных излишен, потому что не может быть вопроса, не привязанного к топику. Мы можем подсказать алхимии при определении relation-а использовать именно INNER JOIN, который более производителен, чем OUTER:

topic = sa.orm.relationship(Topic, innerjoin=True)

С таким параметром мы получим честный INNER JOIN.

Есть еще одна особенность алхимии. Как я упоминал в разделе «Подготовка», в примерах мы работаем внутри контекста сессии. Но если объект будет вынесен за сессию, то для связанного объекта мы потеряем возможность «подгрузки на лету». Например:

with session_scope() as s:
    q = s.query(Question).filter(Question.id == 1).first()
print(q.topic.title)
# sqlalchemy.orm.exc.DetachedInstanceError: Parent instance <Question at 0x109dde470> is not bound to a Session; lazy load operation of attribute 'topic' cannot proceed

Пример 4

Этот пример углубит наши знания по технике нетерпеливой подгрузки. Допустим, нам нужен не только связанный с вопросом топик, но ещё и изображение этого топика. В алхимии (если говорить о работе внутри сессии) «магия» библиотеки позволяет получить доступ к связанным объектам без дополнительных действий со стороны разработчика. Напомню, что похожую ситуацию мы наблюдали и в Django.

with session_scope() as s:
    for q in s.query(Question).filter(Question.topic_id == 1).all():  # sa.4.1
        print(q.topic.title)  # sa.4.2
        print(q.topic.image.name)  # sa.4.3

Ожидаемо получаем запрос за вопросами в sa.4.1. При итерировании ситуация лучше, чем в Django, потому что объекты после получения из базы кешируются в сессии. Поэтому на первом проходе цикла мы получим два запроса:

  • sa.4.2 — за топиком;

  • sa.4.3 — за изображением.

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

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

with session_scope() as s:
    for q in s.query(Question).options(
        joinedload('topic'),
        joinedload('topic.image'),
    ).filter(Question.topic_id == 1).all():  # sa.4.4
        print(q.topic.title)  # sa.4.5
        print(q.topic.image.name)  # sa.4.6

В этом случае только sa.3.4 спровоцирует запрос в базу.

SELECT question.id       AS question_id,
       question.text     AS question_text,
       question.topic_id AS question_topic_id,
       image_1.id        AS image_1_id,
       image_1.name      AS image_1_name,
       topic_1.id        AS topic_1_id,
       topic_1.title     AS topic_1_title,
       topic_1.image_id  AS topic_1_image_id
FROM question
         JOIN topic AS topic_1 ON topic_1.id = question.topic_id
         LEFT OUTER JOIN image AS image_1 ON image_1.id = topic_1.image_id
WHERE question.topic_id = %(topic_id_1)s

Второй join — LEFT OUTER JOIN. Если нам нужен INNER JOIN, то мы уже знаем, как нужно действовать: image = sa.orm.relationship(Image, innerjoin=True).

Пример 5

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

for t in s.query(Topic).filter(Topic.id.in_([1, 2])).all():  # sa.5.1
    print([q.text for q in t.questions])  # sa.5.2
  • sa.5.1 — запрос за списком топиков;

  • sa.5.2 — N запросов за вопросами на каждом шаге цикла по топикам.

Улучшение снова заключается в использовании опций.

for t in s.query(Topic).options(joinedload('questions')).filter(Topic.id.in_([1, 2])).all():
    print([q.text for q in t.questions])

И получаем только один итоговый запрос, в отличие от Django, где их было два. В чём разница? Здесь один более сложный запрос с LEFT OUTER JOIN, который в итоге приводит к тому же самому.

SELECT topic.id            AS topic_id,
       topic.title         AS topic_title,
       topic.image_id      AS topic_image_id,
       question_1.id       AS question_1_id,
       question_1.text     AS question_1_text,
       question_1.topic_id AS question_1_topic_id
FROM topic
         LEFT OUTER JOIN question AS question_1 ON topic.id = question_1.topic_id
WHERE topic.id IN (%(id_1)s, %(id_2)s)

Контролировать поведение можно с помощью другой опции: subqueryload. Но второй запрос в этом случае будет отличаться от аналогичного запроса в Django. Для определения идентификаторов топиков будет использован первый запрос вместо простого аргумента с этими идентификаторами.

Пример 6

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

В алхимии фильтровать массив вопросов можно с помощью новой опции и алиаса, что в совокупности выглядит не самым тривиальным способом.

q_stmt = s.query(Question).filter(Question.text.ilike('%fart%')).subquery()  # sa.6.1
alias = aliased(Question, q_stmt)  # sa.6.2

filtered_topics = s.query(Topic).outerjoin(  # sa.6.3
    Topic.questions.of_type(alias),  # sa.6.4    
).options(
    contains_eager(Topic.questions.of_type(alias)),  # sa.6.5
)

for t in filtered_topics:
    print(f'{t.title}')
    for q in t.questions:
        print(f' --- {q.text}')
  • sa.6.1: создаем подзапрос, выбирающий интересующие нас вопросы. В базу запрос на этом этапе еще не уходит.

  • sa.6.2: создаем алиас. Они обычно используются для SELF JOIN-а или для замены оригинальной таблицы представляющим её запросом.

  • sa.6.3: явно указываем, что нужно использовать LEFT OUTER JOIN.

  • sa.6.4: за счет of_type мы джойним не с целой таблицей, а с тем подзапросом, который нам нужен.

  • sa.6.5: просим в Topic.questions подгрузить сущности не отдельным запросом, а уже из сформированных нами колонок. Подробнее см. здесь.

Вот подходящий пример из документации:

SELECT anon_1.id       AS anon_1_id,
       anon_1.text     AS anon_1_text,
       anon_1.topic_id AS anon_1_topic_id,
       topic.id        AS topic_id,
       topic.title     AS topic_title,
       topic.image_id  AS topic_image_id
FROM topic
         LEFT OUTER JOIN (
    SELECT question.id AS id, question.text AS text, question.topic_id AS topic_id
    FROM question
    WHERE question.text ILIKE %(text_1)s
) AS anon_1 ON topic.id = anon_1.topic_id

Пример 7

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

В алхимии применяется схожий с Django подход: сначала формируем подзапрос, а потом используем его в основном селекте:

topic_ids = s.query(
    Question.topic_id.label('topic_id'),
).group_by(Question.topic_id).having(
    sa.func.count(Question.id) > 1,
)

topics = s.query(Topic).filter(Topic.id.in_(topic_ids))

for t in topics:
    print(f'{t.title}')

В базу уходит такой же запрос, как в Django:

SELECT topic.id AS topic_id, topic.title AS topic_title, topic.image_id AS topic_image_id 
FROM topic 
WHERE topic.id IN (
  SELECT question.topic_id AS topic_id 
  FROM question GROUP BY question.topic_id 
  HAVING count(question.id) > %(count_1)s
)

Будьте внимательны: при написании этого примера я автоматически добавил .subquery() к формированию topic_ids, а это приводит к лишнему подзапросу:

SELECT topic.id AS topic_id, topic.title AS topic_title, topic.image_id AS topic_image_id
FROM topic
WHERE topic.id IN (
    SELECT anon_1.topic_id
    FROM (
             SELECT question.topic_id AS topic_id
             FROM question
             GROUP BY question.topic_id
             HAVING count(question.id) > %(count_1)s
         ) AS anon_1
)

Не критично, но зачем оставлять ненужный код? Да и вопросов от придирчивого DBA сможете избежать.

Пример 8

Этот пример посвящен обзору common table expression (CTE), которыми частенько пользуешься при написании сырых запросов к PostgreSQL ради читаемости и удобства.

Допустим, мы хотим достать из базы топики, у которых определенных вопросов больше десяти. Сначала формируем CTE, в котором найдем идентификаторы нужных топиков:

topic_ids = s.query(Question.topic_id.label('topic_id')).filter(
    Question.text.ilike('%best%'),
).group_by(Question.topic_id).having(
    sa.func.count(Question.id) > 10,
).cte(name='topic_ids')

Далее используем CTE в основном запросе, чтобы достать соответствующие топики:

topics = s.query(Topic).filter(Topic.id.in_(sa.select([topic_ids.c.topic_id])))
print(', '.join(t.title for t in topics))

Запрос получается такой:

WITH topic_ids AS (
  SELECT question.topic_id AS topic_id 
  FROM question 
  WHERE question.text ILIKE %(text_1)s
  GROUP BY question.topic_id 
  HAVING count(question.id) > %(count_1)s
)
SELECT topic.id AS topic_id, topic.title AS topic_title, topic.image_id AS topic_image_id 
FROM topic 
WHERE topic.id IN (
  SELECT topic_ids.topic_id 
  FROM topic_ids
)

Пример 9

Здесь рассмотрим запросы к many-to-many отношениям. Допустим, мы хотим для каждого топика получить список пользователей, которые имеют к нему доступ.

И снова можно сгенерировать запрос без дополнительных усилий с нашей стороны, библиотека всё делает за нас. Однако за такую помощь приходится расплачиваться. Если не указать опции, то для каждого топика будет отдельный запрос за списком пользователей:

for t in s.query(Topic).all():  # sa.9.1
    names = ', '.join(u.name for u in t.users)   # sa.9.2
    print(f'Topic[{t.title}]: {names}')
-- sa.9.1
SELECT topic.id AS topic_id, topic.title AS topic_title, topic.image_id AS topic_image_id
FROM topic;

-- sa.9.2
SELECT "user".id AS user_id, "user".name AS user_name 
FROM "user", topic_user 
WHERE %(param_1)s = topic_user.topic_id AND "user".id = topic_user.user_id

-- sa.9.2
SELECT "user".id AS user_id, "user".name AS user_name 
FROM "user", topic_user 
WHERE %(param_1)s = topic_user.topic_id AND "user".id = topic_user.user_id

Только вот запрос, порождаемый sa.9.2, будет без INNER JOIN. Там декартово произведение таблиц, из которого условием выбираются нужные строки, и так оставлять нельзя. На помощь снова приходят опции:

for t in s.query(Topic).options(
        joinedload('users'),
).all():
    names = ', '.join(u.name for u in t.users)
    print(f'Topic[{t.title}]: {names}')
SELECT topic.id       AS topic_id,
       topic.title    AS topic_title,
       topic.image_id AS topic_image_id,
       user_1.id      AS user_1_id,
       user_1.name    AS user_1_name
FROM topic
         LEFT OUTER JOIN (topic_user AS topic_user_1 JOIN "user" AS user_1 ON user_1.id = topic_user_1.user_id)
                         ON topic.id = topic_user_1.topic_id

Если в промежуточной таблице содержатся дополнительные данные, к которым мы хотим получать доступ (у нас это поле TopicUser.role), то нужно описывать модели согласно паттерну Association Object. В наших схемах смотри закомментированные строки под комментарием # association. Там же есть пример «обратных» зависимостей: когда мы получаем не коллекцию пользователей топика, а набор топиков, доступных пользователю.

with session_scope() as s:
    u = s.query(User).filter(User.id == 1).first()  # sa.9.3
    for t in u.topics:  # sa.9.4
        print(f'{u.name} is {t.role} in topic "{t.topic.title}"')  # sa.9.5
-- sa.9.3
SELECT "user".id AS user_id, "user".name AS user_name 
FROM "user" 
WHERE "user".id = %(id_1)s

-- sa.9.4
SELECT topic_user.id AS topic_user_id, topic_user.topic_id AS topic_user_topic_id, topic_user.user_id AS topic_user_user_id, topic_user.role AS topic_user_role 
FROM topic_user 
WHERE %(param_1)s = topic_user.user_id

-- sa.9.5
SELECT topic.id AS topic_id, topic.title AS topic_title, topic.image_id AS topic_image_id 
FROM topic 
WHERE topic.id = %(param_1)s

-- sa.9.5
SELECT topic.id AS topic_id, topic.title AS topic_title, topic.image_id AS topic_image_id 
FROM topic 
WHERE topic.id = %(param_1)s

И снова предыдущий запрос и последующий цикл не отличаются оптимальностью, потому что провоцирует лишние запросы к базе. Чтобы их избежать, давайте укажем, что мы заранее знаем, что нам потребуются данные о топиках пользователя.

u = s.query(User).filter(User.id == 1).options(
    joinedload('topics'),
    selectinload('topics.topic'),
).first()

for t in u.topics:
    print(f'{u.name} is {t.role} in topic "{t.topic.title}"')
SELECT anon_1.user_id        AS anon_1_user_id,
       anon_1.user_name      AS anon_1_user_name,
       topic_user_1.id       AS topic_user_1_id,
       topic_user_1.topic_id AS topic_user_1_topic_id,
       topic_user_1.user_id  AS topic_user_1_user_id,
       topic_user_1.role     AS topic_user_1_role
FROM (
         SELECT "user".id AS user_id, "user".name AS user_name
         FROM "user"
         WHERE "user".id = %(id_1)s
         LIMIT %(param_1)s
     ) AS anon_1
         LEFT OUTER JOIN topic_user AS topic_user_1 ON anon_1.user_id = topic_user_1.user_id

SELECT topic.id AS topic_id, topic.title AS topic_title, topic.image_id AS topic_image_id 
FROM topic 
WHERE topic.id IN (%(primary_keys_1)s, %(primary_keys_2)s)

Первый запрос здесь вытягивает пользователя с соответствующими ему строками topic_user, а второй ищет нужные топики. Коллекции же формируются скрытым от нас образом в коде алхимии.

Пример 10

Приступим к другой стороне оптимизации. При вставке большого количества новых записей для улучшения производительности рекомендуется пользоваться bulk-операциями. Они позволяют за один запрос вставить много строк.

s.bulk_save_objects([
    Topic(title=f'topic {i}', image_id=1)
    for i in range(3)
])

В алхимии Python-код очень похож на Django. Но вот SQL выглядит необычно:

INSERT INTO topic (title, image_id) VALUES (%(title)s, %(image_id)s)
({'title': 'topic 0', 'image_id': 1}, {'title': 'topic 1', 'image_id': 1}, {'title': 'topic 2', 'image_id': 1})

Запрос не похож на множественную вставку. Так же и с обновлением, о котором поговорим ниже.

Пример 11.

Другая сторона bulk-операций — это массовое обновление. В алхимии можно не создавать экземпляры моделей, а ограничиться словарями с PK и полями для обновления.

s.bulk_update_mappings(
    User,
    [dict(id=1, name='new 1 name'), dict(id=2, name='new 2 name')]
)
UPDATE "user" SET name=%(name)s WHERE "user".id = %(user_id)s
({'name': 'new 1 name', 'user_id': 1}, {'name': 'new 2 name', 'user_id': 2})

Здесь мы видим в логе совсем другой запрос, и он не похож на множественное обновление. Я не исследовал этот вопрос подробно, но в документации сказано, что используется executemany. Если речь об этом, то большого выигрыша в производительности мы не получим. Но повторюсь, это догадки. Оставим это сведущему комментатору или для следующей статьи.

Заключение

Алхимия мне кажется более сложным в освоении инструментом, чем Django ORM, но и более гибким. Также алхимию можно применять в любой проекте, в то время как ORM от Django недоступен в отрыве от основного фреймворка. Очень хочется пощупать алхимию 2.0 с полноценной поддержкой асинхронщины, но пока не дошли руки.

Надеюсь, вы узнали что-нибудь полезное из этой статьи. В следующий раз окунемся в прекрасную библиотеку go-pg, которую мы используем в каждом проекте на Go, где есть взаимодействие с PostgreSQL. Спасибо за внимание, и не забывайте, что библиотеки могут быть коварны, поэтому всегда проверяйте, что скрывается за красивым кодом в пару строк.

Комментарии (4)


  1. vrnvorona
    12.10.2021 11:49
    +1

    Также алхимию можно применять в любой проекте, в то время как ORM от Django недоступен в отрыве от основного фреймворка.

    Это не правда https://github.com/dancaron/Django-ORM

    Не говоря уже о том, что Django это делает гораздо элегантнее и с встроенной поддержкой миграций.


    1. alexey_and_kazakov Автор
      12.10.2021 15:00
      +1

      Спасибо за наводку. Я натыкался на сторонние библиотеки, но использовать их в prod-е я бы не стал. Жду поддержки отдельно ORM именно от Django-проекта.


      1. vrnvorona
        12.10.2021 15:30
        +1

        Ну это не совсем библиотека, это просто шаблон "как подключить Django и убрать ненужное". Прям отдельно ORM сами Django вряд ли будут делать т.к. в этом нет никакого интереса для них. А для использования в проектах можно и руками это сделать.


  1. santjagocorkez
    12.10.2021 19:20
    +5

    Видимо, придётся написать статью-ответ. Но то, что называется в данной статье "оптимизацией" таковой не является. По крайней мере, не всегда. А для конкретных листингов оптимизацией было бы что-то вроде (что-то вокруг #sa.9.1, #sa.9.2):

    query = sa\
      .select(Topic.id.label('id'), sa.func.array_agg(User.name).label('users'))\
      .where(sa.and_(
        Topic.id == TopicUser.topic_id,
        TopicUser.user_id == User.id
      ))\
      .group_by(sa.literal_column('1'))
    
    with session as s:
      for row in s.execute(query):
        print(f'Topic {row.id}, Users: {", ".join(row.users)}')

    Не надо запрашивать то, что не требуется. Если требуется получить только определенные колонки, не надо просить ORM вытащить модель целиком. Гонять новый запрос на каждую строчку связанной модели - моветон. Если средства СУБД позволяют объединить (или схлопнуть несколько строк в одну без потери данных - позвольте это сделать СУБД. Она, во-первых, скорее всего, сделает это эффективнее, во-вторых, будет меньше накладных расходов на запрос и передачу result tuples.

    Более того, мой запрос прочитает, скорее всего, даже далёкий от Python DBA. А запрос с мутаторами для relationship mapping - не всякий, там уже надо ковырять детали и тонкости конкретного движка (в данном случае - SA).