Основы SQLAlchemy

Учим Цыплёнка хранить пользователей не строками SQL, а обычными объектами Python — через библиотеку SQLAlchemy.
ORM (SQLAlchemy) — библиотека, которая позволяет работать с базой данных через объекты Python вместо ручного написания SQL.

Зачем менять рабочий SQL на что-то новое

В прошлом уроке мы научили Цыплёнка запоминать пользователей в SQLite и руками писали запросы: SQLite для бота. Всё работало, и это здорово. Но давай честно вспомним, как выглядел тот код. Чтобы добавить пользователя, ты писал длинную строку INSERT INTO ..., аккуратно расставлял знаки вопроса, передавал кортеж параметров, не забывал про commit(). А чтобы достать пользователя — снова строка SELECT ..., потом fetchone(), и в ответ прилетал безымянный кортеж вроде (42, 'Саша', 15), где надо помнить, что имя лежит во втором элементе, а возраст в третьем.

Проблема даже не в том, что это многословно. Проблема в том, что SQL живёт у тебя внутри строк. Python не видит, что там написано. Опечатался в названии колонки — узнаешь только когда бот рухнет в чате у живого человека. Захотел добавить новое поле — лезешь и в SQL создания таблицы, и в каждый INSERT, и в каждый SELECT, и руками считаешь, под каким номером теперь лежит нужное значение в кортеже. Когда у бота три таблицы и двадцать запросов, такой код превращается в минное поле.

ORM решает это так: ты описываешь, как выглядит твой пользователь, один раз в виде обычного класса Python — а библиотека сама превращает объекты этого класса в строки таблицы и обратно. Вот к чему мы придём:

Было (ручной SQL):
    cur.execute("INSERT INTO users (tg_id, name, age) VALUES (?, ?, ?)",
                (message.from_user.id, "Саша", 15))
    conn.commit()

Станет (SQLAlchemy):
    user = User(tg_id=message.from_user.id, name="Саша", age=15)
    session.add(user)
    session.commit()

Результат: в чате поведение бота не меняется — он по-прежнему запоминает пользователя. Но в коде вместо строки с SQL у нас обычный объект user с понятными полями user.name и user.age. Python теперь видит структуру и подскажет, если ты ошибёшься.

Метафора: ORM как переводчик между двумя мирами

Представь, что ты переписываешься с другом, который говорит только по-английски, а ты — только по-русски. Каждый раз приходится самому лезть в словарь, подбирать слова, бояться напутать с грамматикой. Это и есть ручной SQL: ты сам переводишь свои мысли на «язык базы данных».

А теперь представь, что между вами сел живой переводчик. Ты говоришь по-русски как привык — он мгновенно переводит на английский и обратно. Тебе вообще не нужно знать чужой язык: думаешь на родном, а собеседник тебя понимает. Вот этот переводчик и есть ORM — Object-Relational Mapping, «отображение объектов на таблицы». SQLAlchemy — самый популярный такой переводчик в мире Python.

Два мира, между которыми он переводит, такие:

  • Мир объектов — привычный тебе Python: классы, экземпляры, атрибуты вроде user.name.
  • Мир таблиц — база данных: строки, колонки, тот самый SQL.

ORM держит между ними чёткое соответствие: класс — это таблица, экземпляр класса (объект) — это одна строка таблицы, атрибут объекта — это колонка. Создал объект User — значит, появилась строка. Прочитал user.age — значит, заглянул в колонку age. SQL при этом никуда не девается, просто его за тебя пишет переводчик.

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

Описываем модель пользователя

Начнём с главного — опишем, как выглядит наш пользователь. В SQLAlchemy 2.x современный способ — унаследовать класс от базового DeclarativeBase и перечислить колонки через mapped_column. Звучит страшно, но смотри, как просто:

from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    tg_id: Mapped[int] = mapped_column(unique=True)
    name: Mapped[str] = mapped_column(String(64))
    age: Mapped[int]

Результат: мы описали таблицу users, не написав ни строчки SQL. SQLAlchemy теперь знает: у пользователя есть числовой id (первичный ключ — уникальный номер строки), его Telegram-id (tg_id, уникальный, чтобы один человек не записался дважды), текстовое имя и числовой возраст.

