Приветствую всех, друзья! Ранее мы рассматривали базовые аспекты работы с FastAPI: различные типы запросов, их валидацию и обработку, а также ответы на эти запросы. Однако, это лишь верхушка айсберга.

Прошлые статьи:

Уже на данном этапе код становится громоздким и сложным для понимания. К тому же, хранение данных в JSON-файлах — это далеко не самый профессиональный подход. «Нормальные ребята» используют SQLAlchemy, причем асинхронно.

Сегодня мы займемся интеграцией асинхронной SQLAlchemy в наше FastAPI-приложение. Для упрощения навигации и понимания кода я предложу структуру проекта, которую сам использую в каждом FastAPI-приложении.

Отдельное внимание уделим миграциям с Alembic: что это такое и зачем они нужны. Конкретные функции для взаимодействия с базой данных мы будем рассматривать в следующей статье. Сегодня же займемся подключением, миграциями, описанием моделей таблиц и их созданием.

Миграции

Миграции — это процесс управления изменениями в структуре базы данных (БД) с течением времени. Они позволяют легко и безопасно применять изменения схемы БД, отслеживать их историю и при необходимости возвращаться к предыдущим версиям.

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

Зачем нужны миграции?

  1. Управление изменениями:

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

  2. Контроль версий:

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

  3. Автоматизация:

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

  4. Согласованность:

    • Миграции обеспечивают согласованность схемы базы данных между различными средами (например, разработка, тестирование, продакшен), гарантируя, что все изменения будут применены одинаково везде.

Миграции можно сравнить с GIT, с одним важным отличием. Они отвечают за структуру таблицы. То есть, если у вас добавится таблица, исчезнет, изменятся колонки - миграции вам позволят быстро откатиться к последнему актуальному состоянию.

За сохранность же данных отвечют другие механизмы. Например volumes, бэкапы базы данных, но это уже тема отдельно беседы.

Что такое Alembic?

Alembic — это инструмент для управления миграциями баз данных, который тесно интегрируется с SQLAlchemy. Разработал данный инструмент, создатель библиотеки SQLAlchemy, за счет чего итегрировать Alembic в SQLAlchemy можно достаточно просто, зная определенные фишки, которыми я с вами поделюсь сегодня.

Alembic позволяет:

  • Автоматически генерировать скрипты миграций на основе изменений моделей SQLAlchemy.

  • Применять, откатывать и пересматривать миграции.

  • Управлять историей изменений схемы базы данных.

Основные функции Alembic:

  1. Создание миграций:

    • Автоматически генерировать скрипты миграций на основе изменений моделей данных.

  2. Применение миграций:

    • Применять изменения в структуре базы данных, чтобы они соответствовали текущему состоянию моделей данных (таблиц).

  3. Откат миграций:

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

  4. Управление историей:

    • Отслеживать и управлять историей всех изменений схемы базы данных.

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

Что такое SQLAlchemy?

SQLAlchemy — это мощная и гибкая библиотека для работы с базами данных в языке программирования Python. Она предоставляет два основных подхода к работе с базами данных:

  1. SQLAlchemy Core: Это низкоуровневый интерфейс для выполнения SQL-запросов и управления соединениями с базой данных.

  2. SQLAlchemy ORM (Object-Relational Mapper): Это высокоуровневый интерфейс, который позволяет работать с базой данных как с объектами Python. ORM автоматизирует процесс преобразования данных между объектно-ориентированным программированием (ООП) и реляционной базой данных.

Что такое ORM?

ORM (Object-Relational Mapping) — это методика, которая позволяет разработчикам взаимодействовать с базой данных с помощью объектно-ориентированных принципов. ORM автоматически связывает классы в программном коде с таблицами в базе данных, что позволяет разработчикам работать с данными в виде объектов, а не писать сложные SQL-запросы.

Если говорить простыми словами, то мы не нуждаемся в знании SQL или написании SQL-команд в нашем проекте, как это требуется при прямой работе с psycopg или asyncpg. Мы взаимодействуем с таблицами через классы, что делает процесс изменения данных прозрачным благодаря SQLAlchemy, который автоматически генерирует необходимые SQL запросы.

