Перейти к содержанию

Разделение данных по арендаторам

Meta.tenant_field ограничивает каждый запрос через менеджер по умолчанию к модели арендаторами (tenant), которые сейчас действуют для неё: это фильтр по колонке арендатора, который добавляется так же, как мягкое удаление добавляет фильтр по колонке удаления. Таблицы арендатора можно вместо этого держать в его отдельной схеме — см. Схема на арендатора.

class Order(Model):
    company_id = fields.IntField(db_index=True)
    ...
    class Meta:
        tenant_field = "company_id"
from hare.models.tenancy.tenancy import Tenancy

with Tenancy.scope(company.id):
    await Order.objects.filter(status="pending")   # WHERE company_id = <company.id> AND status = 'pending'
    await Order.objects.create(total=10)                     # company_id заполняется из области — см. ниже

tenant_field может называть и прямую связь ForeignKeyField/OneToOneField — tenant_field = "company" для company = fields.ForeignKeyField("models.Company") означает её колонку ключа company_id, и Order._meta.tenant_field читает "company_id".

Tenancy.scope(tenant)

from hare.models.tenancy.tenancy import Tenancy

with Tenancy.scope(company.id):                          # одно значение для всех моделей
    ...

with Tenancy.scope(Tenancy.any_of("msk", "kzn")):        # несколько значений для всех моделей
    ...

with Tenancy.scope(Tenancy.ALL):                         # все арендаторы
    ...

with Tenancy.scope({Order: Tenancy.any_of("msk", "kzn"), SalesSummary: 2, Faq: Tenancy.ALL}):
    ...                                                  # у каждой модели свои арендаторы
tenant Какие строки видит запрос Строка записывается без арендатора Строка записывается с арендатором
Значение WHERE <tenant_field> = value получает это значение должно быть этим значением
Tenancy.any_of(*values) WHERE <tenant_field> IN (values) QueryError — назовите его должно быть одним из значений
Tenancy.ALL строки всех арендаторов, без фильтра QueryError — назовите его любое значение
Словарь по моделям каждая модель — по своей записи, одной из трёх выше по записи модели по записи модели

Значение, Tenancy.any_of(...) и Tenancy.ALL, переданные напрямую, действуют на все модели одинаково: Meta.tenant_field только называет, какую колонку использует модель (company_id у одной, org_id у другой), а область сравнивается с той колонкой, которую назвала каждая модель. Это подходит моделям, разделённым одними и теми же арендаторами. Модели, разделённые разными полями с разными наборами значений (заказы — по store: "msk"/"kzn", сводки продаж — по store_id: 1/2, вопросы и ответы — по group), получают словарь. Его ключи — классы моделей или их метки "app.Model" ({"shop.Order": "msk"}), которые ищутся в текущем контексте.

Tenancy.any_of() требует хотя бы одно значение и не принимает None — иначе QueryError (это ValueError): «ни одного значения» не означает ни «все арендаторы», ни «ни одной строки». Повторённое значение учитывается один раз, а Tenancy.any_of(value) с одним значением — та же область, что и само value. Список, кортеж или множество в качестве области (или записи модели) дают QueryError: несколько значений всегда записываются как Tenancy.any_of(*values).

Область по моделям

Ключи словаря — классы моделей с Meta.tenant_field или Meta.tenant_schema; всё остальное, а также None в качестве записи, даёт QueryError при входе в область. Модель берёт:

  1. запись своего класса;
  2. иначе — запись ближайшего базового класса с тем же Meta.tenant_field (у модели с Meta.tenant_schema без него — с Meta.tenant_schema): одна запись для абстрактной базовой модели покрывает все модели, построенные на ней, а собственная запись модели её перекрывает;
  3. иначе у неё нет области: она ведёт себя в точности как вне любого Tenancy.scope() — запрос даёт QueryError (см. ниже), запись доверяет арендатору, который она называет. Чтобы намеренно не ограничивать модель, дайте ей Tenancy.ALL.

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

