Django — один из самых популярных веб-фреймворков, написанных на Python. Он следует принципу “Не повторяйся” (DRY) и быстро решает задачи веб-разработки с помощью набора инструментов, библиотек и соглашений.
Действительно продвинутым Django делает встроенная поддержка ORM (механизма объектно-реляционного отображения), который разрабатывает команда Django. Модель данных в Django представлена в виде классов Python, в которых можно создавать и запрашивать таблицы базы данных без использования необработанных SQL-запросов.
В этой статье мы рассмотрим, как интегрировать Django с материализованными представлениями PostgreSQL. Настроим ORM Django на полную поддержку материализованных представлений, определив модели материализованных представлений в проекте Django таким образом, чтобы изменения модели могли быть обнаружены системой миграции Django.
Что такое материализованные представления?
Представления в движках баз данных — это виртуальные таблицы, предоставляющие возможность выполнять запросы и получать данные из существующих таблиц. Они позволяют упростить процесс выполнения сложных запросов. Однако использование представлений, динамически выполняющих первичный запрос (primary query) при каждом обращении, может идти в ущерб производительности.
Именно здесь в игру вступают материализованные представления. В отличие от обычных, материализованные представления хранят результаты базового запроса в физической таблице. Другими словами, материализованные представления компилируют результаты запроса и сохраняют их как отдельную сущность в базе данных. Поэтому получить доступ к материализованным представлениям можно намного быстрее, чем к обычным представлениям, предполагающим повторное выполнение запроса при каждом обращении.
При этом необходимо учитывать некоторые недостатки материализованных представлений.
- Данные в материализованных представлениях могут устаревать, если не обновлять их регулярно, что может привести к несоответствиям.
- Необходимо учитывать требования к хранению, поскольку материализованные представления потребляют дисковое пространство для хранения данных.
- Материализованные представления добавляют сложности системе, так как требуют обслуживания при обновлении базовых таблиц.
Создание материализованного представления в PostgreSQL
Материализованное представление можно создать с помощью следующей команды SQL:
CREATE MATERIALIZED VIEW popular_posts AS
SELECT * FROM posts WHERE rating > 200;
Этот запрос создаст новое материализованное представление из базового запроса с кэшированным результатом. При этом обновленные данные в исходной таблице не будут автоматически доступны в материализованном представлении, так как их нужно будет обновить вручную с помощью следующей команды:
REFRESH MATERIALIZED VIEW popular_posts;
Эта команда обновит материализованное представление и заполнит свежими данными базовую таблицу, а также перекомпилирует определение базового запроса материализованного представления.
Проблема
Поскольку в настоящее время ORM Django поддерживает только таблицы баз данных, мне стало интересно, можно ли настроить ORM Django для поддержки материализованных представлений. Проведя исследование, я понял, что можно добиться этого несколькими способами, но не был удовлетворен ни одним решением, так как ни одна из настроек не могла полностью поддерживать материализованные представления для использования в качестве моделей Django.
Одно из решений предполагало создание пустого файла миграции вручную и указание SQL-запроса для создания материализованного представления с помощью команды RunSQL
.
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("api", "0001_initial"),
]
operations = [
migrations.RunSQL(
sql="CREATE MATERIALIZED VIEW popular_posts AS SELECT * FROM posts WHERE rating > 200",
reverse_sql="DROP MATERIALIZED VIEW popular_posts"
)
]
Кроме того, необходимо создать класс модели, определив поля точно так же, как и в запросе материализованного представления. Класс модели должен иметь:
- опцию
managed
, которую нужно установить в false в определении мета-класса для указания того, что схема базы данных не должна управляться Django, чтобы предотвратить создание новой таблицы системой миграции; - опцию
db_table
, которая должна быть явно установлена в соответствии с именем материализованного представления.
class PopularPosts(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
author = models.ForeignKey(User, on_delete=models.CASCADE)
tag_names = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
rating = models.PositiveIntegerField()
class Meta:
managed = False
db_table = 'popular_posts'
На мой взгляд, данный подход не является оптимальным решением, эффективным в долгосрочной перспективе, особенно при частых обновлениях или изменениях атрибутов класса модели. Когда потребуется изменение атрибутов класса модели, нужно переписывать новый необработанный запрос в новом файле миграции, что может занять много времени и чревато ошибками. Более того, это может стать громоздкой задачей, особенно по мере роста и развития приложения с течением времени.
Как интегрировать Django с материализованными представлениями
Чтобы интегрировать Django с материализованными представлениями, в нашем примере будет применено несколько настроек.
- Создание пользовательского класса модели.
- Создание пользовательского класса поля модели.
- Создание пользовательского движка базы данных для PostgreSQL.
- Соединение всего созданного в проекте.
Шаг 1. Создание пользовательского класса модели
Первым шагом к интеграции Django с материализованными представлениями в PostgreSQL является создание пользовательского класса модели с целью сообщить Django, что модель предназначена для материализованного представления.
Существует несколько способов определить, является ли класс модели материализованным представлением. Я создал класс модели таким образом, чтобы он принимал два пользовательских атрибута класса Meta внутри класса модели.
materialized_view
указывает, что класс модели является материализованным представлением, а не таблицей.view_parent_model
указывает классу модели, какой реальный класс модели должен использоваться, чтобы создать базовый запрос для материализованного представления.
class MaterializedViewModel(models.Model):
class Meta:
materialized_view = True
view_parent_model = 'app_label.Model'
Обычно Django не позволяет определять пользовательские атрибуты своего класса Meta внутри классов модели, выдавая исключение TypeError:
raise TypeError(
TypeError: 'class Meta' got invalid attribute(s): materialized_view
Проведя исследование, я обнаружил, что в Django можно использовать только атрибуты Meta, которые статически определены в кортеже DEFAULT_NAMES
, который находится в модуле django.db.models.options
. Чтобы устранить это ограничение, я применил обходной путь, импортировав модуль options и переопределив переменную DEFAULT_NAMES
в файле __init__.py
модуля проекта перед наполнением приложений Django. Эта модификация обеспечивает поддержку пользовательских атрибутов в классе Meta.
# djangoProject/__init__.py
import django.db.models.options as options
options.DEFAULT_NAMES += ('materialized_view', 'view_parent_model',)
Шаг 2. Создание пользовательского поля модели
Следующий шаг — создание пользовательского поля модели, специально предназначенного для класса модели материализованного представления, которое будет иметь два пользовательских атрибута. Каждое из пользовательских полей, указанных в классе модели, впоследствии будет преобразовано в необработанные SQL-запросы для генерации SQL, что необходимо для создания материализованного представления.
from django.db.models import fields
from django.db.models.expressions import Combinable, ExpressionWrapper, F
class MaterializedViewField(fields.Field):
def __init__(self, child, source=None, **kwargs):
super().__init__(**kwargs)
self.child = child
if isinstance(source, Combinable) or source is None:
self.source = source
elif isinstance(source, str):
self.source = ExpressionWrapper(F(source), output_field=child)
else:
self.source = None
def deconstruct(self):
"""
Переопределение метода deconstruct для включения атрибутов пользовательских полей в файлы миграции
при выполнении команды `makemigrations` в отншении модели материализованного представления.
"""
name, path, args, keywords = super().deconstruct()
keywords.update(
source=self.source, child=self.child
)
return name, path, args, keywords
Шаг 3. Создание пользовательского движка базы данных для PostgreSQL
Следующий шаг — создание пользовательского движка базы данных с пользовательским классом редактора. Этот движок отвечает за реализацию необходимой настройки схемы.
Чтобы настроить редактор схем баз данных Django, нужно определить новый движок базы данных в проекте и сослаться на него в атрибуте ENGINE
записи DATABASES
в файле настроек проекта.
Обратите внимание: пользовательский движок базы данных нужно поместить в каталог с файлом base.py
и классом DatabaseWrapper
.
За дополнительной информацией обратитесь к документации Django.
Ниже приведен пример структуры папок, где движок базы данных помещен в приложение Django под названием core и в подпапку backends.
project_root/
...
django_project/
__init__.py
settings.py
core/
__init__.py
backends/
__init__.py
db/
__init__.py
base.py
Следующий код является примером пользовательского движка базы данных (он расположен в файле base.py
).
from django.apps import apps
from django.db.backends.postgresql import base
from django.db.models import QuerySet, options, Model
from api.fields import MaterializedViewField
class DatabaseSchemaEditor(base.DatabaseSchemaEditor):
sql_create_materialized_view = "CREATE MATERIALIZED VIEW %(table)s AS %(definition)s"
sql_delete_materialized_view = "DROP MATERIALIZED VIEW %(table)s"
sql_refresh_materialized_View = "REFRESH MATERIALIZED VIEW %(concurrently)s %(view)s"
@staticmethod
def model_meta(model: type[Model]) -> options.Options:
return model._meta
def _get_parent_model(self, model: type[Model]):
"""
Возвращает базовую модель представления, которая будет использоваться для генерации SQL материализованного представления.
"""
parent_model = getattr(self.model_meta(model), 'view_parent_model', None)
if parent_model:
return apps.get_model(*parent_model.split('.'))
def model_is_materialized_view(self, model: type[Model]) -> bool:
"""Проверяет, является ли класс модели моделью материализованного представления или обычной моделью django."""
return getattr(self.model_meta(model), 'materialized_view', False)
def get_queryset(self, model: Model, extra_field=None):
"""Генерирует набор запросов из предоставленной родительской модели и предоставленных полей."""
def append_field(_model_field):
if _model_field.source is None:
concrete_fields.append(_model_field.name)
else:
annotation_fields.update({_model_field.attname: _model_field.source})
concrete_fields = []
annotation_fields = dict()
for field_name, field in model.__dict__.items():
if hasattr(field, 'field'):
model_field: MaterializedViewField = field.field
if isinstance(model_field, MaterializedViewField):
append_field(model_field)
if extra_field:
append_field(extra_field)
return QuerySet(
model=self._get_parent_model(model)
).only(*concrete_fields).annotate(**annotation_fields).query
def create_model(self, model, extra_field=None):
if self.model_is_materialized_view(model):
sql = self.sql_create_materialized_view % {
'table': self.quote_name(self.model_meta(model).db_table),
'definition': self.get_queryset(model, extra_field=extra_field)
}
self.execute(sql)
else:
super().create_model(model)
def add_field(self, model: Model, field):
if self.model_is_materialized_view(model):
setattr(model, field.attname, field)
self.delete_model(model)
self.create_model(model, extra_field=field)
else:
super().add_field(model, field)
def remove_field(self, model, field):
if self.model_is_materialized_view(model):
delattr(model, field.attname)
self.delete_model(model)
self.create_model(model)
else:
super().remove_field(model, field)
def alter_field(self, model, old_field, new_field, strict=False):
if self.model_is_materialized_view(model):
delattr(model, old_field.attname)
self.delete_model(model)
self.create_model(model, extra_field=new_field)
else:
super().alter_field(model, old_field, new_field, strict)
def delete_model(self, model):
if self.model_is_materialized_view(model):
self.execute(
self.sql_delete_materialized_view % {
"table": self.model_meta(model).db_table
}
)
else:
super().delete_model(model)
def refresh_materialized_view(self, model: type[Model], concurrent=False):
"""
Выполняет запрос на обновление материализованного представления,
если было желательно заполнять данные представления по требованию.
"""
self.execute(self.sql_refresh_materialized_View % {
'view': model._meta.db_table,
'concurrently': 'CONCURRENTLY' if concurrent else ''
})
class DatabaseWrapper(base.DatabaseWrapper):
SchemaEditorClass = DatabaseSchemaEditor
Приведенный выше код переопределяет встроенный в Django класс схемы базы данных postgres для поддержки выполнения запросов, необходимых для создания материализованных представлений после выполнения команды migrate.
Короче говоря, все методы работы с базой данных были модифицированы, чтобы проверить, является ли модель моделью материализованного представления или моделью обычного класса. Если это модель материализованного представления, будет вызван метод get_queryset
, чтобы сгенерировать необработанный SQL-запрос из родительской модели для создания или обновления материализованного представления. В противном случае родительский метод будет вызван как обычно.
Наконец, добавим пользовательский движок базы данных в настройки базы данных проекта следующим образом:
DATABASES = {
'default': {
'ENGINE': 'core.backends.db',
'HOST': '<db-host>',
'NAME': '<db-name>',
'USER': '<user>',
'PASSWORD': '<passwrod>',
'PORT': 5432,
'ATOMIC_REQUESTS': True,
}
}
Шаг 4. Собираем все вместе
Теперь продемонстрирую использование материализованного представления на нескольких примерах моделей. Определение модели в основном представляет собой простую модель Post с отношением “многие ко многим” с моделями Comment и Tag, как показано в примере ниже:
class Tag(models.Model):
name = models.CharField(max_length=100)
def __str__(self):
return self.name
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'Comment by {self.author.username} on {self.post.title}'
class Post(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
author = models.ForeignKey(User, on_delete=models.CASCADE)
tags = models.ManyToManyField(Tag)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
Затем необходимо провести миграцию моделей, последовательно используя команды makemigrations
и migrate
.
$ python manage.py makemigraitons
$ python manage.py migrate
После того как все модели перенесены в базу данных, нужно создать определение модели материализованного представления для модели Post.
class MaterializedViewBaseModel(models.Model):
class Meta:
abstract = True
@classmethod
def check(cls, **kwargs):
errors = super().check(**kwargs)
if not hasattr(cls._meta, 'materialized_view'):
errors.append(
checks.Error(
'The `view` attribute is required in materialized view meta class.',
obj=cls,
id='models.E100',
)
)
view_parent_model = getattr(cls._meta, 'view_parent_model', None)
if view_parent_model:
try:
apps.get_model(*getattr(cls._meta, 'view_parent_model').split('.'))
except (LookupError, ValueError) as e:
errors.append(
checks.Error(
f"Invalid `view_parent_model` format, {e}", obj=cls, id='models.E101'
)
)
else:
errors.append(
checks.Error(
'The `view_parent_model` attribute is required in materialized view meta class.',
obj=cls,
id='models.E101'
)
)
return errors
@classmethod
def refresh(cls, concurrent=False):
with connection.cursor() as cursor:
editor = cursor.db.schema_editor()
editor.refresh_materialized_view(cls, concurrent=concurrent)
class PostMaterializedView(MaterializedViewBaseModel):
post_id = MaterializedViewField(source='pk', child=models.IntegerField())
title = MaterializedViewField(child=models.CharField())
content = MaterializedViewField(child=models.CharField())
tag_names = MaterializedViewField(
source=aggregates.StringAgg('tags__name', delimiter="'; '", distinct=True),
child=models.CharField()
)
comments_count = MaterializedViewField(
source=functions.Coalesce(models.Count('comments', distinct=True), 0),
child=models.IntegerField()
)
comment_authors = MaterializedViewField(
source=aggregates.StringAgg(
'comments__author__first_name', delimiter="', '", distinct=True
),
child=models.CharField()
)
class Meta:
view_parent_model = 'api.Post'
materialized_view = True
constraints = [
models.UniqueConstraint(
models.F('post_id'), name='unique_post_id',
)
]
В приведенном выше примере пользовательские Options
класса Meta
были определены для информирования редактора схемы базы данных о том, что модель является материализованным представлением, и генерации SQL-запроса из модели Post
.
Поле модели MaterializedViewField
было использовано для определения полей модели, предоставляющих поле дочерней модели и атрибут источника. Важно отметить, что поле модели было специально разработано для поддержки различных выражений запросов базы данных, упомянутых в документации Django, включая F
, StringAggr
, Count
, Avg
, Sum
, Case
и так далее.
Материализованный абстрактный класс в основном выполняет проверки пользовательских атрибутов класса Meta
. Например, проверяет существование атрибутов materialized_view
и view_parent_model
и уточняет был ли view_parent_model
определен в правильном формате.
Наконец, последовательное выполнение команд makemigrations
и migrate
должно создать новое материализованное представление, легко используемое в Django для выполнения запросов и операций фильтрации.
# Сгенерировано Django 4.2.1 2023.05.22 в 22:10
import api.fields
import django.contrib.postgres.aggregates.general
from django.db import migrations, models
import django.db.models.aggregates
import django.db.models.functions.comparison
class Migration(migrations.Migration):
dependencies = [
("api", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="PostMaterializedView",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"post_id",
api.fields.MaterializedViewField(
child=models.IntegerField(),
source=models.ExpressionWrapper(
models.F("pk"), output_field=models.IntegerField()
),
),
),
(
"title",
api.fields.MaterializedViewField(
child=models.CharField(), source=None
),
),
(
"content",
api.fields.MaterializedViewField(
child=models.CharField(), source=None
),
),
(
"tag_names",
api.fields.MaterializedViewField(
child=models.CharField(),
source=django.contrib.postgres.aggregates.general.StringAgg(
"tags__name", delimiter="'; '", distinct=True
),
),
),
(
"comments_count",
api.fields.MaterializedViewField(
child=models.IntegerField(),
source=django.db.models.functions.comparison.Coalesce(
django.db.models.aggregates.Count(
"comments", distinct=True
),
0,
),
),
),
(
"comment_authors",
api.fields.MaterializedViewField(
child=models.CharField(),
source=django.contrib.postgres.aggregates.general.StringAgg(
"comments__author__first_name",
delimiter="', '",
distinct=True,
),
),
),
],
options={
"materialized_view": True,
"view_parent_model": "api.Post",
},
),
migrations.AddConstraint(
model_name="postmaterializedview",
constraint=models.UniqueConstraint(
models.F("post_id"), name="unique_post_id"
),
),
]
Обратите внимание: атрибуты source
и child
поля MaterializedViewField
были включены в файл миграции, так как мы определили их в методе deconstruct
поля.
Теперь Django успешно интегрирован с материализованными представлениями PostgreSQL.
Следующие шаги
Хотя нам удалось внедрить в Django новую функцию для использования материализованных представлений PostgreSQL, есть несколько улучшений, достойных обсуждения.
Поскольку теперь ясно, что материализованные представления нуждаются в регулярных обновлениях в случае, если базовая таблица представления была обновлена, необходимо разработать механизм для поддержания данных материализованного представления в актуальном состоянии.
На предложенном решении можно протестировать более сложные примеры, чтобы проверить его динамичность для различных типов запросов и случаев использования.
Заключение
Надеюсь, это руководство позволит вам легко интегрировать материализованные представления в приложения Django. Для этого потребуется запустить такие механизмы, как переопределение класса схемы базы данных, разработка пользовательских классов модели и разработка пользовательских полей модели.
Благодаря возможности создавать и обновлять материализованные представления с помощью системы миграции Django, вы сможете поддерживать синхронизацию между схемой базы данных и определениями моделей. Разделение обычных моделей и моделей материализованных представлений улучшает организацию и удобство сопровождения кода.
Читайте также:
- Как загружать файлы и изображения в приложении Django
- Как заказывали: админ-панель от Django Jet
- Простой прием для молниеносных запросов LIKE и ILIKE
Читайте нас в Telegram, VK и Дзен
Перевод статьи Abdulla Hashim: Integrating Django with PostgreSQL Materialized Views