Почему стоит выбирать SQLAlchemy и использовать ORM?

1. Упрощение разработки

  • Абстракция SQL: ORM позволяет писать меньше кода и избегать написания сложных SQL-запросов. Вместо этого вы работаете с объектами и методами Python, что упрощает процесс разработки и делает код более читабельным.

  • Скорость разработки: благодаря автоматизации многих задач, связанных с базой данных, ORM ускоряет процесс разработки.

2. Повышенная безопасность

  • Защита от SQL-инъекций: ORM автоматически экранирует данные, передаваемые в запросы, что значительно снижает риск SQL-инъекций.

3. Поддержка различных СУБД

  • Переносимость кода: SQLAlchemy поддерживает множество различных систем управления базами данных (СУБД), таких как PostgreSQL, MySQL, SQLite, Oracle и другие. Это позволяет легко переключаться между СУБД с минимальными изменениями в коде.

4. Мощные возможности

  • Сложные запросы и отношения: SQLAlchemy ORM позволяет легко создавать сложные запросы и управлять отношениями между таблицами, используя объектно-ориентированные принципы.

  • Расширяемость: SQLAlchemy легко расширяется и интегрируется с другими библиотеками и инструментами.

5. Активное сообщество и хорошая документация

  • Сообщество: SQLAlchemy имеет большое и активное сообщество, что обеспечивает обилие ресурсов и поддержку.

  • Документация: Подробная и качественная документация помогает быстро освоиться с библиотекой и эффективно использовать её возможности.

6. Асинхронная поддержка

  • Асинхронность: С последними версиями SQLAlchemy предоставляет поддержку асинхронных операций, что особенно полезно для современных высокопроизводительных веб‑приложений, разработанных, например, на FastApi.

Надеюсь, что с описанной выше информацией вам сейчас более-менее понятно.

Подготовка

Для начала нам необходимо развернуть базу данных PostgreSQL на локальной машине.

Для того чтоб это сделать вы можете воспользоваться или Docker (подробно тут) или просто скайчайте PgAdmin и запустите.

Я опишу тут коротко как запуск сделать через docker‑compose.yml файл.

  1. Ставим Docker Desktop

  2. Запускаем Docker Desktop

  3. Закидываем файл docker‑compose.yml в корень проекта FastApi и заполняем его примерно так:

services:
  postgres:
    image: postgres:latest
    container_name: postgres_fast_api
    environment:
      POSTGRES_USER: amin
      POSTGRES_PASSWORD: my_super_password
      POSTGRES_DB: fast_api
      PGDATA: /var/lib/postgresql/data/pgdata
    ports:
      - "5433:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data/pgdata
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 256M
    command: >
      postgres -c max_connections=1000
               -c shared_buffers=256MB
               -c effective_cache_size=768MB
               -c maintenance_work_mem=64MB
               -c checkpoint_completion_target=0.7
               -c wal_buffers=16MB
               -c default_statistics_target=100
    healthcheck:
      test: [ "CMD-SHELL", "pg_isready -U postgres_user -d postgres_db" ]
      interval: 30s
      timeout: 10s
      retries: 5
    restart: unless-stopped
    tty: true
    stdin_open: true

volumes:
  pgdata:
    driver: local

Запоните свои данные для подключения.

Теперь просто выполняем команду docker-compose up -d. Этого будет достаточно для того, чтоб на вашей локальной машине запустилась база данных PostgreSQL.

В работе, в качестве GUI для PostgreSQL я использую DBeaver, вы же можете выбрать свой вариант.

Теперь попробуем подключиться к базе данных.

Выбираем подключение PostgreSQL и вбиваем свои данные.
Выбираем подключение PostgreSQL и вбиваем свои данные.
Жмем на "Тест соединения" и если все ок получаем похожее окно. Возможно нужно будет выполнить установку драйвера.
Жмем на "Тест соединения" и если все ок получаем похожее окно. Возможно нужно будет выполнить установку драйвера.

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

Перед тем как приступить к написанию кода, необходимо установить несколько библиотек, которые помогут нам управлять базой данных и выполнять миграции. Эти библиотеки включают Alembic, SQLAlchemy, asyncpg и pydantic-settings.

Откройте терминал и выполните следующую команду для установки необходимых библиотек:

pip install alembic asyncpg sqlalchemy pydantic-settings
  • Alembic: эта библиотека используется для управления миграциями базы данных. Миграции позволяют отслеживать изменения в структуре базы данных и применять их последовательно.

  • SQLAlchemy: это мощный и гибкий инструмент для работы с базой данных. Он предоставляет ORM (Object-Relational Mapping) для удобного взаимодействия с базой данных.

  • asyncpg: это асинхронный драйвер для PostgreSQL, который позволяет использовать возможности асинхронного программирования с SQLAlchemy.

  • pydantic-settings: это отдельная библиотека, которую мы будем использовать для хранения настроек.

Начинаем правильно организоваывать проект FastApi

Я предлагаю следующую структуру:

my_fastapi_project/

├── tests/
│   └── (тут мы будем добавлять функции для Pytest)
├── app/
│   ├── database.py
│   ├── config.py
│   ├── main.py
│   └── students/
│       └── models.py
│   └── migration/
│       └── (файлы миграций Alembic)
├── alembic.ini
├── .env
└── requirements.txt
Структура проекта
Структура проекта

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

Если вы читали мои прошлые статьи по теме разработки собственного API через FastApi то понмите, что я говорил что запускать FastApi приложение нужно с корневой дирректории проекта, а не с папки app. Так запускать проект я советовал именно для того, чтоб текущая структура могла корректно работать.

Напоминаю, что исходники кода по моим публикациям по FastApi вы найдете только в моем телеграмм канале «Легкий путь в Python» (структура там-же).

Заполним файл database.py.

from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, declared_attr


DB_HOST = 'localhost'
DB_PORT = '5433'
DB_NAME = 'fast_api'
DB_USER = 'amin'
DB_PASSWORD = 'my_super_password'

DATABASE_URL = f'postgresql+asyncpg://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}'

engine = create_async_engine(DATABASE_URL)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)


class Base(AsyncAttrs, DeclarativeBase):
    __abstract__ = True

    @declared_attr.directive
    def __tablename__(cls) -> str:
        return f"{cls.__name__.lower()}s"

Обратите внимание. Тут я просто продемонстрировал как генерируюется ссылка. Далее я покажу, как правильно вынести данные в .env файл и при помощи pydantic_settings описать наши настройки.

Разберем сам код.

  • create_async_engine: создаёт асинхронное подключение к базе данных PostgreSQL, используя драйвер asyncpg.

  • async_session_maker: создаёт фабрику асинхронных сессий, используя созданный движок. Сессии используются для выполнения транзакций в базе данных.

  • Base: абстрактный класс, от которого наследуются все модели. Он используется для миграций и аккумулирует информацию обо всех моделях, чтобы Alembic мог создавать миграции для синхронизации структуры базы данных с моделями на бэкенде.· 

  • @declared_attr.directive: определяет имя таблицы для модели на основе имени класса, преобразуя его в нижний регистр и добавляя букву 's' в конце (например, класс User будет иметь таблицу users).

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

Заполним файл app/config.py

import os
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    DB_HOST: str
    DB_PORT: int
    DB_NAME: str
    DB_USER: str
    DB_PASSWORD: str
    model_config = SettingsConfigDict(
        env_file=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env")
    )


settings = Settings()


def get_db_url():
    return (f"postgresql+asyncpg://{settings.DB_USER}:{settings.DB_PASSWORD}@"
            f"{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}")

Для того, чтоб все тут корректно работало, необходимо отдельно установить модуль pydentic_settings:

pip install pydantic-settings

Далее мы описываем каждое значение как в обычном Pydantic, добавляя разве что параметр model_config. Он нам нужен для того чтоб передать путь к нашему .env-файлу.

Далее я написал простую функцию, которая сгенерирует нашу ссылку.

После добавления опций вид файла database.py будет таким:

from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, declared_attr
from app.config import get_db_url


DATABASE_URL = get_db_url()
engine = create_async_engine(DATABASE_URL)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)


class Base(AsyncAttrs, DeclarativeBase):
    __abstract__ = True

    @declared_attr.directive
    def __tablename__(cls) -> str:
        return f"{cls.__name__.lower()}s"

Хорошенько подумав, я решил показать вам несколько фишек, которые я использую в своих проектах, описывая колонки. Файл database.py может иметь и такой вид:

from datetime import datetime
from typing import Annotated

from sqlalchemy import func
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, declared_attr, Mapped, mapped_column

from app.config import get_db_url

DATABASE_URL = get_db_url()

engine = create_async_engine(DATABASE_URL)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)

# настройка аннотаций
int_pk = Annotated[int, mapped_column(primary_key=True)]
created_at = Annotated[datetime, mapped_column(server_default=func.now())]
updated_at = Annotated[datetime, mapped_column(server_default=func.now(), onupdate=datetime.now)]
str_uniq = Annotated[str, mapped_column(unique=True, nullable=False)]
str_null_true = Annotated[str, mapped_column(nullable=True)]


class Base(AsyncAttrs, DeclarativeBase):
    __abstract__ = True

    @declared_attr.directive
    def __tablename__(cls) -> str:
        return f"{cls.__name__.lower()}s"

    created_at: Mapped[created_at]
    updated_at: Mapped[updated_at]

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

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

Кроме того, вы видите что я расширил класс Base, добавив в него:

created_at: Mapped[created_at]
updated_at: Mapped[updated_at]

Теперь, в каждой создаваемой вами таблице будут появляться две колонки:

  • created_at — Дата и время создания записи. Описанная аннотация сделает так, чтоб на стороне базы данных вытягивалась дата с сервера, на котором база данных размещена: server_default=func.now().

  • updated_at — колонка, в которой будет фиксироваться текущая дата и время после обновления.

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

Отлично. Теперь мы можем приступить к описанию моделей наших будующих таблиц.

Далее я предлагаю помещать отдельные сущности нашего API, такие как студенты или преподаватели, в отдельные папки. А внутри уже создавать файлы и папки, которые будут иметь отношение к конкретной сущности.

Забегая немного вперед — вокруг каждой такой сущности мы будем строить отдельные роуты и с этим мы познакомимся уже в следующей статье.

Пока создадим папку students и внутри нее файл models.py:

from sqlalchemy import ForeignKey, text, Text
from sqlalchemy.orm import relationship, Mapped, mapped_column
from app.database import Base, str_uniq, int_pk, str_null_true
from datetime import date


# создаем модель таблицы студентов
class Student(Base):
    id: Mapped[int_pk]
    phone_number: Mapped[str_uniq]
    first_name: Mapped[str]
    last_name: Mapped[str]
    date_of_birth: Mapped[date]
    email: Mapped[str_uniq]
    address: Mapped[str] = mapped_column(Text, nullable=False)
    enrollment_year: Mapped[int]
    course: Mapped[int]
    special_notes: Mapped[str_null_true]
    major_id: Mapped[int] = mapped_column(ForeignKey("majors.id"), nullable=False)

    major: Mapped["Major"] = relationship("Major", back_populates="students")

    def __str__(self):
        return (f"{self.__class__.__name__}(id={self.id}, "
                f"first_name={self.first_name!r},"
                f"last_name={self.last_name!r})")

    def __repr__(self):
        return str(self)


# создаем модель таблицы факультетов (majors)
class Major(Base):
    id: Mapped[int_pk]
    major_name: Mapped[str_uniq]
    major_description: Mapped[str_null_true]
    count_students: Mapped[int] = mapped_column(server_default=text('0'))

    def __str__(self):
        return f"{self.__class__.__name__}(id={self.id}, major_name={self.major_name!r})"

    def __repr__(self):
        return str(self)

Если вы уже были знакомы с SQLAlchemy в прошлом, то у вас могут возникнуть вопросы по синтаксису описания колонок в модели таблиц, в приведенном примере.

На данный момент разработка ведется на версии алхимии 2.031 и сейчас это самый актуальный синтаксис, в чем вы можете убедиться, посетив страницы официальной документации SQLAlchemy.

Начнем разбираться с кодом.

Рассмотрим импорты:

  1. SQLAlchemy: Думаю, что уже понятно что это.

  2. ForeignKey, text, Text:

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

    • text: это функция в SQLAlchemy, которая позволяет создавать текстовые фрагменты SQL напрямую в вашем Python коде. В нашем случае, мы ее импортировали чтоб передать значение по умолчанию напрямую в таблице базы данных (об этом подробнее далее)

    • Text: это тип данных в SQLAlchemy, который представляет собой текстовое поле в базе данных. Он содержит строки, размером больше 255 знаков. В остальных случаях достаточно использовать String (импортировал просто для примера)

  3. Mapped, mapped_column: это части SQLAlchemy, которые используются для объявления сопоставления (mapping) между классами Python и структурами таблиц в базе данных. Они могут использоваться совместно с Declarative ORM, чтобы определить атрибуты класса, которые соответствуют столбцам в базе данных

  4. Base: Base — это базовый класс, который мы определили в модуле app.database. Он наследует DeclarativeBase из SQLAlchemy и используется в качестве базового класса для всех моделей (таблиц) базы данных вашего приложения.

Каждый из этих классов описывают свою таблицу (модель таблицы). Давайте разберем подробно каждый из классов:

Определение модели таблицы Student

class Student(Base):
    id: Mapped[int_pk]
    phone_number: Mapped[str_uniq]
    first_name: Mapped[str]
    last_name: Mapped[str]
    date_of_birth: Mapped[date]
    email: Mapped[str_uniq]
    address: Mapped[str] = mapped_column(Text, nullable=False)
    enrollment_year: Mapped[int]
    course: Mapped[int]
    special_notes: Mapped[str_null_true]
    major_id: Mapped[int] = mapped_column(ForeignKey("majors.id"), nullable=False)

    major: Mapped["Major"] = relationship("Major", back_populates="students")

    def __str__(self):
        return (f"{self.__class__.__name__}(id={self.id}, "
                f"first_name={self.first_name!r},"
                f"last_name={self.last_name!r})")

    def __repr__(self):
        return str(self)

Давайте на этом остановимся подробнее, так как, предполагаю, у вас есть вопросы из серии «А чего так мало кода»? Давайте разбираться.

id: Mapped[int_pk]

Тут, напоминаю, что описание int_pk имеет такой вид:

int_pk = Annotated[int, mapped_column(primary_key=True)]

То есть, в данном моменте мы уже настроили нашу колонку, сообщив ей, что она — это целое число, первичный ключ, да ещё и Autoincrement (передали не явно, так как в алхимии целое число, которое первичный ключ автоматически помечается автоинкрементным). Другими словами, значение id будет автоматически генерироваться и увеличиваться на единицу.

Далее мы просто передали это значение, тем самым описав колонку id в таблице students (напоминаю, что имя таблицы у нас генерируется автоматически и берется оно от названия класса).

phone_number: Mapped[str_uniq]

Тут наша аннотация выглядела так:

str_uniq = Annotated[str, mapped_column(unique=True, nullable=False)]

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

unique=True говорит о том, что данная запись у нас должна быть уникальной, а nullable=False о том что в данной записи обязательно должно быть значение.

Теперь разберем остальные колонки на примере этих записей:

  • last_name: Mapped[str]

  • date_of_birth: Mapped[date]

  • course: Mapped[int]

Тут нам достаточно указывать встроенные питоновские классы, тем самым описывая колонку. Алхимия, в случае подобного синтаксиса, будет трансформировать стандартное питоновское описание (str, date, int) в эквивалентные им типы данных: String, Integer, Date, а у нас нет необходимости в том, чтоб передавать их явно.

Когда такая необходимость появляется, нам нужно использовать подобный синтаксис:

address: Mapped[str] = mapped_column(Text, nullable=False)

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

С остальными колонками вопросов быть не должно, разве что с этим:

major_id: Mapped[int] = mapped_column(ForeignKey("majors.id"), nullable=False)

Данная запись в SQLAlchemy описывает колонку major_id, сообщая алхимии, что major_id является внешним ключом (ForeignKey) и ссылается на колонку id в таблице majors. Таким образом, major_id может хранить значения, которые существуют в колонке id таблицы majors.

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

Определение модели таблицы Major

class Major(Base):
    id: Mapped[int_pk]
    major_name: Mapped[str_uniq]
    major_description: Mapped[str_null_true]
    count_students: Mapped[int] = mapped_column(server_default=text('0'))

    def __str__(self):
        return f"{self.__class__.__name__}(id={self.id}, major_name={self.major_name!r})"

    def __repr__(self):
        return str(self)
  • class Major(Base): Создает модель таблицы Major, которая наследуется от Base.

  • mapped_column: Определяет колонки в таблице.

    • id: Первичный ключ, автоинкрементируемое целое число (уже знаете почему).

    • major_name: Уникальная и обязательная строка.

    • major_description: Необязательная строка.

    • count_students: строка в которой будет храниться количество студентов. На данной колонке мы остановимся подробнее.

  • __str__ и __repr__: Методы для удобного представления объектов модели в виде строки.

Разница default и server_default

Запись server_default=text('0') в SQLAlchemy используется для установки значения по умолчанию для колонки на уровне базы данных с помощью SQL-выражения '0'.

Если бы мы прописывали default=0, то мы бы устонавливали значения по умолчанию на уровне объектов и моделей в Python коде. В таком случае значение бы 0 подставлялось в таблицу при добавлении записи, но в самой таблице эта информация не отобразилась бы.

Мы создали две модели таблиц для базы данных: Student и Major. Эти модели описывают структуру таблиц и связи между ними. Модель Student включает много полей с различными типами данных и ограничениями, а также внешние ключи и связи с моделью Major. Модель Major включает меньше полей и определяет связь с моделью Student.

Миграция в базу данных PostgreSQL

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

Тут на сцену выходит Alembic.

Для создания миграций нам нужно зайти в папку «app» через консоль (cd app) и там выполнить команду:

alembic init -t async migration

Обратите внимание, что команда должна принимать флаг -t async иначе работать ничего у нас не будет. Слово migration можно заменить на любое другое нужное вам. Часто пишут так

alembic init -t async alembic

Но лично мне кажется, что лучше использовать название «migration».

После выполнения данной команды будет сгенерирован файл alembic.ini и папка migrations. Теперь нам необходимо выполнить несколько трюков, чтоб та структура проекта FastApi, которую я вам предлагаю заработала. Начнем.

  1. Перемещаем файл alembic.ini с папки app в корень проекта

  2. В файле alembic.ini заменяем строку script_location=migration на script_location = app/migration

  3. Заходим в папку migration, которая появилась в дирректории app и там находим файл env.py.

  4. Правим файл env.py следующим образом.

Добавляем в файл новые импорты:

import sys
from os.path import dirname, abspath

sys.path.insert(0, dirname(dirname(abspath(__file__))))

from app.database import DATABASE_URL, Base
from app.students.models import Student, Major

В данном случае мы «обманули» Alembic, заставив его думать, что он находится в папке app. Это необходимо, чтобы Alembic правильно обрабатывал импорты и понимал, как они работают в контексте проекта.

Порядок импортов тут крайне важен. Сначала мы импортируем класс Base, а после уже модели наших библиотек.

Далее нам необходимо добавить несколько строк. Первая строка:

config.set_main_option("sqlalchemy.url", DATABASE_URL)
  • config: Это объект конфигурации Alembic (alembic.config.Config), который используется для управления параметрами и настройками миграций.

  • set_main_option("sqlalchemy.url", DATABASE_URL): Этот метод устанавливает основную опцию sqlalchemy.url в конфигурации Alembic. Он используется для указания URL, по которому Alembic будет подключаться к базе данных SQLAlchemy.

