Интеграция Django с материализованными представлениями PostgreSQL

Django  —  один из самых популярных веб-фреймворков, написанных на Python. Он следует принципу “Не повторяйся” (DRY) и быстро решает задачи веб-разработки с помощью набора инструментов, библиотек и соглашений.

Действительно продвинутым Django делает встроенная поддержка ORM (механизма объектно-реляционного отображения), который разрабатывает команда Django. Модель данных в Django представлена в виде классов Python, в которых можно создавать и запрашивать таблицы базы данных без использования необработанных SQL-запросов.

В этой статье мы рассмотрим, как интегрировать Django с материализованными представлениями PostgreSQL. Настроим ORM Django на полную поддержку материализованных представлений, определив модели материализованных представлений в проекте Django таким образом, чтобы изменения модели могли быть обнаружены системой миграции Django.

Что такое материализованные представления?

Представления в движках баз данных  —  это виртуальные таблицы, предоставляющие возможность выполнять запросы и получать данные из существующих таблиц. Они позволяют упростить процесс выполнения сложных запросов. Однако использование представлений, динамически выполняющих первичный запрос (primary query) при каждом обращении, может идти в ущерб производительности.

Именно здесь в игру вступают материализованные представления. В отличие от обычных, материализованные представления хранят результаты базового запроса в физической таблице. Другими словами, материализованные представления компилируют результаты запроса и сохраняют их как отдельную сущность в базе данных. Поэтому получить доступ к материализованным представлениям можно намного быстрее, чем к обычным представлениям, предполагающим повторное выполнение запроса при каждом обращении.

При этом необходимо учитывать некоторые недостатки материализованных представлений.

  1. Данные в материализованных представлениях могут устаревать, если не обновлять их регулярно, что может привести к несоответствиям.
  2. Необходимо учитывать требования к хранению, поскольку материализованные представления потребляют дисковое пространство для хранения данных.
  3. Материализованные представления добавляют сложности системе, так как требуют обслуживания при обновлении базовых таблиц.

Создание материализованного представления в 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, вы сможете поддерживать синхронизацию между схемой базы данных и определениями моделей. Разделение обычных моделей и моделей материализованных представлений улучшает организацию и удобство сопровождения кода.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Abdulla Hashim: Integrating Django with PostgreSQL Materialized Views

Предыдущая статьяТоп-6 инструментов и фреймворков для искусственного интеллекта
Следующая статьяПарадоксы нейминга: Windows придумали не в Microsoft, а Android — в Apple