Привет, Хабр!
Сегодня рассмотрим тему кастомных lookup‑операторов в Django ORM. Они позволяют расширить стандартный синтаксис Django, интегрируя свои SQL‑функции и алгоритмы, при этом сохраняя привычный вид фильтрации.
Обзор синтаксиса кастомных lookup-операторов
Каждый кастомный lookup в Django — это класс, наследник django.db.models.lookups.Lookup
. Основная его задача — реализовать метод as_sql()
, который генерирует SQL‑код для запроса. Пример схемы:
from django.db.models import Lookup, Field
class MyCustomLookup(Lookup):
# Имя lookup-а, используемое в фильтрах (например, __mylookup)
lookup_name = 'mylookup'
def as_sql(self, compiler, connection):
# process_lhs() преобразует левую часть выражения (поле модели) в SQL
lhs_sql, lhs_params = self.process_lhs(compiler, connection)
# process_rhs() делает то же самое для правой части (значение фильтра)
rhs_sql, rhs_params = self.process_rhs(compiler, connection)
# Собираем итоговое SQL-условие. Здесь можно применять SQL-функции, операторы и т.д.
sql = "%s = %s" % (lhs_sql, rhs_sql)
params = lhs_params + rhs_params
return sql, params
# Регистрация lookup-а для нужного типа поля или для всех полей
Field.register_lookup(MyCustomLookup)
Основные моменты. lookup_name
: это имя, под которым вы будете вызывать оператор, например, field__mylookup=value
. process_lhs()
и process_rhs()
: гарантируют, что входные данные корректно экранируются и адаптируются под конкретную СУБД. as_sql()
: здесь формируется SQL‑условие, используя компоненты запроса. Можно вызывать встроенные SQL‑функции, комбинировать выражения и делать подзапросы.
Примеры применения
Lookup для звукового поиска
Допустим, есть модель для хранения имен:
# models.py
from django.db import models
class Person(models.Model):
name = models.CharField(max_length=255)
def __str__(self):
return self.name
Создадим lookup, который использует функцию SOUNDEX
для поиска похожих по звучанию имен:
# lookups.py
from django.db.models import Lookup, Field
class SoundexLookup(Lookup):
lookup_name = 'soundex'
def as_sql(self, compiler, connection):
lhs_sql, lhs_params = self.process_lhs(compiler, connection)
rhs_sql, rhs_params = self.process_rhs(compiler, connection)
# Генерируем SQL-условие: сравниваем SOUNDEX от поля и от переданного значения
sql = "SOUNDEX(%s) = SOUNDEX(%s)" % (lhs_sql, rhs_sql)
params = lhs_params + rhs_params
return sql, params
Field.register_lookup(SoundexLookup)
Используем его в запросе:
from myapp.models import Person
# Если в базе "John", "Jon" и "Juan" — оператор найдёт нужные варианты
matching_people = Person.objects.filter(name__soundex="John")
for person in matching_people:
print(f"Найдено: {person.name}")
Если вы используете PostgreSQL, проверьте наличие расширения fuzzystrmatch.
Полнотекстовый поиск с to_tsvector/to_tsquery
Переходим к более сложной задаче — поиску по тексту. Представим модель статьи:
# models.py
from django.db import models
class Article(models.Model):
title = models.CharField(max_length=255)
content = models.TextField()
def __str__(self):
return self.title
Создадим lookup для полнотекстового поиска:
# lookups.py
from django.db.models import Lookup, Field
class FullTextSearchLookup(Lookup):
lookup_name = 'fts'
def as_sql(self, compiler, connection):
lhs_sql, lhs_params = self.process_lhs(compiler, connection)
rhs_sql, rhs_params = self.process_rhs(compiler, connection)
# 'russian' — конфигурация языка
sql = "to_tsvector('russian', %s) @@ to_tsquery('russian', %s)" % (lhs_sql, rhs_sql)
params = lhs_params + rhs_params
return sql, params
Field.register_lookup(FullTextSearchLookup)
Пример запроса:
from myapp.models import Article
# Найдёт статьи, где в тексте встречается слово "Django"
articles = Article.objects.filter(content__fts="Django")
for article in articles:
print(f"Статья: {article.title}")
Чтобы ускорить поиск, можно создать индекс:
CREATE INDEX article_content_idx ON myapp_article USING gin(to_tsvector('russian', content));
Геопространственный lookup с PostGIS
Для проектов, где нужны геоданные, GeoDjango и PostGIS есть множество возможностей. Пример модели:
# models.py
from django.contrib.gis.db import models as geomodels
class Location(geomodels.Model):
name = geomodels.CharField(max_length=255)
coordinates = geomodels.PointField(geography=True)
def __str__(self):
return self.name
Создадим lookup для поиска объектов в пределах заданного радиуса с помощью ST_Distance
:
# lookups.py
from django.db.models import Lookup, Field
class STDistanceLessThan(Lookup):
lookup_name = 'st_dlt'
def as_sql(self, compiler, connection):
lhs_sql, lhs_params = self.process_lhs(compiler, connection)
# Ожидаем, что rhs — это кортеж (target_point, threshold)
if not isinstance(self.rhs, (tuple, list)) or len(self.rhs) != 2:
raise ValueError("Для st_dlt требуется кортеж (target_point, threshold)")
target_point, threshold = self.rhs
sql = "ST_Distance(%s, %%s) < %%s" % lhs_sql
params = lhs_params + [target_point, threshold]
return sql, params
Field.register_lookup(STDistanceLessThan)
Пример запроса:
from django.contrib.gis.geos import Point
from myapp.models import Location
# Координаты центра Москвы
center = Point(37.6173, 55.7558)
radius = 1000 # в метрах
nearby_locations = Location.objects.filter(coordinates__st_dlt=(center, radius))
for loc in nearby_locations:
print(f"{loc.name} находится в пределах {radius} м от центра Москвы")
Lookup для поиска с алгоритмом Левенштейна и Regex
Чтобы обрабатывать опечатки и находить похожие строки, можно использовать алгоритм Левенштейна. Рассмотрим модель продукта:
# models.py
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=255)
def __str__(self):
return self.name
Lookup для Левенштейна:
# lookups.py
from django.db.models import Lookup, Field
class LevenshteinLookup(Lookup):
lookup_name = 'lev'
def as_sql(self, compiler, connection):
lhs_sql, lhs_params = self.process_lhs(compiler, connection)
rhs_sql, rhs_params = self.process_rhs(compiler, connection)
if not isinstance(self.rhs, (tuple, list)) or len(self.rhs) != 2:
raise ValueError("Для lev требуется кортеж (искомая строка, порог)")
search_str, threshold = self.rhs
sql = "levenshtein(%s, %%s) <= %%s" % lhs_sql
params = lhs_params + [search_str, threshold]
return sql, params
Field.register_lookup(LevenshteinLookup)
Пример использования:
from myapp.models import Product
# Находим товары, где название отличается от "Smartphone" не более чем на 2 символа
similar_products = Product.objects.filter(name__lev=("Smartphone", 2))
for prod in similar_products:
print(f"Похожий продукт: {prod.name}")
А чтобы добавить универсальность, можно написать lookup для поиска по регулярке. Допустим, есть модель комментариев:
# models.py
from django.db import models
class Comment(models.Model):
author = models.CharField(max_length=100)
content = models.TextField()
def __str__(self):
return f"{self.author}: {self.content[:20]}..."
Lookup для Regex:
# lookups.py
from django.db.models import Lookup, Field
class RegexLookup(Lookup):
lookup_name = 'regex'
def as_sql(self, compiler, connection):
lhs_sql, lhs_params = self.process_lhs(compiler, connection)
rhs_sql, rhs_params = self.process_rhs(compiler, connection)
sql = "%s ~ %s" % (lhs_sql, rhs_sql)
params = lhs_params + rhs_params
return sql, params
Field.register_lookup(RegexLookup)
Пример запроса:
from myapp.models import Comment
# Фильтруем комментарии, где встречается слово, начинающееся на "django"
matching_comments = Comment.objects.filter(content__regex=r'\bdjango\w*')
for comment in matching_comments:
print(f"Комментарий: {comment.content}")
Тестирование
Ни один оператор не обходится без тестов! Пример набора тестов для lookup‑операторов:
# tests.py
from django.test import TestCase
from myapp.models import Person, Product, Article, Comment, Location
from django.contrib.gis.geos import Point
class LookupTests(TestCase):
def setUp(self):
Person.objects.create(name="John")
Person.objects.create(name="Jon")
Person.objects.create(name="Juan")
Product.objects.create(name="Smartphone")
Product.objects.create(name="Smartfone")
Product.objects.create(name="Smatphone")
Article.objects.create(title="Django Tips", content="Полнотекстовый поиск в Django с использованием PostgreSQL")
Article.objects.create(title="ORM магия", content="Расширяем возможности Django ORM через кастомные lookup-операторы.")
Comment.objects.create(author="Alice", content="Django — это круто!")
Comment.objects.create(author="Bob", content="Я люблю django-разработку.")
Location.objects.create(name="Центр", coordinates=Point(37.6173, 55.7558))
Location.objects.create(name="Окрестности", coordinates=Point(37.6300, 55.7600))
def test_soundex_lookup(self):
qs = Person.objects.filter(name__soundex="John")
self.assertEqual(qs.count(), 2)
def test_levenshtein_lookup(self):
qs = Product.objects.filter(name__lev=("Smartphone", 2))
self.assertGreaterEqual(qs.count(), 1)
def test_fulltext_search_lookup(self):
qs = Article.objects.filter(content__fts="Django")
self.assertGreaterEqual(qs.count(), 1)
def test_regex_lookup(self):
qs = Comment.objects.filter(content__regex=r'\bdjango\w*')
self.assertGreaterEqual(qs.count(), 1)
def test_st_distance_lookup(self):
center = Point(37.6173, 55.7558)
qs = Location.objects.filter(coordinates__st_dlt=(center, 1000))
self.assertGreaterEqual(qs.count(), 1)
Если у вас есть интересные кейсы применения и хочется поделиться своим опытом, пишите в комментариях.
20 февраля в Otus пройдёт открытый урок на тему «Децентрализованная революция в управлении данными: Data Mesh и его четыре принципа».
Если тема для вас актуальна, записывайтесь на странице курса "Data Engineer".