Вторая строка :

target_metadata = Base.metadata
  • Base.metadata: Это атрибут metadata вашего базового класса SQLAlchemy (Base), который содержит информацию о структуре вашей базы данных.

Зачем это делается?

  • Создание целевых метаданных для миграций: Alembic использует target_metadata для сравнения текущей структуры базы данных с тем, как она должна выглядеть после применения миграций. Это позволяет Alembic автоматически генерировать миграции на основе изменений в вашей модели данных.

  • Согласованность структуры данных: Использование Base.metadata гарантирует, что Alembic работает с актуальной структурой вашей базы данных, которая определена в ваших моделях SQLAlchemy.

Полный код изменений:

import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context

import sys
from os.path import dirname, abspath

sys.path.insert(0, dirname(dirname(abspath(__file__))))

from app.database import DATABASE_URL, Base
from app.students.models import Student, Major

config = context.config
config.set_main_option("sqlalchemy.url", DATABASE_URL)
if config.config_file_name is not None:
    fileConfig(config.config_file_name)
    
target_metadata = Base.metadata

# то что идет дальше пока оставляем без изменений

Надеюсь, что доступно объяснил. Если нет, то можете просто повторять. Настраивается это все дело один раз на конкретный проект.

Теперь мы готовы выполнить свою первую миграцию (revision). Для этого необходимо в консоли вернуться в корень проекта:

cd ../

Далее вводим команду:

alembic revision --autogenerate -m "Initial revision"

Команда alembic revision --autogenerate -m "Initial revision" используется для автоматической генерации миграции базы данных с помощью Alembic. Давайте разберем, что делает эта команда и зачем нужен флаг --autogenerate.

Общее описание команды

  • alembic revision: Это команда Alembic для создания новой ревизии (миграции) базы данных.

  • --autogenerate: Флаг, который указывает Alembic автоматически сгенерировать миграцию на основе изменений в моделях SQLAlchemy и текущей структуре базы данных.

  • -m "Initial revision": Опция -m используется для добавления сообщения о миграции. В данном случае сообщение "Initial revision" указывает на то, что это первая (начальная) миграция. Вы можете указать любое свое сообщение, но, советую это делать осмысленно.

Зачем используется флаг --autogenerate

  • Автоматическое создание миграций:

    • Флаг --autogenerate позволяет Alembic анализировать текущее состояние базы данных и сравнивать его с определениями моделей SQLAlchemy. На основе этих сравнений Alembic генерирует код миграции, который включает изменения структуры базы данных (такие как создание новых таблиц, изменение существующих столбцов и т.д.).

  • Упрощение процесса:

    • Автоматическая генерация миграций с флагом --autogenerate упрощает процесс управления изменениями в базе данных, особенно когда ваши модели данных SQLAlchemy изменяются. Это позволяет избежать ручного написания сложных SQL-запросов для каждого изменения.

Как это работает

  1. Сравнение текущего состояния с моделями: Alembic анализирует текущую структуру базы данных и сравнивает её с определениями моделей SQLAlchemy, которые хранятся в target_metadata (как мы рассмотрели ранее).

  2. Генерация миграционного скрипта: На основе выявленных различий Alembic автоматически генерирует код Python, который описывает необходимые изменения структуры базы данных.

  3. Применение и откат миграций: Сгенерированный миграционный скрипт можно применить к базе данных с помощью команды alembic upgrade head, а при необходимости выполнить откат изменений с помощью alembic downgrade.

Наверное проще будет выполнить команду и посмотреть результаты выполнения.

Отлично. Мы видим что никаких ошибок нет и тут нас больше всего интересует нижняя строка:

Generating C:\Users\mrmno\PycharmProjects\FastApiMyProject\app\migration\versions\de943521188c_initial_revision.py ...  done

Тут описывается путь к сгенерированной версии миграции. Давайте откроем файл и изучим его.

