Специфичные для PostgreSQL возможности | Курс Django ORM урок 7.2
Цель урока
Разобрать специфичные для PostgreSQL поля и операции Django ORM: JSONField, ArrayField, полнотекстовый поиск. Понять когда эти инструменты дают преимущество перед стандартными подходами.
Необходимые знания
JSONField
JSONField хранит данные в PostgreSQL типе jsonb. Это не просто TEXT, PostgreSQL понимает структуру JSON и может индексировать и запрашивать по вложенным полям.
from django.db.models import JSONField
class Product(models.Model):
name = models.CharField(max_length=200)
attributes = JSONField(default=dict, blank=True)
# Хранит: {"color": "black", "memory": "256GB", "warranty_years": 2}
CREATE TABLE shop_product (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200),
attributes JSONB DEFAULT '{}'
);
jsonb (binary JSON), PostgreSQL хранит данные в бинарном формате: более быстрые запросы, дедупликация ключей, поддержка GIN индексов.
Сохранение и чтение
# Сохранить
product = Product.objects.create(
name="iPhone 15",
attributes={
"color": "black",
"memory": "256GB",
"warranty_years": 2,
"features": ["5G", "USB-C", "Face ID"],
}
)
# Прочитать
product = Product.objects.get(id=1)
print(product.attributes["color"]) # "black"
print(product.attributes["features"]) # ["5G", "USB-C", "Face ID"]
Django автоматически сериализует Python dict/list в JSON при сохранении и десериализует при чтении.
Фильтрация по JSON полям
# Точное совпадение значения по ключу
Product.objects.filter(attributes__color="black")
SELECT * FROM shop_product
WHERE attributes ->> 'color' = 'black';
# Числовое сравнение
Product.objects.filter(attributes__warranty_years__gte=2)
WHERE (attributes ->> 'warranty_years')::numeric >= 2
# Вложенные ключи через __
Product.objects.filter(attributes__specs__ram="16GB")
# attributes = {"specs": {"ram": "16GB"}}
WHERE attributes -> 'specs' ->> 'ram' = '16GB'
# Содержит ключ
from django.db.models import Q
Product.objects.filter(attributes__has_key="warranty_years")
WHERE attributes ? 'warranty_years'
# Содержит все ключи
Product.objects.filter(attributes__has_keys=["color", "memory"])
WHERE attributes ?& ARRAY['color', 'memory']
# Содержит хотя бы один ключ
Product.objects.filter(attributes__has_any_keys=["color", "size"])
WHERE attributes ?| ARRAY['color', 'size']
# Содержит подмножество (attributes содержит все пары из переданного dict)
Product.objects.filter(attributes__contains={"color": "black"})
WHERE attributes @> '{"color": "black"}'::jsonb
Обновление JSON поля
# Перезаписать весь объект
product.attributes = {"color": "white", "memory": "128GB"}
product.save(update_fields=["attributes"])
# Обновить один ключ без загрузки объекта
from django.db.models.functions import JSONObject
from django.db.models import Value
# Django не имеет встроенного оператора для обновления одного ключа
# Нужен raw SQL или F() с jsonb_set
from django.db import connection
with connection.cursor() as cursor:
cursor.execute(
"UPDATE shop_product SET attributes = jsonb_set(attributes, %s, %s::jsonb) WHERE id = %s",
["{color}", '"white"', product.id]
)
GIN индекс для JSONField
from django.contrib.postgres.indexes import GinIndex
class Product(models.Model):
attributes = JSONField(default=dict)
class Meta:
indexes = [
GinIndex(fields=["attributes"], name="product_attributes_gin"),
]
CREATE INDEX product_attributes_gin ON shop_product USING gin(attributes);
GIN индекс ускоряет операции contains, has_key, has_keys. Для = по конкретному ключу обычный B-tree на выражении эффективнее. Для индекса на конкретный JSON ключ используйте функциональный индекс через Index(expressions=...):
from django.db.models.expressions import RawSQL
class Meta:
indexes = [
models.Index(
RawSQL("(attributes->>'color')", []),
name="product_attributes_color_idx",
),
]
CREATE INDEX product_attributes_color_idx ON shop_product ((attributes->>'color'));
ArrayField
ArrayField хранит массив значений в PostgreSQL типе ARRAY:
from django.contrib.postgres.fields import ArrayField
class Product(models.Model):
tags = ArrayField(
base_field=models.CharField(max_length=50),
default=list,
blank=True,
)
# Хранит: ["wireless", "bluetooth", "premium"]
CREATE TABLE shop_product (
...
tags VARCHAR(50)[] DEFAULT '{}'
);
Фильтрация по ArrayField
# Содержит элемент
Product.objects.filter(tags__contains=["wireless"])
WHERE tags @> ARRAY['wireless']
# Содержится в (product.tags является подмножеством переданного массива)
Product.objects.filter(tags__contained_by=["wireless", "bluetooth", "premium"])
WHERE tags <@ ARRAY['wireless', 'bluetooth', 'premium']
# Пересечение непустое (хотя бы один элемент совпадает)
Product.objects.filter(tags__overlap=["wireless", "usb-c"])
WHERE tags && ARRAY['wireless', 'usb-c']
# Длина массива
Product.objects.filter(tags__len__gte=3)
WHERE array_length(tags, 1) >= 3
# Конкретный элемент по индексу (1-based в PostgreSQL)
Product.objects.filter(tags__0="wireless") # первый элемент
WHERE tags[1] = 'wireless'
GIN индекс для ArrayField
from django.contrib.postgres.indexes import GinIndex
class Meta:
indexes = [
GinIndex(fields=["tags"], name="product_tags_gin"),
]
CREATE INDEX product_tags_gin ON shop_product USING gin(tags);
Полнотекстовый поиск
Django предоставляет интерфейс к PostgreSQL full-text search через django.contrib.postgres.search.
SearchVector и SearchQuery
from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank
# Простой поиск по одному полю
Product.objects.annotate(
search=SearchVector("name", "description"),
).filter(search=SearchQuery("wireless headphones"))
SELECT shop_product.*,
to_tsvector('english', name || ' ' || description) AS search
FROM shop_product
WHERE to_tsvector('english', name || ' ' || description) @@ plainto_tsquery('english', 'wireless headphones')
ORDER BY shop_product.name;
SearchVector, преобразует текст в tsvector (список лексем).
SearchQuery, запрос для поиска.
Русский язык
# Указать язык
Product.objects.annotate(
search=SearchVector("name", "description", config="russian"),
).filter(search=SearchQuery("беспроводные наушники", config="russian"))
WHERE to_tsvector('russian', ...) @@ plainto_tsquery('russian', 'беспроводные наушники')
PostgreSQL применяет стемминг: "наушников" и "наушники", это одна лексема.
SearchRank - ранжирование результатов
from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank
query = SearchQuery("wireless headphones")
vector = SearchVector("name", weight="A") + SearchVector("description", weight="B")
products = (
Product.objects
.annotate(rank=SearchRank(vector, query))
.filter(rank__gte=0.1)
.order_by("-rank")
)
SELECT shop_product.*,
ts_rank(
setweight(to_tsvector('english', name), 'A') ||
setweight(to_tsvector('english', description), 'B'),
plainto_tsquery('english', 'wireless headphones')
) AS rank
FROM shop_product
WHERE ts_rank(...) >= 0.1
ORDER BY rank DESC;
weight="A" дает больший вес совпадениям в name, чем в description (weight="B").
SearchVectorField - индексированный поиск
Вычислять SearchVector при каждом запросе дорого. Для production нужно хранить предвычисленный tsvector в отдельном поле:
from django.contrib.postgres.search import SearchVectorField
class Product(models.Model):
name = models.CharField(max_length=200)
description = models.TextField()
search_vector = SearchVectorField(null=True, editable=False)
class Meta:
indexes = [
GinIndex(fields=["search_vector"], name="product_search_vector_gin"),
]
Обновлять search_vector при изменении name или description, через сигнал или через UPDATE:
from django.contrib.postgres.search import SearchVector
# Обновить search_vector для всех продуктов
Product.objects.update(
search_vector=SearchVector("name", "description", config="russian")
)
UPDATE shop_product
SET search_vector = to_tsvector('russian', name || ' ' || description);
Поиск по индексу:
from django.contrib.postgres.search import SearchQuery
Product.objects.filter(
search_vector=SearchQuery("наушники", config="russian")
)
SELECT * FROM shop_product
WHERE search_vector @@ plainto_tsquery('russian', 'наушники')
ORDER BY name;
-- GIN индекс используется автоматически
TrigramSimilarity - поиск по схожести
TrigramSimilarity ищет строки похожие на запрос, полезно для "Вы имели в виду?" или поиска с опечатками:
from django.contrib.postgres.search import TrigramSimilarity
Product.objects.annotate(
similarity=TrigramSimilarity("name", "iPhone")
).filter(similarity__gte=0.3).order_by("-similarity")
SELECT *, similarity(name, 'iPhone') AS similarity
FROM shop_product
WHERE similarity(name, 'iPhone') >= 0.3
ORDER BY similarity DESC;
Требует расширения PostgreSQL pg_trgm:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
Или через миграцию:
from django.contrib.postgres.operations import TrigramExtension
class Migration(migrations.Migration):
operations = [
TrigramExtension(),
# ...
]
Типы SearchQuery - search_type
SearchQuery поддерживает несколько режимов поиска через параметр search_type:
from django.contrib.postgres.search import SearchQuery
# plain (по умолчанию) - стемминг, слова объединяются AND
SearchQuery("wireless headphones")
# phrase - точная фраза в том же порядке
SearchQuery("wireless headphones", search_type="phrase")
# raw - сырой tsquery синтаксис PostgreSQL
SearchQuery("wireless & headphones", search_type="raw")
# websearch - Google подобный синтаксис (Django 3.2+)
SearchQuery('"wireless headphones" OR earbuds', search_type="websearch")
-- plain
plainto_tsquery('english', 'wireless headphones')
-- phrase
phraseto_tsquery('english', 'wireless headphones')
-- raw
to_tsquery('english', 'wireless & headphones')
-- websearch
websearch_to_tsquery('english', '"wireless headphones" OR earbuds')
search_type="plain" применяет стемминг: "headphones" найдет "headphone". search_type="raw" дает полный контроль над tsquery синтаксисом PostgreSQL.
Практическое задание
-
Добавьте поле
attributes = JSONField()в модельProduct. Наполните данными: для телефонов -{"color": str, "memory": str}, для ноутбуков -{"ram": str, "cpu": str}. Напишите запрос: все черные телефоны с памятью 256GB. -
Добавьте поле
tags = ArrayField(CharField)вProduct. Найдите все продукты с тегами "wireless" и "bluetooth" одновременно (оба тега должны присутствовать). -
Реализуйте функцию
search_products(query, lang="russian")черезSearchVectorиSearchRank. Результаты должны быть отсортированы по релевантности. Имя продукта должно иметь больший вес чем описание. -
Добавьте поле
search_vector = SearchVectorField()и GIN индекс. Напишите сигналpost_saveкоторый обновляетsearch_vectorпри измененииnameилиdescription. -
Сравните скорость поиска по
name ILIKE '%phone%'vsSearchVectorна таблице с 10 000 продуктов. ИспользуйтеEXPLAIN ANALYZE.
Возможные ошибки
JSONField без GIN индекса при частых contains запросах
# Без индекса будет Seq Scan на каждый запрос
Product.objects.filter(attributes__contains={"color": "black"})
Добавьте GinIndex если такие запросы частые.
ArrayField для M2M отношений
# Плохо, денормализованные данные, нет целостности
class Product(models.Model):
tag_ids = ArrayField(IntegerField()) # [1, 2, 3]
# Правильно M2M связь
class Product(models.Model):
tags = ManyToManyField(Tag)
ArrayField подходит для данных которые не имеют собственных атрибутов (простые строки, числа). Для связей с другими моделями, M2M.
Полнотекстовый поиск без конфига языка
# Без config, PostgreSQL использует язык по умолчанию (обычно english)
# Для русского текста, стемминг не работает
SearchVector("description")
# Для русского
SearchVector("description", config="russian")
TrigramSimilarity без расширения
# ProgrammingError: function similarity(text, unknown) does not exist
Product.objects.annotate(sim=TrigramSimilarity("name", "iPhone"))
# Нужно установить расширение
Связь со следующим уроком
В уроке 7.3 разберем кастомные lookup'ы и функции. Как добавить собственные lookup типы __unaccent, __similarity и как обернуть произвольные SQL функции через Func() для использования в ORM.
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться веб-разработке, узнать подробнее
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru