hare-orm¶
Асинхронная ORM для Python с PostgreSQL, SQLite и ClickHouse, спроектированная с упором на связи между
моделями. Модели — обычные классы, запросы строятся лениво через QuerySet, а миграции создаются по
изменениям моделей. См. Если вы пришли с Django.
- Быстрая. Драйвер PostgreSQL на Rust разбирает строки и преобразует значения вне Python, а каждая форма запроса строится один раз: повторный запрос только подставляет новые значения — см. Производительность.
- Связи как надо. Составные первичные ключи везде — и как цель внешнего ключа, обобщённые связи из
настоящих внешних ключей,
select_related()/prefetch_related()с условиями, рекурсивные запросы по дереву и ошибка вместо незаметно завышенного агрегата — см. Модели и связи. - Схема — в объявлениях. Ограничения, частичные индексы и индексы по выражениям, триггеры,
представления, материализованные представления, функции, последовательности, политики защиты строк,
права и секционирование описываются в
Metaмодели, а создают и меняют их миграции — см. Схема вMeta. - Готовые прикладные приёмы. Мягкое удаление, версии строк, разделение данных по арендаторам (по
колонке, по схеме или защитой строк в самой базе), оптимистичная блокировка, отслеживание изменённых
полей и transactional outbox — опции
Meta— см. Поведение вMeta. - Миграциям можно доверять. Они выводятся из моделей, проверяются на операции, блокирующие
нагруженную таблицу, сравниваются с живой базой (
hare drift) и создаются по существующей базе (hare inspectdb) — см. Миграции. - Каждая база — на своих условиях. SQLite, PostgreSQL и ClickHouse — диалекты на том же публичном
API, которым пользуется сторонний пакет; с PostgreSQL приходят его типы, индексы, PostGIS, pgvector,
полнотекстовый поиск и
LISTEN/NOTIFY, а база без нужной возможности отвечает понятной ошибкой вместо неверного результата — см. Базы данных. - Готова к продакшену. Наблюдатели, OpenTelemetry, теги в комментариях SQL, детектор N+1, метрики пула, распределённые транзакции, реплики для чтения и маршрутизаторы, поддержка PgBouncer, схемы Pydantic и интеграции с Litestar, FastAPI и Robyn — см. Наблюдаемость.
- Проверяется до запуска. Плагин mypy отклоняет опечатку в фильтре и выводит типы строк
values(); плагин pytest, фабрики и проверки числа запросов покрывают тесты — см. Тесты и проверка типов.
Быстрый старт¶
Установите пакет, объявите две модели и запросите их:
# library/models.py
from hare import fields
from hare.models import Model
class Author(Model):
id = fields.IntField(primary_key=True)
name = fields.CharField(max_length=120)
class Book(Model):
id = fields.IntField(primary_key=True)
author = fields.ForeignKeyField("models.Author", related_name="books")
title = fields.CharField(max_length=300)
rating = fields.IntField(default=0)
# main.py
import asyncio
from hare import Hare, HareConfig
from library.models import Author, Book
async def main() -> None:
await Hare.init(HareConfig.from_db_url("sqlite+aiosqlite://db.sqlite3", {"models": ["library.models"]}))
await Hare.generate_schemas()
author = await Author.objects.create(name="Ursula K. Le Guin")
await Book.objects.create(author=author, title="The Left Hand of Darkness", rating=5)
async for book in Book.objects.filter(author__name__icontains="le guin").select_related("author"):
print(book.title, "—", book.author.name)
await Hare.close_connections()
asyncio.run(main())
В настоящем проекте схема ведётся миграциями, а не generate_schemas(): hare makemigrations, затем
hare migrate. См. Первая модель.
Модели и связи¶
Модель — класс из полей, и связь — тоже поле — см. Типы полей и Связи.
from hare import fields
from hare.models import Model
from hare.query.expressions import F
class Author(Model):
id = fields.IntField(primary_key=True)
name = fields.CharField(max_length=120)
class Shelf(Model):
store = fields.CharField(max_length=20)
number = fields.IntField()
pk = fields.CompositePrimaryKey("store", "number") # составной первичный ключ
class Tag(Model):
id = fields.IntField(primary_key=True)
name = fields.CharField(max_length=50, unique=True)
class Book(Model):
id = fields.IntField(primary_key=True)
isbn = fields.CharField(max_length=13, unique=True)
title = fields.CharField(max_length=300)
author = fields.ForeignKeyField("models.Author", related_name="books")
shelf = fields.ForeignKeyField("models.Shelf", related_name="books", null=True) # обе колонки ключа
tags = fields.ManyToManyField("models.Tag", related_name="books")
rating = fields.IntField(default=0)
published_at = fields.DateField(null=True)
price = fields.DecimalField(max_digits=10, decimal_places=2)
quantity = fields.IntField(default=0)
stock_value = fields.GeneratedField( # вычисляет база
F("price") * F("quantity"), output_field=fields.DecimalField(max_digits=12, decimal_places=2)
)
supplier_token = fields.EncryptedTextField(null=True) # шифруется в базе, скрыто в логах
class Category(Model):
id = fields.IntField(primary_key=True)
name = fields.CharField(max_length=100)
parent = fields.ForeignKeyField("models.Category", related_name="children", null=True)
class Comment(Model):
id = fields.IntField(primary_key=True)
target = fields.GenericForeignKeyField({"book": Book, "shelf": Shelf}, related_name="comments")
text = fields.TextField()
- Составные первичные ключи работают везде, где работает ключ из одной колонки: как цель внешнего
ключа с настоящим табличным
FOREIGN KEY, в соединениях, каскадах,prefetch_related(), фильтрах__inи курсорах. Таблица может быть и вовсе без первичного ключа. GenericForeignKeyField— исключающая дуга из настоящих внешних ключей: на каждую цель колонка, внешний ключ и индекс, аCHECKследит, чтобы был задан ровно один, — так что согласованность держит сама база.- Каждое
on_delete—CASCADE,RESTRICT,PROTECT,SET_NULL,SET_DEFAULT— выполняется и для строк, которые удаляетQuerySet.delete(), аdelete_preview()показывает, до чего дойдёт удаление строки. - Есть поля для JSON (с поиском по путям), перечислений, десятичных чисел, UUID, двоичных данных, дат и времени с часовым поясом, массивов, диапазонов, геометрии и векторов; свои поля подключаются к тому же механизму.
Запросы¶
QuerySet строит запрос лениво и выполняет его при await — см.
Методы QuerySet.
from hare import Transactions
from hare.query.expressions import Case, Exists, F, OuterReference, Q, Subquery, Value, When, Window
from hare.query.functions import Avg, Count, Sum
from hare.query.functions.window import Lag, Rank
# Фильтры через связи, объекты Q и выражения F
await Book.objects.filter(Q(title__icontains="dark") | Q(rating__gte=4), shelf__store="north")
await Book.objects.filter(quantity=0).update(quantity=F("quantity") + 10, rating=F("rating") - 1)
# Агрегаты с группировкой по выбранному
await Author.objects.annotate(book_count=Count("books"), avg_rating=Avg("books__rating")).filter(book_count__gte=3)
await Book.objects.values("author__name").annotate(total=Sum("quantity")).order_by("-total")
# Подзапросы, Exists и условные выражения
latest = Book.objects.filter(author=OuterReference("pk")).order_by("-published_at").values("title")[:1]
await Author.objects.annotate(
latest_title=Subquery(latest),
has_bestseller=Exists(Book.objects.filter(author=OuterReference("pk"), rating=5)),
)
await Book.objects.annotate(level=Case(When(rating__gte=4, then=Value("good")), default=Value("other")))
# Оконные функции
await Book.objects.annotate(
rank_in_author=Window(Rank(), partition_by=["author_id"], order_by=["-rating"]),
previous_price=Window(Lag("price"), partition_by=["author_id"], order_by=["published_at"]),
)
# Категория и все её потомки одним запросом WITH RECURSIVE
await Category.objects.filter(pk=root.pk).with_recursive("children", max_depth=5)
# Связи, загруженные вместе со строками, постраничный вывод по ключу, заблокированная строка
await Book.objects.select_related("author").prefetch_related("tags")
newest = Book.objects.order_by("-published_at", "id")
next_page = await newest.after_cursor(*newest.cursor_values(last_book)).limit(20)
async with Transactions.atomic():
book = await Book.objects.select_for_update(skip_locked=True).get(id=book_id)
# Вставка с обновлением при конфликте одной командой
await Book.objects.bulk_create(books, on_conflict=["isbn"], update_fields=["price", "quantity"])
- Типизированные строки.
values()иvalues_list()возвращают словари и кортежи, с путями через связи, ключи JSON и части даты ("published_at__year"). - Безопасные агрегаты. Агрегат рядом с соединением по связи «ко многим», который посчитал бы строку
дважды, даёт
QueryErrorвместо завышенного числа; есть наборы группировок и подытоги. - Операции над множествами (
union(),intersection(),difference()),FilteredRelation,Lateral,JsonTable, CTE (with_cte()),distinct(<fields>), случайная выборка иiterator()/stream()для больших результатов. - Кэш планов запросов хранит SQL каждой формы запроса: второй
filter(title__icontains=...)с другим значением не строит запрос заново, а только подставляет значение. - Готовый SQL, когда он нужен:
RawSQL(...)внутри запроса,raw()с экземплярами моделей,execute_sql().
Поведение в Meta¶
Арендаторы, мягкое удаление, оптимистичная блокировка, отслеживание изменённых полей и захват изменений —
опции Meta — см. Опции Meta.
from hare.contrib.outbox import ChangeCapture, OutboxEvent
from hare.contrib.versioning import VersionedModel
from hare.models.tenancy.tenancy import Tenancy
class LoanEvent(OutboxEvent): # таблица outbox; события уходят в Kafka, RabbitMQ, Redis...
pass
class Loan(Model):
id = fields.IntField(primary_key=True)
library_id = fields.IntField(db_index=True)
status = fields.CharField(max_length=20)
version = fields.IntField(default=1)
deleted_at = fields.DatetimeField(null=True)
class Meta:
tenant_field = "library_id" # каждый запрос — в пределах активного арендатора
soft_delete_field = "deleted_at" # delete() помечает строку; запросы её скрывают
optimistic_lock_field = "version" # save() устаревшей копии даёт StaleObjectError
track_dirty_fields = True # get_dirty_fields(); save() пишет только изменения
change_capture = ChangeCapture(LoanEvent) # каждая запись попадает и в outbox
with Tenancy.scope(library.id):
overdue = await Loan.objects.filter(status="overdue") # WHERE library_id = ... AND deleted_at IS NULL
await overdue[0].delete() # мягкое удаление
restored = await Loan.objects.only_deleted().get(id=loan_id)
await restored.restore()
class CatalogRecord(VersionedModel): # новая строка на каждую версию, ключ (id, version)
title = fields.CharField(max_length=200)
current = await CatalogRecord.get_last_version_or_exception(id=record_id)
draft = await current.create_new_version(title="Second draft")
- Разделение данных по арендаторам по колонке, по схеме на арендатора или силами самой базы через защиту строк: ограничение арендатором доходит и до присоединённых моделей и заполняет арендатора новой строки.
- Мягкое удаление каскадом помечает связанные
строки, которые тоже удаляются мягко, а
restore()возвращает их. - Transactional outbox записывает событие в транзакции изменения, о котором оно сообщает, и доставляет его хотя бы один раз, по порядку для каждой строки, в Kafka, RabbitMQ, Redis, вебхук или taskiq.
Схема в Meta¶
Ограничения, индексы, триггеры, представления, функции и построчная защита тоже объявляются в Meta, и
их создают миграции — см. Ограничения и триггеры и
Представления, функции, последовательности и доступ.
from hare.ddl import (
CheckConstraint, DatabaseFunction, MaterializedView, PartialIndex, Policy, RawSQLTerm,
RowLevelSecurity, TenantCondition, Trigger, TriggerEvent, TriggerTiming, UniqueConstraint, View,
)
from hare.dialects.postgresql.indexes import GinIndex
from hare.query.expressions import Q
from hare.query.queryset import ModelQuery
class Invoice(Model):
id = fields.IntField(primary_key=True)
tenant_id = fields.IntField()
number = fields.CharField(max_length=20)
amount = fields.DecimalField(max_digits=12, decimal_places=2)
status = fields.CharField(max_length=10)
tags = fields.JSONField(default=list)
class Meta:
tenant_field = "tenant_id"
constraints = (
CheckConstraint(name="invoice_amount_positive", check=Q(amount__gt=0)),
UniqueConstraint(fields=("tenant_id", "number"), condition=Q(status="open")),
)
indexes = (PartialIndex(fields=("tenant_id",), condition=Q(status="open")), GinIndex(fields=("tags",)))
triggers = (
Trigger(
name="invoice_no_negative",
on=TriggerEvent.UPDATE,
timing=TriggerTiming.BEFORE,
when=Q(amount__lt=0),
body=RawSQLTerm("RAISE EXCEPTION 'negative amount'; RETURN NEW;"),
),
)
views = [View("open_invoices", query=ModelQuery().all_tenants().filter(status="open").values("id"))]
materialized_views = [
MaterializedView(
"invoice_totals",
query=RawSQLTerm("SELECT tenant_id, sum(amount) AS total FROM invoice GROUP BY tenant_id"),
unique_columns=("tenant_id",),
)
]
functions = [
DatabaseFunction(
"current_tenant",
returns="integer",
body=RawSQLTerm("SELECT current_setting('app.tenant')::integer"),
language="sql",
)
]
row_level_security = RowLevelSecurity.FORCED
policies = [Policy(name="invoice_tenant", using=TenantCondition())]
Каждый из этих объектов создаёт migrate, меняет при изменении объявления и удаляет, когда его убирают;
hare drift показывает таблицы, колонки, индексы, ограничения и триггеры, которые в базе разошлись с
моделями. Условия — это объекты Q по собственным полям модели, поэтому при переименовании поля оно
переименовывается и в них; готовый SQL — всегда явный RawSQLTerm(...). Таблицы PostgreSQL можно ещё и
секционировать по хешу, списку или диапазону, а рядом объявить
ExclusionConstraint, откладываемые ограничения, последовательности и права.
Миграции¶
Команда hare находит изменения моделей, применяет их и проверяет на работающей базе — см.
Команда hare.
hare makemigrations # найти изменения моделей
hare migrate # применить их
hare checkmigrations # найти операции, которые заблокируют или перепишут большую таблицу
hare drift library # сравнить модели приложения с живой базой
hare inspectdb > models.py # создать модели по существующей базе
hare squashmigrations library 0010
WARNING: risky on a database in use if the tables are large - `hare checkmigrations` checks against the database:
app.0007_remove_book_pages: Remove field pages from Book [remove_field]
The column of Book.pages is dropped while the code still running reads it.
Safely: First remove the field from the models only - SeparateDatabaseAndState(state_operations=[RemoveField(model_name='Book', name='pages')]) - and drop the column with RunSQL in a later migration, once no running code reads it.
- Правила миграций без простоя знают, какие операции переписывают
таблицу, надолго её блокируют или ломают ещё работающий код, и подсказывают, как сделать каждую
безопасно:
AddIndex(..., concurrently=True),AddConstraint(..., not_valid=True)сValidateConstraint,BackfillColumnпорциями иAlterColumnNotNullSafeуже есть. - Переименования, миграции данных (
RunPython,RunSQL), слияние разошедшейся истории, миграции между приложениями и подключениями, миграции без файлов для моделей, зарегистрированных во время работы, и командаhareсshell,dbshell,sqlmigrateиstubs.
Транзакции и подключения¶
Transactions открывает транзакции, точки сохранения, независимые и распределённые транзакции — см.
Транзакции.
from hare import Transactions
async with Transactions.atomic(lock_timeout=2.0):
await loan.save()
Transactions.on_commit(lambda: send_due_date_reminder(loan.id)) # выполнится после фиксации
async for attempt in Transactions.atomic(isolation="serializable", retries=3):
async with attempt: # повтор после сбоя сериализации
await lend(book_id, reader_id)
async with Transactions.autonomous() as connection: # фиксируется, даже если вызывающий откатится
await CatalogImport.objects.using(connection).create(status="started")
async with Transactions.distributed(coordinator="library", participants=["billing"]) as distributed:
await Loan.objects.using(distributed.coordinator).create(...) # двухфазная фиксация
await Deposit.objects.using(distributed["billing"]).create(...)
book = await Book.objects.using("replica").get(id=book_id) # его связи тоже читаются из "replica"
Точки сохранения вкладываются друг в друга, statement_timeout/lock_timeout/read_only задаются для
каждой транзакции, маршрутизатор направляет чтение и запись в
свои базы, а за PgBouncer 1.22.1+ подготовленные команды продолжают
работать.
Базы данных¶
SQLite, PostgreSQL и ClickHouse — диалекты одного API; что есть у каждой базы — см. Диалекты и их возможности.
| SQLite | PostgreSQL | ClickHouse | |
|---|---|---|---|
| Драйвер | aiosqlite |
rust_pg (по умолчанию) или asyncpg |
clickhouse-connect (HTTP) или clickhouse-driver (родной протокол TCP) |
| Транзакции | ✓ | ✓ | с transactions=true на сервере с ClickHouse Keeper |
Внешние ключи и on_delete |
✓ | ✓ | связи с db_constraint=False, on_delete выполняет hare |
UPDATE на месте |
✓ | ✓ | мутация, которая завершается до возврата из вызова, или лёгкий UPDATE |
Полнотекстовый поиск (__search, SearchRank, SearchHeadline) |
FTS5 | tsvector |
— |
Векторный поиск (VectorField, CosineDistance) |
sqlite-vec | pgvector | — |
Геометрия (PointField, __dwithin, __contains) |
SpatiaLite | PostGIS | гео-типы ClickHouse |
from hare import Connections
from hare.dialects.postgresql.functions import TrigramSimilarity
from hare.search import SearchQuery, SearchRank, SearchType
from hare.vectors import CosineDistance
await Book.objects.filter(description__search=SearchQuery("white whale", search_type=SearchType.PHRASE))
await Book.objects.annotate(rank=SearchRank(("title", "description"), "white whale")).order_by("-rank")
await Book.objects.annotate(distance=CosineDistance("embedding", query_vector)).order_by("distance")[:10]
await Author.objects.annotate(score=TrigramSimilarity("name", "Gerard")).filter(score__gt=0.3)
listener = await Connections.get("default").listen("book_returned", on_notify) # LISTEN/NOTIFY в PostgreSQL
PostgreSQL добавляет ArrayField, поля диапазонов и мультидиапазонов, HStoreField, CitextField,
PostGIS, поиск по триграммам и unaccent, а также индексы GIN,
GiST, BRIN, Bloom, Hash, SP-GiST, IVFFlat и HNSW. Таблицы
ClickHouse объявляют движок, ключ сортировки, секционирование,
TTL, проекции и распределение по кластеру; ClickHouse добавляет поля массивов, словарей, кортежей и
Variant/Dynamic, final(), prewhere(), limit_by() и with_totals(), свои агрегаты,
материализованные представления и словари. Другая база —
это пакет диалекта.
Веб-API и интеграции¶
Запрос из параметров HTTP-запроса превращает их в запрос, а интеграции с фреймворками открывают ORM вместе с приложением — см. Веб-фреймворки.
from pydantic import BaseModel, ConfigDict
from hare.contrib.frameworks import PageSchema
from hare.contrib.frameworks.fastapi import HareFastAPI, RequestQueryDependency
from hare.contrib.request_query import OffsetPagination, OrderingConfig, Page, RequestQuery, SearchConfig
class BookSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
class BookQuery(RequestQuery[Book]): # ?author=3&search=dark&ordering=-published_at&limit=20
author: int | None = None
title__icontains: str | None = None
class Meta:
queryset = Book.objects.all().select_related("author")
search = SearchConfig(fields=("title", "author__name"))
ordering = OrderingConfig(fields=("title", "published_at"), default=("-published_at",))
pagination = OffsetPagination(default_limit=50, max_limit=200)
app = HareFastAPI(hare_config=HARE_CONFIG, atomic_requests=True) # каждый запрос — в транзакции
@app.get("/books", response_model=PageSchema[BookSchema])
async def list_books(books: BookQuery = RequestQueryDependency.provide(BookQuery)) -> Page[Book]:
return await books.page()
Запросы из параметров превращают параметры HTTP-запроса в проверенный запрос с фильтрами, поиском, сортировкой и постраничным выводом. Интеграции с Litestar, FastAPI и Robyn открывают ORM вместе с приложением, могут выполнять каждый запрос в транзакции и превращают ошибки ORM в HTTP-статусы; схемы Pydantic создаются по моделям, а воркеры taskiq выполняют каждую задачу в пределах арендатора кода, который её отправил, помечают её запросы задачей, а сама задача отправляется только после фиксации транзакции, которая её поставила.
Наблюдаемость¶
Наблюдатели видят каждый запрос, изменённые строки и транзакции; на них построены OpenTelemetry и детектор N+1 — см. Наблюдатели и Поиск повторяющихся запросов (N+1).
from hare.contrib.opentelemetry import OpenTelemetryInstrumentor
from hare.contrib.repeated_queries import RepeatedQueryDetector
from hare.instrumentation import Observers, QueryExecuted, QueryTags
OpenTelemetryInstrumentor().instrument() # спан на каждый запрос, метрики пула
Observers.observe(QueryExecuted, lambda event: metrics.observe(event.duration_ms))
async with RepeatedQueryDetector(threshold=10, window_seconds=1.0): # предупреждает о N+1 прямо во время работы
with QueryTags.scope(job="sync_orders"): # ... /*job='sync_orders'*/ у каждого запроса
await sync_orders()
Наблюдатели видят и изменённые строки, и события транзакций; журнал медленных запросов, проверки здоровья пула и метрики дополняют картину. См. Наблюдаемость.
Тесты и проверка типов¶
Фабрики создают объекты теста, плагин pytest даёт каждому тесту базу, а плагин mypy проверяет запросы до запуска — см. Настройка тестов.
from hare.contrib.factories import ModelFactory, Sequence, SubFactory
class AuthorFactory(ModelFactory[Author]):
name = Sequence(lambda number: f"Author {number}")
class BookFactory(ModelFactory[Book]):
isbn = Sequence(lambda number: f"{number:013d}")
title = Sequence(lambda number: f"Book {number}")
price = 10
author = SubFactory(AuthorFactory)
async def test_list_books(hare_db, hare_assert_query_count): # фикстуры встроенного плагина pytest
await BookFactory.create_batch(3)
async with hare_assert_query_count(1):
await Book.objects.select_related("author")
Book.objects.filter(titel="War")
# error: Unknown filter param 'titel': Book has no field 'titel' [hare-query]
rows = await Book.objects.values("id", "title", "author__name")
reveal_type(rows[0]) # TypedDict({'id': int, 'title': str, 'author__name': str})
Плагин pytest создаёт тестовые базы, изолирует каждый тест транзакцией, которая затем откатывается, и
запускает тест только там, где у базы есть нужные ему возможности;
плагин mypy и заглушки для pyright проверяют фильтры, сортировки и
values() по моделям.
Что дальше¶
- Установка и Первая модель — от пустого проекта до базы с применёнными миграциями.
- Разделы навигации описывают каждое поле, опцию
Meta, методQuerySet, операцию миграции и возможность базы по коду: что делает код, то и сказано на странице.