Каждая часть одного запроса читает запись своей модели: собственный фильтр запроса, соединение со связанной моделью, второй запрос prefetch_related(), промежуточная таблица и цель связи «многие-ко-многим».

with Tenancy.scope({Order: Tenancy.any_of("msk", "kzn"), Customer: "north"}):
    # заказы двух магазинов, соединённые только с клиентами севера
    await Order.objects.filter(customer__name__startswith="A")

Запись модели с Meta.tenant_schema — одно значение арендатора: её таблицы читаются в схеме одного арендатора, поэтому Tenancy.any_of(...) из нескольких значений и Tenancy.ALL дают QueryError при входе в область. Подключение работает в схеме арендатора, которого область даёт его моделям с Meta.tenant_schema, — см. Запросы.

Регистрация или удаление живых моделей обновляет выбор схемы подключения и арендаторов таблиц для новой транзакции с row-level security, в том числе внутри уже открытой области. Начатая транзакция сохраняет настройки арендаторов, с которыми она открылась. Повторное использование области моделей в другом HareContext учитывает модели этого контекста, даже если оба контекста используют одинаковый alias подключения.

with Tenancy.scope({Invoice: "acme", Order: Tenancy.any_of("msk", "kzn"), Faq: Tenancy.ALL}):
    await Invoice.objects.count()      # tenant_acme.invoice
    await Order.objects.count()        # public.order, два магазина

Tenancy.get_scope(model) даёт область модели в текущей задаче: значение, объект Tenancy.any_of(...) (его values — кортеж значений), Tenancy.ALL или None, когда области нет или действующая область не называет модель. Пользуйтесь им вместо чтения Tenancy.current.get(), где лежит область целиком — для словаря это объект, у которого на тот же вопрос отвечает get_for_model(model). Tenancy.get_values(scope) превращает область модели в кортеж значений, которыми ограничены её строки, — одно значение или значения any_of, — либо в None, когда они не ограничены (области нет или это Tenancy.ALL).

Время действия

Вложенность работает правильно: вложенный Tenancy.scope(...) заменяет внешнюю область для своих запросов — области не сливаются, так что внутри вложенного словаря у модели, которой в нём нет, области нет, — а после выхода из него снова действует область внешнего блока. У каждой задачи asyncio своя область.

Запрос выполняется для области, действующей в момент его ожидания (await), и всё, что он тогда выполняет, ограничено этой одной областью: его собственный фильтр, каждое его соединение таблиц, построенные из него .values()/.values_list()/.count(), его использование как подзапроса (field__in=queryset, Subquery(...), Exists(...)) и второй запрос .prefetch_related().

Значение области сравнивается с арендатором строки после преобразования поля арендатора, поэтому для арендатора в UUIDField Tenancy.scope(uuid_value) и Tenancy.scope(str(uuid_value)) — один и тот же арендатор на любой базе.

Tenancy.set(tenant)/Tenancy.reset(token) — более простые инструменты, на которых построен сам Tenancy.scope(), для случая, когда один блок with не может охватить всё время жизни области (например, через await, охватывающий несколько вызовов обработчиков), — так же, как пара QueryTags.set()/QueryTags.reset() у QueryTags.

Без активной области

Запрос к модели с арендаторами, у которой нет области, — Tenancy.scope() не действует или действующая область — словарь, в котором модели нет, — сразу даёт QueryError: намеренно, а не молча фильтрует по tenant_field = None (тогда запрос просто не нашёл бы ни одной строки, и это выглядело бы как «данных нет», а не как «вы забыли задать область»). Если вы на это наткнулись, оберните вызов в Tenancy.scope(...) или добавьте модель в словарь.

Все арендаторы: all_tenants()

await Order.objects.all_tenants().filter(status="pending")   # все арендаторы, без ограничения

Возвращает QuerySet, полностью обходящий автоматический фильтр, — для административных инструментов, фоновых задач и миграций, которым намеренно нужны данные всех арендаторов. Модели с арендаторами, до которых запрос доходит через связи (фильтр по связи, Count("docs"), values("docs__title") и т. п.), тоже охватывают всех арендаторов. У модели без собственного Meta.tenant_field это единственное, что он делает, — так выполняется запрос через связь от общей модели без активного арендатора:

