Привет, Хабр!
Сегодня рассмотрим тему кастомных 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".
