Основы 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, которая удаляет пользователя из базы — пригодится, если человек хочет, чтобы бот его «забыл». План такой:
- Напиши хэндлер на
Command("forget"), открой сессию черезwith Session() as session:. - Найди пользователя по его
tg_idчерезselect(User).where(...)иsession.scalar(...), как в примере 2. - Если пользователь нашёлся — вызови
session.delete(user)(метод-зеркало дляadd, только удаляет) и не забудьsession.commit(). - Ответь в чат «Готово, я всё забыл про тебя». Если пользователя и так не было — скажи «А я тебя и не знал».
Когда заработает — попробуй усложнить: сделай команду /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()—UPDATESQLAlchemy напишет сама.
В следующем уроке мы соединим SQLAlchemy с тем, что уже умеет Цыплёнок: проведём пользователя через анкету на FSM, а собранные имя и возраст аккуратно положим в базу через сессию. База перестанет быть отдельной темой и станет живой памятью нашего бота.