await Tag.objects.all_tenants().filter(docs__isnull=True)          # теги, на которые не ссылается ни один арендатор
await Tag.objects.all_tenants().annotate(uses=Count("docs"))       # связи всех арендаторов
await tag.docs.clear(all_tenants=True)                     # связи этого тега у всех арендаторов

Как и include_deleted(), он строит запрос без условия арендатора, а не снимает уже применённое.

.all_tenants().delete() — намеренное удаление через всех арендаторов: оно удаляет каждую найденную строку, какой бы ни была активная область, — и когда выполняется одной командой, и когда удаляет строки по одной (у модели, на которую ссылаются другие модели, каскад идёт по строкам).

Защита при записи

Каждая запись проверяется по области той модели, в которую пишет (Tenancy.get_scope(model)): «значение области» ниже — единственное значение этой области, а «внутри области» означает это значение, одно из значений Tenancy.any_of(...) или что угодно при Tenancy.ALL.

  • create() и save() новой строки заполняют tenant_field значением области и дают QueryError, если строка называет арендатора вне области. При Tenancy.any_of(...) и Tenancy.ALL единственного значения для подстановки нет: строка обязана назвать арендатора, иначе QueryError. Так же ведут себя get_or_create() и update_or_create(), когда создают строку, — передайте арендатора в defaults/create_defaults. Если tenant_field — собственная колонка ForeignKeyField (tenant_field = "company_id" для company = fields.ForeignKeyField(...)), передача связи (create(company=company)) считается передачей арендатора: без области она принимается, внутри области — проверяется.
  • save() существующей строки записывает только строку, арендатор которой — и тот, что на объекте, и тот, что хранится в базе, — внутри области: UPDATE несёт область в своём WHERE, поэтому объект, собранный вручную вокруг первичного ключа чужой строки, ничего не записывает. Смена арендатора у объекта и его сохранение переносят строку — только к арендатору внутри области. delete() и restore() объекта проверяются так же.
  • Прямая связь не может указывать на строку, которую не показывает область её собственной модели. create(), save(), .update(...), bulk_create() и bulk_update() читают арендатора каждой строки с арендаторами, на которую указывает записываемая связь (по одному запросу на связь и команду), и дают QueryError, если он вне области связанной модели, — и из модели с арендаторами, и из общей модели, так же как add() у «многие-ко-многим» отклоняет такую строку. При одном значении для всех моделей это означает «связанная строка принадлежит текущему арендатору». Связанная модель без области или с Tenancy.ALL не проверяется, как и .all_tenants().update(...), merge() и insert_from() в цель .all_tenants() и записи вовсе без области (начальные данные, которым доверяют, как явному tenant_field). Записи объектов — create(), bulk_create(), bulk_update() и остальные — проверяются под активной областью и через .all_tenants(): он расширяет строки, которые читает запрос, а не строки, на которые указывает объект. Ключ, которому не соответствует ни одна строка, оставляется базе, — кроме ключа в таблицу политики TenantCondition на подключении с tenant_row_level_security: политика скрывает от проверки строки других арендаторов, а внешний ключ базы их всё равно принимает, поэтому ключ, строку которого область не показывает, даёт QueryError. В ClickHouse, где ключ не уникален, строки двух арендаторов могут иметь один ключ: ключ проходит, если одна из его строк входит в область, — и у add() «многие-ко-многим», — а удаление под областью распространяется только на строки этой области.
  • add() у «многие-ко-многим» заполняет tenant_field промежуточной модели значением области промежуточной модели, а при области из нескольких значений требует его в through_defaults (см. Промежуточная таблица-модель).
  • .update(<tenant_field>=value) переносит строки только внутри области. value должно быть простым значением внутри области модели: при одном значении это то значение, которое у строк уже есть, при Tenancy.any_of("msk", "kzn") заказ переносится между двумя магазинами. Значение вне области, None, выражение и любое такое обновление без области или на .all_tenants() дают QueryError — None для поля-арендатора без null=True раньше даёт ValidationError самого поля. .update(company=...) для связи-арендатора подчиняется тому же правилу, что и .update(company_id=...).
  • bulk_update() явно проверяет арендатора каждого объекта. bulk_update() находит строки только по их первичному ключу (как Model.save()) и намеренно не идёт через тот же путь автоматического фильтра, что .filter()/.all(), поэтому до отправки чего-либо он проверяет, что у модели есть область и что каждый обновляемый объект принадлежит арендатору внутри неё, иначе даёт QueryError с первичными ключами несовпадающих. Без этой явной проверки вызывающий, как-то получивший первичные ключи другого арендатора, мог бы молча перезаписать его строки.
  • bulk_create() требует области. Если у модели её нет, он даёт QueryError, даже когда у каждого объекта уже есть значение tenant_field (в отличие от create(), который вне области доверяет явному арендатору). Внутри области объект без значения tenant_field получает значение области — при нескольких значениях или Tenancy.ALL это QueryError, — а объект с арендатором вне области заставляет весь вызов дать QueryError с первичными ключами несовпадающих.
  • bulk_create(update_fields=...) перезаписывает только строки внутри области. ON CONFLICT находит физическое ограничение уникальности таблицы независимо от арендатора, поэтому DO UPDATE получает WHERE <tenant column> = <value> (IN (<values>) для нескольких): конфликтующая строка арендатора вне области остаётся нетронутой. Объекты тогда не помечаются сохранёнными (вызов не может понять, какие из них записаны), если только returning=True с on_conflict=[...] не сопоставляет им записанные строки; объект, строка которого пропущена, ничего не получает из RETURNING. При Tenancy.ALL и под .all_tenants() не применяется.
  • QuerySet.delete() и .update() достают строки внутри области, как любой запрос. Каскад от удалённой строки идёт по её внешним ключам к связанным строкам всех арендаторов, а restore(cascade=True) возвращает то, что это удаление забрало с собой, — см. мягкое удаление.

Связи с моделью с арендаторами

Прямая связь из любой модели на модель с арендаторами видит только цель внутри области самой связанной модели, как мягко удалённую цель: note.doc, select_related(), values("doc__title") дают None для цели другого арендатора, а doc__isnull=True (doc=None, doc__not_isnull=False) находит такую строку, doc__isnull=False — нет. Проверка на NULL колонок самой цели с этим согласна: у скрытой цели нет видимых колонок, поэтому doc__title__isnull=True (doc__title=None) находит те же строки, что и doc__isnull=True, и в filter(), и в exclude(). Колонка ключа (doc_id__isnull, doc_id=...) по-прежнему сравнивает записанное значение. Чтение связи или проверка связи на NULL такого рода требует активной области (или .all_tenants() у фильтруемого запроса, который убирает часть проверки, относящуюся к арендатору) — как и values("doc__title"). То же относится к цели, которую отсекает собственный Meta.manager.

Связь «многие-ко-многим» с моделью с арендаторами ограничивается так же, с любой стороны: Tag.objects.filter(docs=doc_pk), docs__in, docs__not, docs__not_in, их варианты в exclude() и docs__isnull учитывают только связи со строками активного арендатора (не мягко удалёнными и не скрытыми собственным Meta.manager), в точности как docs__title=.... Так же — каждый путь через связь: docs__id__isnull, docs__title=..., values("docs__id"), order_by("docs__title"), F("docs__title"), Count("docs"): связь со скрытой строкой не добавляет ни строки, ни своего NULL. tag.docs.clear() и tag.docs.set(...) тоже удаляют только такие связи — связь того же тега со строкой другого арендатора остаётся; add()/remove() сразу отклоняют связанный объект другого арендатора, а add() отклоняет ещё и строку, которую база показывает мягко удалённой или скрытой Meta.manager связанной модели (в том числе устаревший или собранный вручную объект).

Используется ли общая строка, зависит от текущего арендатора

Для модели без Meta.tenant_field (тег, общий для всех арендаторов) «есть связи» означает «есть связи со строками, которые видит текущий арендатор». Внутри Tenancy.scope(1) Tag.objects.filter(docs__isnull=True).delete() удалит и тег, который используют только документы арендатора 2. Очищайте общие строки без активного арендатора, через Tag.objects.all_tenants() (см. выше), чтобы учитывались связи всех арендаторов.

Маршрутизаторы

Маршрутизатор, выбирающий базу по арендатору (шардинг), выполняется с той областью, для которой выполняется его запрос, — той, что действует в момент ожидания QuerySet (await), и она же остаётся у запросов, которые он выполняет при этом (prefetch_related(), подзапрос), — поэтому выбранное им подключение держит тех же арендаторов, по которым фильтрует запрос. Tenancy.get_scope(model) внутри db_for_read(model)/db_for_write(model) даёт часть области для этой модели: одно значение; объект Tenancy.any_of(...), значения которого маршрутизатор должен свести к одной базе или отказать; Tenancy.ALL; или None. Tenancy.current.get() по-прежнему читает область целиком — для области из одного значения это само значение.

class StoreRouter:
    def db_for_read(self, model):
        stores = Tenancy.get_values(Tenancy.get_scope(model))
        if stores is None:                                # области нет или все арендаторы
            return None                                   # подключение по умолчанию
        shards = {SHARD_BY_STORE[store] for store in stores}
        if len(shards) > 1:
            raise RuntimeError(f"{model.__name__}: stores {stores} are on different shards")
        return shards.pop()

    db_for_write = db_for_read

Входные модели pydantic

pydantic_model_creator() делает Meta.tenant_field необязательным во входной модели: если его не передать, create() заполнит его значением области. При области из нескольких значений (или Tenancy.ALL) запрос обязан его передать — оно принимается и проверяется по области, как любое значение create(), а его отсутствие даёт QueryError.

Вместе с Meta.soft_delete_field

Модель может задать обе опции сразу — Manager.get_queryset() добавляет оба фильтра независимо, поэтому у модели с обеими опциями они оба применяются к каждому запросу через менеджер по умолчанию. Но способы обхода друг друга не включают: all_tenants() по-прежнему учитывает soft_delete_field (мягко удалённые строки остаются скрытыми, если не вызвать ещё и include_deleted()), а include_deleted() по-прежнему учитывает tenant_field (чтобы видеть всех арендаторов, нужна активная область или all_tenants()).

Кэш планов запросов

Фильтр арендатора у запроса к самой модели с арендаторами — обычное значение фильтра, добавляемое так же, как фильтр deleted_at=None у soft_delete_field, поэтому кэш планов запросов подставляет его при каждом вызове, как любое другое значение фильтра, и арендатор не входит в ключ плана — в отличие от имени часового пояса у операторов по частям даты, которое при попадании в кэш не подставляется. Область из нескольких значений — обычный фильтр __in на том же месте.

Запрос, доходящий до модели с арендаторами через связь, ставит арендатора в условие ON у JOIN. Его план хранит значения этой области отдельно от собственных значений запроса, и запрос, идущий по плану, подставляет область, активную при его выполнении; область, фильтр которой устроен иначе (другое число арендаторов в Tenancy.any_of(...)), строит запрос заново целиком. То же верно для модели, которую фильтрует собственный Meta.manager, — см. Кэш планов запросов.

Ограничения уникальности и исключения

Ни одно из них не учитывает арендаторов. UniqueConstraint и ExclusionConstraint PostgreSQL (обычный способ обеспечить «у двух строк для одного ресурса диапазоны не пересекаются» — см. DateTimeRangeField) проверяются только по названным колонкам — никакого автоматического учёта арендатора под ними нет. Две строки с одинаковыми значениями fields/ expressions у разных арендаторов всё равно столкнутся, если не добавить колонку арендатора в ограничение явно (например, ExclusionConstraint(expressions=[("company_id", "="), ("resource", "="), ("during", "&&")])).