Разберём по частям, чтобы ничего не осталось загадкой:

  • class Base(DeclarativeBase) — общий «предок» для всех наших моделей. От него мы наследуем каждую таблицу. Он нужен SQLAlchemy, чтобы собрать список всех моделей в одном месте.
  • __tablename__ = "users" — как будет называться таблица в самой базе. Это та же таблица, что мы создавали руками через CREATE TABLE users в прошлом уроке.
  • id: Mapped[int] — запись Mapped[int] говорит: «эта колонка хранит целое число». Аналогично Mapped[str] — текст. Это обычные подсказки типов Python, которые SQLAlchemy читает и превращает в типы колонок.
  • primary_key=True — помечает id главным ключом строки. База сама будет проставлять сюда новые номера: 1, 2, 3...
  • unique=True у tg_id — запрет на повторы: дважды один и тот же Telegram-аккаунт в таблицу не влезет.
  • String(64) — текст длиной до 64 символов. Если просто написать Mapped[str] без mapped_column (как у age с int), SQLAlchemy подберёт тип сам — для учёбы этого хватает.

Сравни с тем, что было раньше: вместо CREATE TABLE users (id INTEGER PRIMARY KEY, tg_id INTEGER UNIQUE, name TEXT, age INTEGER) у нас теперь обычный класс Python, который вдобавок умеет создавать объекты-пользователей. Один источник правды вместо разбросанного по строкам SQL.

Подключаемся к базе: движок и сессия

Класс описан, но переводчику нужно знать, к какой базе подключаться и через что с ней разговаривать. За это отвечают две вещи: движок (engine) и сессия (session).

Движок — это «розетка», подключение к конкретному файлу базы. Создаём его один раз при старте бота. Сессия — это твой рабочий стол на время одной задачи: ты раскладываешь на нём объекты, что-то меняешь, а потом одной командой говоришь «сохрани всё, что я тут наработал».

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

engine = create_engine("sqlite:///bot.db")
Base.metadata.create_all(engine)   # создаст таблицы, если их ещё нет

Session = sessionmaker(engine)

Результат: при запуске бот открывает (или создаёт) файл bot.db — тот же самый файл базы, что и в прошлом уроке. Строка Base.metadata.create_all(engine) просит SQLAlchemy создать все описанные таблицы, если их ещё нет. Заметь: sqlite:///bot.db — это адрес базы; для SQLite это просто путь к файлу. sessionmaker(engine) готовит «фабрику» сессий: дальше мы будем брать у неё свежий рабочий стол под каждую задачу.

Три главные операции: add, get, commit

Теперь самое интересное — как работать через сессию. Запомни три глагола, и 90% работы с базой у тебя в кармане: add (положить новый объект), get (достать по ключу), commit (сохранить изменения насовсем).

Пример 1. Добавляем пользователя через add и commit

Допустим, новый человек написал Цыплёнку /start. Сохраним его:

from aiogram.filters import CommandStart
from aiogram.types import Message

@router.message(CommandStart())
async def on_start(message: Message):
    with Session() as session:
        user = User(tg_id=message.from_user.id,
                    name=message.from_user.first_name,
                    age=0)
        session.add(user)
        session.commit()
    await message.answer("Привет! Я тебя запомнил \u2728")

Результат: в чате на /start Цыплёнок отвечает «Привет! Я тебя запомнил», а в базе появляется новая строка с Telegram-id и именем пользователя. Поле age пока ставим в 0 — заполним позже через анкету.

Разберём пошагово:

  • with Session() as session: — берём свежий рабочий стол. Конструкция with гарантирует, что стол аккуратно уберут (сессия закроется), даже если что-то пойдёт не так.
  • user = User(...) — создаём объект-пользователя, как любой обычный объект Python. Поля передаём по именам — никаких знаков вопроса и кортежей.
  • session.add(user) — кладём объект на рабочий стол. Важно: в базе его пока нет! Мы только пометили «этого надо сохранить».
  • session.commit() — говорим «сохрани всё со стола в базу». Вот теперь SQLAlchemy генерирует INSERT и выполняет его. Без commit() запись пропадёт — ровно как было с ручным SQL.

Пример 2. Достаём пользователя через get

