Как стать автором
Обновить
749
OTUS
Цифровые навыки от ведущих экспертов

Кастомные lookup-операторы в Django ORM

Уровень сложностиПростой
Время на прочтение6 мин
Количество просмотров980

Привет, Хабр!

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

Теги:
Хабы:
+6
Комментарии2

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS