Схема на арендатора¶
При схеме на арендатора (PostgreSQL) таблицы части моделей у каждого арендатора лежат в его собственной
схеме базы — tenant_acme.invoice, tenant_7.invoice, — а остальные модели держат одну таблицу на всех
арендаторов в собственной схеме подключения (public). Запрос не несёт фильтра по арендатору: схему
выбирает активная Tenancy.scope(tenant), а подключение попадает в
неё через путь поиска схем PostgreSQL (search_path). Это другой способ, чем
Meta.tenant_field (колонка арендатора в общей таблице): строки арендаторов
разделены на уровне базы, данные арендатора удаляются одним DROP SCHEMA, а каждый индекс и каждое
ограничение — свои у каждого арендатора.
Настройка¶
Подключение называет схемы арендаторов шаблоном tenant_schema_template — строчные латинские буквы,
цифры и _ вокруг одного {tenant}:
config = {
"connections": {
"default": {
"engine": "postgresql",
"credentials": {
"host": "localhost", "database": "app", "user": "app", "password": "...",
"tenant_schema_template": "tenant_{tenant}",
},
},
},
"apps": {"billing": {"models": ["billing.models"], "default_connection": "default"}},
}
В адресе подключения это параметр с закодированными скобками:
postgresql://app@localhost/app?tenant_schema_template=tenant_%7Btenant%7D. Любой другой шаблон (без
подстановки, с двумя подстановками, с заглавными буквами, с -) даёт ConfigurationError при настройке
подключения. В SQLite и ClickHouse схем на арендатора нет — Features.supports_tenant_schemas равен
False, и TenantSchemas даёт UnSupportedError до отправки SQL.
Модель, таблица которой лежит в схеме каждого арендатора, объявляет Meta.tenant_schema = True:
class Customer(Model): # общая — одна таблица в "public"
id = fields.IntField(primary_key=True)
name = fields.CharField(max_length=100)
class Invoice(Model): # своя таблица в схеме каждого арендатора
id = fields.IntField(primary_key=True)
customer = fields.ForeignKeyField("billing.Customer", related_name="invoices")
total = fields.DecimalField(max_digits=12, decimal_places=2)
class Meta:
tenant_schema = True
- Его нельзя задать вместе с
Meta.schema(ConfigurationError): схема таблицы — схема активного арендатора. - Модель арендатора может ссылаться на общую модель (
Invoice.customerвыше): её внешний ключ в схеме каждого арендатора ссылается на общую таблицу. У общей модели не может бытьForeignKeyField,OneToOneFieldилиManyToManyFieldна модель арендатора — на таблицу какого арендатора она бы ссылалась? —ConfigurationErrorназывает поле; объявите связь на модели арендатора. - Удаление общей строки —
delete(),hard_delete(),QuerySet.delete(), в области арендатора или вне любой — выполняетon_deleteстрок арендаторов, ссылающихся на неё, в схеме каждого арендатора, в одной транзакции: ихCASCADEи мягкое удаление,SET_NULL, а строкаPROTECTлюбого арендатора даётProtectedErrorдо того, как что-либо записано.restore(cascade=True)возвращает строки, удалённые вместе с ней, в схеме каждого арендатора, аdelete_preview()считает и перечисляет их все. Транзакция обходит схемы по очереди (SET LOCAL search_path), поэтому удаление читает список схем арендаторов и стоит нескольких запросов на каждого арендатора. Жёсткое удаление, которое во всех схемах выполняет собственныйON DELETEбазы, не заходит ни в одну — только ради связиPROTECT, связиdb_constraint=False, захвата изменений или переопределённогоdelete()модели арендатора. Схема без таблицы модели (созданная до её миграции) не хранит её строк и пропускается. - Модель с
Meta.tenant_schemaна подключении безtenant_schema_templateдаётConfigurationErrorна первом запросе и при создании её таблиц.
Значения арендатора¶
Схема арендатора — шаблон, в котором {tenant} заменён значением арендатора: int (7 → tenant_7)
или строкой из строчных латинских букв, цифр и _ ("acme" → tenant_acme). Любое другое значение —
"Acme", "acme-1", "", UUID (передавайте uuid.hex), float — даёт ValidationError, как и имя
схемы длиннее 63 байт PostgreSQL. Значение не приводится к другому виду: "Acme" отклоняется, а не
превращается в схему другого арендатора. 7 и "7" — одна схема.
Создание, удаление и список арендаторов¶
hare.models.tenancy.tenant_schemas.TenantSchemas:
from hare.models.tenancy.tenant_schemas import TenantSchemas
await TenantSchemas.create("acme") # CREATE SCHEMA tenant_acme + таблицы всех моделей с tenant_schema
await TenantSchemas.create(7, create_tables=False) # только схема — таблицы создаст `migrate`
await TenantSchemas.get_tenants() # ["7", "acme"] — схемы, имена которых даёт шаблон, текстом
await TenantSchemas.drop("acme") # DROP SCHEMA tenant_acme CASCADE — все таблицы и строки арендатора
Каждый метод принимает using= — имя подключения, необязательное при одном подключении; create()
возвращает имя схемы. Они работают вне области арендатора и вне транзакции — внутри дают QueryError
(схема, созданная в транзакции, ещё не видна собственному подключению арендатора). create() с
create_tables=True (по умолчанию) создаёт таблицы по текущим моделям, как generate_schemas(), — для
проекта без миграций и для тестов. Проект с миграциями создаёт схему с create_tables=False и запускает
migrate, который доводит до актуального состояния схему каждого арендатора (Миграции).
get_tenants() перечисляет схемы, имя которых даёт шаблон, поэтому схема tenant_beta, созданная
вручную, считается арендатором "beta". Запрос в области арендатора, схемы которого нет, даёт
ConfigurationError с её именем, пока схема не создана.
Запросы¶
Внутри Tenancy.scope(tenant) одного арендатора каждый запрос подключения выполняется клиентом схемы
этого арендатора: у его соединений путь поиска tenant_<tenant>, public (вместо public — собственная
настройка schema подключения, если она задана). Модель арендатора читает и пишет таблицу своего
арендатора; общая модель находится в public через тот же путь поиска — соединения таблиц двух видов
(select_related("customer")) обычные.
with Tenancy.scope("acme"):
await Invoice.objects.create(id=1, customer=customer, total=10)
await Invoice.objects.select_related("customer").all() # tenant_acme.invoice JOIN public.customer
with Tenancy.scope(7):
await Invoice.objects.count() # tenant_7.invoice — 0
- Область, заданная по моделям, работает в схеме того единственного
арендатора, которого она даёт моделям подключения с
Meta.tenant_schema, —Tenancy.scope({Invoice: "acme", Order: Tenancy.any_of("msk", "kzn")})читаетtenant_acme.invoice, а общую таблицуOrder— по её собственной записи. Модель арендатора берёт там одно значение (несколько илиTenancy.ALLдаютQueryErrorпри входе в область); значения, называющие одну схему (7и"7"), и здесь означают одного арендатора. Область, называющая разные схемы для моделей арендатора одного подключения, даётQueryErrorна запросе этого подключения — оно работает в одной схеме. - Модель арендатора без области, в области нескольких арендаторов (
Tenancy.any_of(...)), вTenancy.ALLили в области по моделям, не называющей ни одной модели арендатора подключения, даётQueryError— её таблица доступна только в области одного арендатора. Общая модель работает в любой из них через собственного клиента подключения. - QuerySet выполняется в схеме арендатора, область которого действует в момент его ожидания (
await), — так же, как QuerySet модели сMeta.tenant_fieldфильтрует по этому арендатору: построенный внутриTenancy.scope("acme")и дождавшийся внутриTenancy.scope(7), он читаетtenant_7, а без области даётQueryError. Запросы, которые он выполняет при этом (prefetch_related(), подзапрос), идут в ту же схему, как и у маршрутизатора. - Клиент схемы каждого арендатора — отдельный пул соединений, открывается при первом запросе и
закрывается вместе с подключением (
Hare.close_connections()). Его соединение, не использованное дольшеmax_inactive_connection_lifetimeподключения (по умолчанию 300 секунд), закрывается, поэтому сервер держит сессии арендаторов, к которым обращались недавно, а не всех, к которым обращались когда-либо. Внутри области его даётConnections.get(connection_alias);ConnectionHandler.get_main(connection_alias)всегда даёт само подключение. - При
tenant_schema_templateзначениеTenancy.scope()должно быть допустимым значением арендатора этого подключения даже для запроса к общей модели — область"Acme"даётValidationErrorна первом запросе подключения. Meta.tenant_fieldработает рядом: модель арендатора может объявить и колонку арендатора — она фильтруется внутри её схемы как обычно.
Транзакции¶
Транзакция работает в схеме арендатора, активного при её начале: все её запросы идут через одно
соединение. Внутри неё запрос в области другого арендатора или без арендатора, если она началась в схеме
арендатора (и наоборот), даёт QueryError, а не выполняется молча в схеме транзакции:
with Tenancy.scope("acme"):
async with Transactions.atomic():
await Invoice.objects.create(...) # tenant_acme
with Tenancy.scope("beta"):
await Invoice.objects.count() # QueryError — транзакция работает в tenant_acme
Транзакция, начатая в области по моделям, работает в схеме арендатора, которого область даёт моделям
арендатора; запрос в ней в области другой схемы так же даёт QueryError.
Transactions.autonomous() внутри области открывает своё соединение в схеме того же арендатора; каскад
удаления по схемам всех арендаторов, изменение, записанное в outbox схемы арендатора, и migrate
работают в области по моделям так же, как в области одного арендатора.
Миграции¶
migrate на подключении с tenant_schema_template работает в два шага:
- Общая схема — с журналом
public.hare_migrations. Операции над таблицами общих моделей выполняются, над таблицами моделей сMeta.tenant_schema— пропускаются. - Схема каждого арендатора (схемы из
TenantSchemas.get_tenants()), по порядку имён схем — каждая со своим журналомtenant_<tenant>.hare_migrations, внутриTenancy.scope(tenant)(значение арендатора текстом). Операции над таблицами моделей арендатора выполняются, над таблицами общих моделей — пропускаются.
Поэтому арендатор, добавленный позже (create(..., create_tables=False)), получает все миграции при
следующем migrate, а остальные — только недостающие. Состояние миграций одно для всех схем —
CreateModel(..., options={"tenant_schema": True}) записывает опцию.
Python API MigrationExecutor.migrate(targets) принимает и одноразовые итераторы: цели сохраняются,
чтобы каждая схема мигрировала до одних и тех же запрошенных миграций.
- Операция над таблицей модели (
CreateModel,AddField,AddIndex, представление или политика, объявленные на модели, ...) следуетMeta.tenant_schemaмодели. - Операция, не привязанная к модели, —
RunSQL,RunPython,CreateExtension,CreateSchema, тип-перечисление — выполняется в общей схеме.RunSQL(..., tenant_schema=True)иRunPython(..., tenant_schema=True)вместо этого выполняются в схеме каждого арендатора; функция такогоRunPythonвызывается по разу на арендатора, в его области, поэтому её запросы к моделям арендатора попадают в его таблицу. - Внутри
Tenancy.scope(tenant)migrateдоводит до цели только схему этого арендатора — общая схема и остальные арендаторы не меняются. Область по моделям мигрирует схему арендатора, которого она даёт моделям подключения сMeta.tenant_schema; если ни одну из них не называет,migrateидёт как без области. migrateдоapp.zeroоткатывает и общую схему, и схему каждого арендатора.sqlmigrateпоказывает SQL всех операций — и общих, и арендаторских — как для одной схемы.- Смену
Meta.tenant_schemaу существующей моделиmakemigrationsотклоняет (ConfigurationError): её строки переехали бы между общей схемой и схемами всех арендаторов. Напишите миграцию вручную — новая модель,RunPython, копирующий строки, затем удаление старой. hare driftсравнивает общие модели в общей схеме;detect_drift()с клиентом арендатора (Connections.get("default").get_schema_client("tenant_acme")) сравнивает модели сMeta.tenant_schemaв схеме этого арендатора. Ни тот, ни другой не считают таблицы другой стороны отсутствующими, а внешний ключ модели арендатора на общую модель читается, в какой бы схеме после схемы арендатора она ни была.
Колонка арендатора или схема на арендатора¶
Meta.tenant_field |
Meta.tenant_schema |
|
|---|---|---|
| Где строки арендатора | одна общая таблица, колонка арендатора | таблица в схеме арендатора |
| Разделение | фильтр ORM | база: таблицы другого арендатора нет на пути поиска |
| Запросы по нескольким арендаторам | Tenancy.any_of(...), Tenancy.ALL |
не через ORM — одна область, один арендатор |
| Удаление арендатора | DELETE по колонке арендатора |
TenantSchemas.drop(tenant) |
| Много арендаторов | одна таблица на всех | свой набор таблиц и индексов и свой пул соединений у каждого арендатора |
| Базы данных | все диалекты | PostgreSQL |