Когда человек снова пишет боту, нам нужно найти его в базе по Telegram-id. Метод session.get ищет строку по первичному ключу мгновенно:

from sqlalchemy import select

@router.message(Command("profile"))
async def show_profile(message: Message):
    with Session() as session:
        user = session.scalar(
            select(User).where(User.tg_id == message.from_user.id)
        )
        if user is None:
            await message.answer("Я тебя пока не знаю, напиши /start")
            return
        await message.answer(f"Тебя зовут {user.name}, возраст: {user.age}")

Результат: в чате на команду /profile Цыплёнок находит пользователя и отвечает, например: «Тебя зовут Саша, возраст: 15». Если человека в базе нет — вежливо просит сначала написать /start.

Тут важная деталь. Метод session.get(User, 5) ищет строго по первичному ключу (по нашему id). Но мы знаем не id, а tg_id — поэтому используем запрос select(User).where(...) и достаём результат через session.scalar(...). scalar вернёт либо один объект User, либо None, если никто не подошёл. И смотри, какая красота: вместо разбора кортежа (42, 'Саша', 15) мы просто пишем user.name и user.age — понятно с первого взгляда.

Пример 3. Меняем данные — и снова commit

Объект, полученный из сессии, «живой»: меняешь его атрибут — и SQLAlchemy запоминает, что строку надо обновить. Допустим, пользователь прошёл анкету и сообщил возраст:

@router.message(Command("age15"))
async def set_age(message: Message):
    with Session() as session:
        user = session.scalar(
            select(User).where(User.tg_id == message.from_user.id)
        )
        if user is not None:
            user.age = 15
            session.commit()
            await message.answer("Запомнил, тебе 15!")

Результат: в чате Цыплёнок находит пользователя, меняет ему возраст на 15 и сохраняет. Заметь — мы нигде не писали UPDATE! Достаточно изменить user.age и вызвать commit(): переводчик сам поймёт, что строку нужно обновить, и сгенерирует UPDATE за нас.

Как это выглядит без базы — на чистом Python

Чтобы прочувствовать идею «объект = строка», вот крошечный сниппет на стандартной библиотеке. Он не лезет в базу — просто показывает ту же логику «храним пользователей как объекты и обращаемся по именам полей»:

class User:
    def __init__(self, tg_id, name, age):
        self.tg_id = tg_id
        self.name = name
        self.age = age

# "таблица" — это просто список объектов
users = []
users.append(User(101, "Саша", 15))
users.append(User(202, "Маша", 14))

# "select по tg_id" — обычный поиск в списке
naydeno = next((u for u in users if u.tg_id == 202), None)
print("Имя:", naydeno.name)
print("Возраст:", naydeno.age)

Вывод:

Имя: Маша
Возраст: 14

SQLAlchemy делает почти то же самое, только «список» у неё — это настоящая таблица в файле, которая переживёт перезапуск бота. А обращение к полям через u.name и u.age — один в один.

Частые ошибки и подводные камни

ORM убирает много старых граблей, но подкидывает пару новых. Разберём те, на которые наступают чаще всего.

1. Забыл commit()

Самая частая беда — ровно как с ручным SQL. Ты сделал session.add(user) или изменил user.age, бот ответил «сохранил!», а после перезапуска данных нет. Причина: без session.commit() изменения живут только на «рабочем столе» и не доезжают до файла базы. Правило простое: что-то поменял в данных — вызови commit().

2. Перепутал add и commit

Новички иногда думают, что add уже сохраняет. Нет: add только кладёт объект на стол («буду сохранять вот это»), а реально пишет в базу commit. Можно добавить пять объектов через add и сохранить их все одним commit() — так даже быстрее.

3. Использовал get вместо select по tg_id

Метод session.get(User, X) ищет по первичному ключу, то есть по нашему внутреннему id, а не по tg_id. Если передать туда Telegram-id, ты либо найдёшь не того пользователя, либо никого. Для поиска по любому полю, кроме первичного ключа, бери select(User).where(User.поле == значение).

4. Создал по объекту на каждого, забыв про unique

Если на каждый /start слепо делать session.add(User(...)), один человек размножится в таблице. Мы поставили tg_id флаг unique=True — повторная вставка вызовет ошибку. Правильный путь: сначала select «а есть ли уже такой?», и только если None — добавлять нового. Это называют «создать, если не существует».

5. Держишь одну сессию навсегда

Заманчиво создать одну сессию при старте бота и пользоваться ей вечно. Так лучше не делать: сессия — это рабочий стол под одну задачу, а не склад. Открывай свежую сессию через with Session() as session: под каждый хэндлер — так данные не перепутаются между пользователями, а стол всегда будет чистым.

Мини-практика: команда /forget

Теперь твоя очередь прокачать Цыплёнка. Добавь команду /forget, которая удаляет пользователя из базы — пригодится, если человек хочет, чтобы бот его «забыл». План такой:

  1. Напиши хэндлер на Command("forget"), открой сессию через with Session() as session:.
  2. Найди пользователя по его tg_id через select(User).where(...) и session.scalar(...), как в примере 2.
  3. Если пользователь нашёлся — вызови session.delete(user) (метод-зеркало для add, только удаляет) и не забудь session.commit().
  4. Ответь в чат «Готово, я всё забыл про тебя». Если пользователя и так не было — скажи «А я тебя и не знал».

Когда заработает — попробуй усложнить: сделай команду /whoami, которая показывает, сколько всего пользователей запомнил бот. Подсказка: посчитать строки можно запросом select(func.count()).select_from(User) и достать число через session.scalar(...) (нужен импорт from sqlalchemy import func). Это та же идея, что SELECT COUNT(*) FROM users из прошлого урока, только глазами ORM.

Итоги

Сегодня Цыплёнок перешёл с ручного SQL на SQLAlchemy и стал понятнее изнутри. Запомни главное:

  • ORM — переводчик между миром объектов Python и миром таблиц базы. SQLAlchemy — самый популярный такой переводчик.
  • Класс = таблица, объект = строка, атрибут = колонка. Модель описываем один раз как класс-наследник DeclarativeBase.
  • Движок (create_engine) — подключение к файлу базы; сессия (Session()) — рабочий стол под одну задачу.
  • Три глагола: session.add(obj) — положить новый объект, session.get/select(...).scalar() — достать, session.commit() — сохранить насовсем.
  • Менять данные просто: поправил атрибут объекта и вызвал commit()UPDATE SQLAlchemy напишет сама.

В следующем уроке мы соединим SQLAlchemy с тем, что уже умеет Цыплёнок: проведём пользователя через анкету на FSM, а собранные имя и возраст аккуратно положим в базу через сессию. База перестанет быть отдельной темой и станет живой памятью нашего бота.

Проверьте себя
1. Что такое ORM и зачем он нужен боту?
AБиблиотека, которая позволяет работать с базой через объекты Python вместо ручного SQL
BСпособ ускорить отправку сообщений в Telegram
CХранилище для токена бота
DОсобый вид inline-кнопок
2. Как в SQLAlchemy соответствуют друг другу мир объектов и мир таблиц?
AКласс — это колонка, а объект — это вся база
BКласс — это таблица, объект — строка, атрибут — колонка
CКласс — это строка, а атрибут — это таблица
DВсе три понятия означают одно и то же
3. Что реально происходит при вызове session.add(user)?
AПользователь сразу записывается в файл базы
BОбъект помечается «к сохранению», но в базу пока не пишется
CИз базы удаляется старая строка пользователя
DСоздаётся новая таблица users
4. Почему после изменения user.age нужно вызвать session.commit()?
AИначе бот не сможет ответить в чат
BИначе изменение останется только в сессии и не попадёт в файл базы
CИначе придётся вручную писать запрос UPDATE
Dcommit нужен только при удалении строк
5. Чем session.get(User, X) отличается от select(User).where(User.tg_id == X)?
Aget ищет по первичному ключу (id), а select — по любому указанному полю
Bget работает только с inline-кнопками
CОни полностью одинаковы
Dselect удаляет строку, а get её читает
6. Зачем открывать свежую сессию через with Session() as session под каждый хэндлер?
AЧтобы бот работал быстрее в группах
BЧтобы данные разных пользователей не перепутались, а сессия аккуратно закрывалась
CЧтобы не писать commit()
DЭто нужно только для Redis