Команда Python for Devs подготовила перевод статьи о том, почему автору SQLAlchemy нравится… но не настолько, чтобы не попробовать создать собственный ORM. SQLORM ― минималистичный, прямолинейный и честный: никакой магии, никаких скрытых Unit of Work, максимум контроля над SQL и минимум связности с сессией.


На моей основной работе я использую SQLAlchemy как ORM. Это потрясающий проект — мощный и гибкий. Но при этом мне всегда казалось, что некоторые архитектурные решения не совсем подходят под то, как я люблю работать с ORM. В частности:

  • Мне не особенно нравится паттерн Unit of Work и то, что вы не управляете моментом выполнения DML-запросов. Я предпочитаю, чтобы запросы выполнялись сразу, как только вызываются в коде.

  • Я не хочу, чтобы мои объекты были «привязаны» к сессии или конкретной базе данных. Я хочу иметь возможность выбрать данные из одной базы и вставить в другую, используя тот же объект.

  • В большинстве случаев мне нужны простые объекты, которые отображают строку таблицы.

  • Я хочу писать SQL вручную для сложных запросов. Мне не нужен query builder или DSL — я предпочитаю писать обычный SQL.

  • Меня не интересует абстракция поверх разных СУБД. Обычно я выбираю сервер базы данных в начале проекта и оптимизирую работу под него.

  • Оставаться как можно ближе к уровню DB-API.

С этими идеями в голове и родился SQLORM. (Да, название так себе — я ужасно придумываю имена для подобных проектов.) Он вдохновлён множеством существующих ORM, но при этом привносит несколько собственных особенностей.

(К слову, я знаю, что в экосистеме Python есть много других ORM, но для меня SQLAlchemy остаётся лучшей. Мне не нравится API или кодовая база остальных.)

Главная особенность SQLORM в том, что SQL находится в центре всего. Вы можете создавать SQL-запросы как обычные функции Python, используя докстринг, чтобы писать в нём шаблон SQL-запроса:

from sqlorm import sqlfunc

@sqlfunc
def tasks_completion_report(start_date, end_date):
    """SELECT done_at, COUNT(*) count
       FROM tasks
       WHERE done_at >= %(start_date)s AND done_at <= %(end_date)s
       GROUP BY done_at"""

В этом примере start_date и end_date — это параметры, и они будут корректно экранированы. Выполнение функции запускает SQL-запрос в рамках активной транзакции.

Подключения и транзакции используются через контекстные менеджеры. Класс Engine управляет подключениями DB-API.

from sqlorm import Engine
import datetime

engine = Engine.from_uri("sqlite://app.db")

with engine:
    report = tasks_completion_report(datetime.date(2025, 1, 1), datetime.date.today())

SQLORM предоставляет множество утилит, которые помогают строить SQL-выражения, а также выбирать связанные строки одним запросом.

По умолчанию строки возвращаются как словари, но при желании можно «наполнять» объекты:

class Task:
    pass

@sqlfunc(model=Task)
def find_tasks():
    "SELECT * FROM tasks"

with engine:
    tasks = find_tasks()

Теперь нам не хочется вручную писать бесконечные простые выражения, чтобы заново собрать базовые возможности CRUD, поэтому в SQLORM есть класс Model. Он следует паттерну Active Record.

from sqlorm import Model

class Task(Model):
    pass

with engine:
    tasks = Task.find_all()

    task = Task.create(title="my task")

    task = Task.find_one(id=1)
    task.done = True
    task.save()

Разумеется, у классов моделей тоже могут быть SQL-методы!

class Task(Model):
    @classmethod
    def find_todos(cls):
        "SELECT * FROM tasks WHERE not done"

    def toggle(self):
        "UPDATE tasks SET done = not done WHERE id = %(self.id)s"

with engine:
    tasks = Task.find_todos()
    task = next(tasks)
    task.toggle()

Как вы уже заметили, классам моделей не нужно заранее знать список колонок. Однако полезно всё же определить их — для автодополнения, проверки типов и генерации DDL-выражений. SQLORM позволяет сделать это с помощью аннотаций Python:

from sqlorm import PrimaryKey

class Task(Model):
    id: PrimaryKey[int]
    title: str
    done: bool

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

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

Реализовать чтение из реплики и запись в primary можно в несколько строк:

main = Engine.from_uri("postgresql://main")
replica = Engine.from_uri("postgresql://replica")

with replica:
    task = Task.get(1)
    if not task.done:
        with main:
            task.toggle()

У SQLORM есть ещё много мощных возможностей. Он хорошо документирован и предоставляет интеграцию с Flask. Попробуйте!

Русскоязычное сообщество про Python

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