Представления, функции, последовательности и доступ¶
Модель объявляет в своём Meta объекты базы данных, которые живут рядом с её таблицей: представления,
материализованные представления, функции, последовательности, защиту на уровне строк с её политиками и
права ролей. Их создают Hare.generate_schemas() и миграции — makemigrations пишет операцию на каждый
добавленный, изменённый, переименованный или удалённый объект (см.
Операции), — а удаляются они вместе с моделью.
Представления и материализованные представления есть в PostgreSQL и ClickHouse (см. Представления
ClickHouse); функции, последовательности, защита на уровне
строк и права — только в PostgreSQL. На базе без такого объекта (SQLite, а для последних — и ClickHouse)
модель, объявившая его, и любая операция над ним вызывают UnSupportedError до отправки SQL — см.
признаки Features в Диалекты и возможности.
from hare import fields
from hare.ddl import (
DatabaseFunction,
DatabaseSequence,
FunctionVolatility,
Grant,
GrantTarget,
MaterializedView,
Policy,
PolicyCommand,
Privilege,
RawSQLTerm,
RowLevelSecurity,
View,
)
from hare.models import Model
from hare.query.queryset import ModelQuery
class Invoice(Model):
id = fields.IntField(primary_key=True)
number = fields.BigIntField(null=True)
tenant_id = fields.IntField()
amount = fields.IntField()
paid = fields.BooleanField(default=False)
class Meta:
sequences = [DatabaseSequence("invoice_number", start=1000, owned_by="number")]
functions = [
DatabaseFunction(
"current_tenant",
returns="integer",
body=RawSQLTerm("SELECT nullif(current_setting('app.tenant', true), '')::integer"),
language="sql",
volatility=FunctionVolatility.STABLE,
),
]
views = [View("paid_invoices", query=ModelQuery().filter(paid=True).values("id", "amount"))]
materialized_views = [
MaterializedView(
"invoice_totals",
query=RawSQLTerm("SELECT tenant_id, sum(amount) AS total FROM invoice GROUP BY tenant_id"),
unique_columns=("tenant_id",),
),
]
row_level_security = RowLevelSecurity.ENABLED
policies = [
Policy(
"tenant_rows",
command=PolicyCommand.SELECT,
roles=("reporting",),
using=RawSQLTerm("tenant_id = current_tenant()"),
),
]
grants = [
Grant(privileges=(Privilege.SELECT,), roles=("reporting",)),
Grant((Privilege.SELECT,), ("reporting",), on=GrantTarget.VIEW, object_name="paid_invoices"),
]
Каждый объект создаётся в схеме модели (Meta.schema), как и её таблица. У представления,
материализованного представления, функции и последовательности своё имя в схеме: две модели, объявившие
такой объект с одним именем (например, абстрактная база объявила его для подклассов), вызывают
ConfigurationError, как и одна модель с представлением, материализованным представлением и
последовательностью одного имени. Meta принимает список или кортеж каждого вида; элемент другого класса
или два элемента одного имени (два одинаковых права) вызывают ConfigurationError при объявлении модели.
Объявление само проверяет свои аргументы — тип и диапазон — и вызывает ConfigurationError для
неверного.
Представления¶
@dataclass(frozen=True)
class View:
name: str
query: RawSQLTerm | QuerySet | ModelQuery
read_columns: tuple[tuple[str, str], ...] = () # keyword only
Именованный SELECT. query — QuerySet, ModelQuery() или RawSQLTerm с готовым SQL; всё остальное
(обычный текст, функция) вызывает ConfigurationError. ModelQuery() — QuerySet модели, объявляющей
представление, которая, пока выполняется её Meta, ещё не собрана: его вызовы
(ModelQuery().filter(paid=True).values("id"), в том числе собственные методы менеджера) записываются и
выполняются на QuerySet модели, когда она собрана; QuerySet другой модели передаётся как есть. Queryset
записывается как SQL со значениями внутри (queryset.sql(parameters_inline=True)) для подключения, на
котором он выполняется: состояние миграций и файл миграции хранят этот SQL, поэтому QuerySet, чей SQL
изменился (переименовано поле, переименована таблица модели), заставляет makemigrations записать
представление заново — удалить до переименования и добавить после.
read_columns — колонки, которые читает представление, (таблица, колонка) каждая. У QuerySet они
берутся из самого запроса — выбранные колонки, условия, соединения и подзапросы, SQL не читается — и
пишутся в файл миграции; для готового SQL назовите их сами. Миграция, которая меняет, переименовывает или
удаляет одну из них или удаляет её таблицу, удаляет представление до этого и создаёт после, изменилось
его объявление или нет, — PostgreSQL не даёт менять тип колонки, которую читает представление.
Представление с готовым SQL без read_columns не трогается, пока не меняется его объявление.
Изменение представления удаляет его и создаёт новую версию, затем снова выдаёт на неё права Meta.grants
модели — у заново созданного представления прав нет. Представление или материализованное представление,
чьи read_columns называют изменяемое представление, удаляется до него и создаётся заново после, по
всей цепочке таких представлений; читающее его только из готового SQL без read_columns hare не знает —
сначала уберите его в той же миграции.
Материализованные представления¶
@dataclass(frozen=True)
class MaterializedView(View):
name: str
query: RawSQLTerm | QuerySet | Callable[[], QuerySet]
with_data: bool = True
unique_columns: tuple[str, ...] = ()
Строки запроса, хранящиеся в базе до обновления. with_data=False создаёт представление пустым и
недоступным для чтения до первого обновления. По unique_columns — колонкам представления, а не полям
модели, — строится уникальный индекс <view>_unique, переименуемый вместе с представлением; он нужен для
обновления без блокировки чтения.
Обновление заново наполняет представление строками его запроса:
await Invoice.objects.refresh_materialized_view("invoice_totals")
await Invoice.objects.refresh_materialized_view("invoice_totals", concurrently=True)
Обновление выполняется на подключении, в которое пишет модель (.using(...) выбирает другое), внутри
текущей транзакции, если она есть. concurrently=True оставляет представление доступным для чтения во
время обновления — для этого нужны unique_columns и уже наполненное представление (без unique_columns
— ConfigurationError). Представление, которого модель не объявляет, вызывает QueryError. В миграции
представление обновляет RefreshMaterializedView.
Изменение материализованного представления удаляет его и создаёт новую версию — наполненную заново, если
не with_data=False, — и снова выдаёт на неё права модели.
Функции¶
@dataclass(frozen=True)
class DatabaseFunction:
name: str
returns: str # "integer", "SETOF text", "TABLE (id integer, total numeric)"
body: RawSQLTerm
arguments: tuple[str, ...] = () # ("tenant_id integer", "since date")
language: str | None = None # None: plpgsql в PostgreSQL
volatility: FunctionVolatility = FunctionVolatility.VOLATILE
security_definer: bool = False
Функция, хранимая в базе и вызываемая из SQL — из представления, условия политики, готового SQL, значения
колонки по умолчанию. body — это RawSQLTerm с телом функции (обычный текст вызывает
ConfigurationError); returns, arguments и body пишутся в DDL как есть, поэтому они непереносимы.
Тело заключается в долларовые кавычки ($hare_function$); тело, содержащее эту кавычку, вызывает
ConfigurationError. language — простое имя; volatility — VOLATILE, STABLE или IMMUTABLE — то,
что функция обещает планировщику; security_definer=True выполняет её с правами владельца.
Функцию узнают по имени и типам аргументов. Изменение, сохраняющее их и тип результата, заменяет её на
месте (CREATE OR REPLACE FUNCTION) с сохранением прав; изменение аргументов или типа результата удаляет
её и создаёт новую, снова выдавая на неё права модели. Функцию, от которой зависят представление или
политика, так удалить нельзя — сначала измените их в той же миграции. Функция LANGUAGE sql при создании
проверяется по таблицам, которые она читает, поэтому может читать только уже существующие таблицы:
функции модели создаются после таблиц миграции, до её представлений и политик.
Последовательности¶
@dataclass(frozen=True)
class DatabaseSequence:
name: str
start: int | None = None
increment: int = 1
minimum: int | None = None
maximum: int | None = None
cycle: bool = False
cache: int = 1
owned_by: str | None = None
Счётчик, выдающий номера. Каждый номер — 64-битное целое; increment не может быть 0 (отрицательный
считает вниз), minimum должен быть меньше maximum, start — между ними, а cache — сколько номеров
сеанс берёт заранее — от 1 до 1 000 000. Без start последовательность начинается с minimum при счёте
вверх (по умолчанию 1) и с maximum при счёте вниз. owned_by называет поле модели: последовательность
удаляется вместе с его колонкой, и у поля должна быть своя колонка.
Последовательности модели создаются до её таблицы, чтобы значение колонки по умолчанию могло брать из них; колонка-владелец задаётся, когда таблица уже есть. Следующий номер читается во время работы на подключении, в которое пишет модель:
Выданный номер больше не выдаётся никогда, даже если транзакция откатилась. Последовательность, которой
модель не объявляет, вызывает QueryError.
Изменение последовательности сохраняет её текущий номер: новые шаг, границы, кэш, цикличность и
колонка-владелец действуют со следующего номера. start задаёт только место, с которого начнётся
перезапуск последовательности; не заданные граница или начало возвращаются к значениям по умолчанию.
Защита на уровне строк¶
class Meta:
row_level_security = RowLevelSecurity.ENABLED # или RowLevelSecurity.FORCED, или None (выключена)
ENABLED фильтрует строки таблицы через её политики для всех ролей, кроме владельца; роль, которую не
пускает ни одна политика, не видит строк. FORCED применяет политики и к владельцу — это настройка для
приложения, которое подключается ролью-владельцем своих таблиц. None — значение по умолчанию —
выключает защиту. ENABLED без единой политики закрывает таблицу для всех остальных ролей: они читают её
строки только через функцию с security_definer. Meta.policies при выключенной row_level_security
вызывает ConfigurationError: база сохранила бы политики, но не применяла бы их.
@dataclass(frozen=True)
class Policy:
name: str
command: PolicyCommand = PolicyCommand.ALL # ALL, SELECT, INSERT, UPDATE, DELETE
roles: tuple[str, ...] = () # пусто: все роли
using: Q | RawSQLTerm | TenantCondition | None = None
with_check: Q | RawSQLTerm | TenantCondition | None = None
permissive: bool = True
using — условие, которому должна отвечать строка, которую команда читает, изменяет или удаляет;
with_check — условие для строки, которую она вставляет или изменяет; без него политика ALL или
UPDATE проверяет using. Каждое — Q по собственным полям модели со значениями внутри или
RawSQLTerm с готовым SQL (вызов функции, чтение настройки) или TenantCondition() —
Meta.tenant_field строки среди арендаторов транзакции (Разделение данных по
арендаторам). Политике нужно
хотя бы одно из них; политика INSERT не принимает using, а SELECT и DELETE — with_check, иначе
ConfigurationError. Строка проходит, когда её пропускают любая разрешающая политика команды и все
ограничивающие (permissive=False). PUBLIC, CURRENT_USER, CURRENT_ROLE и SESSION_USER в roles
пишутся ключевыми словами; любая другая роль берётся в кавычки.
Строка, которую скрывает using, пропускается без ошибки: запрос её не читает, update()/delete() её
не считают (0 строк). Строка, которую отклоняет with_check (вставка или изменение со значениями вне
условия), даёт OperationalError — это insufficient_privilege PostgreSQL (SQLSTATE 42501,
new row violates row-level security policy for table "invoice"), как и отсутствующий GRANT.
Имя политики уникально в пределах таблицы. Изменение её ролей или условий меняет её на месте; новая команда или вид, а также убранное условие удаляют её и создают новую.
Права¶
@dataclass(frozen=True)
class Grant:
privileges: tuple[Privilege, ...]
roles: tuple[str, ...]
on: GrantTarget = GrantTarget.TABLE # TABLE, VIEW, MATERIALIZED_VIEW, SEQUENCE, FUNCTION
object_name: str | None = None # имя представления, последовательности или функции модели
columns: tuple[str, ...] = () # поля — только для таблицы
with_grant_option: bool = False
Права, которые получают роли, — на таблицу модели или на представление, материализованное представление,
последовательность или функцию, которые модель объявляет и которые называет object_name. Права должны
существовать для вида объекта:
on |
Права |
|---|---|
TABLE, VIEW |
SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, ALL |
MATERIALIZED_VIEW |
SELECT, ALL |
SEQUENCE |
USAGE, SELECT, UPDATE, ALL |
FUNCTION |
EXECUTE, ALL |
columns ограничивает SELECT, INSERT, UPDATE и REFERENCES на таблицу колонками некоторых полей.
with_grant_option=True позволяет ролям передавать права дальше. У права нет имени — его узнают по
всему, что оно выдаёт: изменённое право отзывается, а новое выдаётся. Удаление права отзывает его
привилегии у его ролей — даже если другое право модели выдаёт те же. Объект, который право называет, но
модель не объявляет, вызывает ConfigurationError, когда право выполняется. Роль должна существовать на
сервере до выдачи права — hare ролей не создаёт.
В миграциях¶
makemigrations сравнивает каждый вид объектов по имени: объект, у которого изменилось только имя,
переименовывается; изменённый под тем же именем — меняется (AlterView, AlterFunction,
AlterSequence, AlterPolicy, ...), а если миграция меняет колонку или таблицу, которые может читать
его готовый SQL, — удаляется до этого изменения и добавляется после; остальные удаляются и добавляются;
права отзываются и выдаются. Объект, объявление которого не менялось, не трогается, что бы ни читал его
готовый SQL.
Операции идут в таком порядке, чтобы каждый объект находил то, чем пользуется:
- Сначала, до любых изменений полей и таблиц: отзыв прав, удаление политик, материализованных представлений и представлений, затем переименования.
- Затем операции моделей, полей, индексов и ограничений.
CreateModelновой модели создаёт её последовательности до таблицы; остальные её объекты идут следом. - Когда все таблицы на месте: создание и изменение последовательностей, функций, представлений,
материализованных представлений, защиты строк, политик и прав — каждый вид после видов, которыми он
может пользоваться. Представление или материализованное представление, чьи
read_columnsназывают другое, создаётся после него (представление поверх материализованного); готовый SQL для этого не читается, поэтому называйте читаемые колонки там. - В конце: удаление функций и последовательностей.
Представление, материализованное представление или политика, чей SQL называет поле или колонку, которые
миграция удаляет или меняет, удаляется до операций полей и создаётся заново после них, даже если в
остальном не изменилось, — PostgreSQL не удаляет и не меняет колонку, от которой такой объект зависит.
Колонки, которые читает представление, hare знает из его набора запроса или read_columns, а колонки
политики — из её условий Q и TenantCondition(), а колонки триггера — из его условия when в виде Q
(триггер, читающий изменяемую колонку, так же удаляется и создаётся заново); готовый SQL-запрос без
read_columns и готовое
SQL-условие остаются как написаны — меняйте их в той же миграции.
Создание представления, материализованного представления, функции или последовательности сразу выдаёт на неё права модели, поэтому объект, удалённый и созданный заново, сохраняет свои права. Удаление модели удаляет её представления и материализованные представления до таблицы, затем её функции и последовательности; её политики и права на таблицу уходят вместе с таблицей. Таблица, которую PostgreSQL перестраивает (изменилось партиционирование), получает назад свои представления, защиту строк, политики и права.
hare drift читает каждый объект, объявленный моделью, из базы и из её
тени одинаково — так, как его печатает PostgreSQL, — поэтому
объявление, написанное другими словами, но то же самое, расхождением не считается:
- представление и материализованное представление — по запросу (
pg_get_viewdef()); у материализованного представления — ещё и уникальный индекс поunique_columns; - функцию — по аргументам, типу результата, телу, языку, изменчивости и
security_definerкаждой её перегрузки; - последовательность — по началу, шагу, границам, кэшу, цикличности и колонке-владельцу;
- защиту строк — включена, принудительна или выключена, объявлена она моделью или нет;
- политику — по типу, команде, ролям и условиям;
- права — по каждой привилегии каждой роли вместе с правом передачи.
Объект, которого нет в базе, возвращается как его операция Add..., изменённый — как Alter.... Объект,
который не объявляет ни одна модель, — функция или представление, созданные вручную, — не сообщается.