Как включать

В отличие от большинства других необязательных опций Meta, включение tenant_field меняет поведение каждого существующего запроса к этой модели — включайте так:

  1. Добавляйте Meta.tenant_field по одной модели, начиная с малонагруженной.
  2. Проверьте каждое место, где запрашивается эта модель, на отсутствие Tenancy.scope(), прежде чем полагаться на автоматический фильтр; all_tenants() — мост для кода, который вы ещё не обновили.
  3. Только после этого переходите к следующей модели.

Фильтр выше выполняется в Python, в Manager.get_queryset(), — написанный вручную SQL или ошибка, обходящая менеджер по умолчанию, им не покрываются. В PostgreSQL то же самое может проверять и сама база — см. Защиту строк на уровне базы.

Защита строк на уровне базы (PostgreSQL)

В PostgreSQL база сама может держать каждую транзакцию в пределах её арендаторов для модели с Meta.tenant_field — какой бы SQL в транзакции ни выполнялся: нужны Policy с TenantCondition() и подключение с tenant_row_level_security:

from hare.ddl import Policy, RowLevelSecurity, TenantCondition


class Order(Model):
    company_id = fields.IntField(db_index=True)
    ...
    class Meta:
        tenant_field = "company_id"
        row_level_security = RowLevelSecurity.FORCED
        policies = [Policy(name="order_tenant", using=TenantCondition())]
config = {**HARE_CONFIG, "connections": {"default": "postgresql://app@localhost/app?tenant_row_level_security=true"}}
  • Арендаторы транзакции. Каждая транзакция подключения сразу после BEGIN записывает своих арендаторов из Tenancy.scope(), активной при её начале, — SELECT set_config('hare.tenant', ..., true), JSON-массив значений арендаторов текстом: одно значение, значения Tenancy.any_of(...) или все арендаторы при Tenancy.ALL. В области по моделям это JSON-объект арендаторов каждой таблицы по её имени (после Meta.schema, если она есть: {"order": ["msk", "kzn"], "shop.faq": "*"}) — каждой таблицы моделей подключения с политикой TenantCondition, которые область называет. Настройка действует только внутри транзакции — заканчивается вместе с ней, и соединение из пула не переносит её в следующую.
  • Политика. TenantCondition() записывается как «колонка арендатора строки — один из арендаторов транзакции» — в области по моделям из записи своей таблицы (при Tenancy.ALL — любая строка); в using политики ALL оно проверяет и каждую записываемую строку. Арендаторы читаются один раз на оператор, а не на каждую строку.
  • Нет арендатора — нет строк. Транзакция, начатая без области, арендаторов не записывает, а область по моделям не даёт их таблице модели, которую не называет, — политика не даёт транзакции ни видеть, ни писать эти строки, в том числе через написанный вручную SQL.
  • Запросы через ORM к такой модели выполняются внутри транзакции, записавшей арендаторов: вне транзакции, в транзакции без арендатора или в области по моделям, не называющей модель, они дают QueryError до отправки SQL, а не пустой результат. Запрос в другой области, чем та, в которой транзакция началась, тоже даёт QueryError: настройка транзакции за ним не последует.
  • Модель с политикой TenantCondition на подключении без tenant_row_level_security даёт ConfigurationError на первом запросе, а без Meta.tenant_field — при объявлении.
  • Роли. PostgreSQL не применяет row level security к суперпользователю и к роли с BYPASSRLS, а к владельцу таблицы — только при RowLevelSecurity.FORCED; подключайтесь ролью, к которой она применяется.
  • Фильтр на стороне Python остаётся как есть — политика проверяет под ним второй раз, а не заменяет его.
  • Смена Meta.tenant_field сама текст политики не меняет — переименуйте политику в том же изменении, чтобы миграция удалила её и создала заново.

В SQLite и ClickHouse row level security нет: Policy там даёт UnSupportedError до отправки SQL.