"""Initial revision

Revision ID: de943521188c
Revises: 
Create Date: 2024-07-08 23:13:48.105217

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = 'de943521188c'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('majors',
                    sa.Column('id', sa.Integer(), nullable=False),
                    sa.Column('major_name', sa.String(), nullable=False),
                    sa.Column('major_description', sa.String(), nullable=True),
                    sa.Column('count_students', sa.Integer(), server_default=sa.text('0'), nullable=False),
                    sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
                    sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
                    sa.PrimaryKeyConstraint('id'),
                    sa.UniqueConstraint('major_name')
                    )
    op.create_table('students',
                    sa.Column('id', sa.Integer(), nullable=False),
                    sa.Column('phone_number', sa.String(), nullable=False),
                    sa.Column('first_name', sa.String(), nullable=False),
                    sa.Column('last_name', sa.String(), nullable=False),
                    sa.Column('date_of_birth', sa.Date(), nullable=False),
                    sa.Column('email', sa.String(), nullable=False),
                    sa.Column('address', sa.Text(), nullable=False),
                    sa.Column('enrollment_year', sa.Integer(), nullable=False),
                    sa.Column('course', sa.Integer(), nullable=False),
                    sa.Column('special_notes', sa.String(), nullable=True),
                    sa.Column('major_id', sa.Integer(), nullable=False),
                    sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
                    sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
                    sa.ForeignKeyConstraint(['major_id'], ['majors.id'], ),
                    sa.PrimaryKeyConstraint('id'),
                    sa.UniqueConstraint('email'),
                    sa.UniqueConstraint('phone_number')
                    )
    # ### end Alembic commands ###


def downgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_table('students')
    op.drop_table('majors')
    # ### end Alembic commands ###

Приятно то, что данный файл был сформирован автоматически, не так ли? При чем, alembic выполнил полные описания каждой колонки, что доказало, что мы правильно описали модели и базовый класс.

Внутри мы видим 2 функции upgrade() и downgrade().

Данные функции уже сейчас позволят вам переместиться в состояние когда таблицы не было (downgrade) удаляет индексы и сами таблицы или в состояние создания двух таблиц (функция upgrade).

Тут стоит обратить внимание на важный момент. Функция upgrade не выполнится автоматически. Alembic, в данном случае, просто сгенерировал его, а для того чтоб ее запустить нам необходимо выполнить следующую команду:

alembic upgrade de943521188c

Либо можно вместо revision_id выполнить команду:

alembic upgrade head

В таком случае подтянется самое последнее обновление и выполнится функция upgrade.

Запускаем и смотрим:

Несмотря на то, что мы создавали всего две таблицы, в базе данных мы видим 3. Одна из таблиц — это alembic_version.  В ней будут сохраняться ID миграций, что в перспективе очень удобно.

А теперь посмотрим на наши созданные таблицы:

Таблица Students
Таблица Students
Таблица majors
Таблица majors

Для отмены последнего изменения достаточно выполнить команду:

alembic downgrade -1

После этого таблица со студентами и факультетами исчезнет.

Заключение

В данной статье мы рассмотрели основные аспекты создания собственного API на FastAPI с использованием асинхронной SQLAlchemy для взаимодействия с базой данных PostgreSQL.

Следующим шагом будет разработка функций для взаимодействия с таблицами: добавление, удаление и изменение данных о студентах и других объектах. Мы также разберем создание роутов и немного дополним структуру проекта, что позволит нам перейти к более сложным и узкоспециализированным темам.

И, прежде чем закончить, хочу напомнить что полные исходники данного кода вы найдете только в моем телеграмм канале «Легкий путь в Python».

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

Благодарю за внимание и до скорого.

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


  1. Q3_Results
    09.07.2024 09:27

    В периметре миграции базы данных только структура БД, без переливки и возврата состояния данных в случае отката?


    1. yakvenalex Автор
      09.07.2024 09:27

      Почему? Там же я показывал функции downgrade и upgrade. Абсолютный стандарт Alembic.