├── tests
├── __init__.py
├── helpers.py
├── django_settings.py
├── test_paginator.py
├── models.py
├── test_management_command.py
├── test_signals.py
├── conftest.py
├── test_mixins.py
├── test_integration.py
├── test_query.py
├── test_registry.py
└── test_documents.py
├── example
├── __init__.py
├── core
│ ├── __init__.py
│ ├── migrations
│ │ ├── __init__.py
│ │ └── 0001_initial.py
│ ├── templatetags
│ │ ├── __init__.py
│ │ └── build_query_string.py
│ ├── urls.py
│ ├── apps.py
│ ├── admin.py
│ ├── models.py
│ ├── documents.py
│ ├── views.py
│ └── templates
│ │ └── core
│ │ └── search.html
├── example
│ ├── __init__.py
│ ├── urls.py
│ ├── asgi.py
│ ├── wsgi.py
│ └── settings.py
├── requirements.txt
├── README.md
└── manage.py
├── redis_search_django
├── py.typed
├── __init__.py
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ └── index.py
├── apps.py
├── paginator.py
├── signals.py
├── mixins.py
├── registry.py
├── config.py
├── query.py
└── documents.py
├── requirements_test.txt
├── MANIFEST.in
├── CHANGELOG.md
├── docker-compose.yaml
├── .github
├── workflows
│ ├── changelog-ci.yaml
│ ├── pypi_publish.yaml
│ └── test.yml
└── dependabot.yml
├── tox.ini
├── pyproject.toml
├── LICENSE
├── .pre-commit-config.yaml
├── setup.cfg
├── .gitignore
└── README.md
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/core/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/example/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/redis_search_django/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/redis_search_django/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/core/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/core/templatetags/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/redis_search_django/management/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/redis_search_django/management/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/requirements_test.txt:
--------------------------------------------------------------------------------
1 | coverage
2 | pytest
3 | pytest-cov
4 | pytest-django
5 |
--------------------------------------------------------------------------------
/example/requirements.txt:
--------------------------------------------------------------------------------
1 | # Install redis-search-django
2 | -e ..
3 | Django==4.2.2
4 | Pillow==9.5.0
5 |
--------------------------------------------------------------------------------
/example/core/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 |
3 | from .views import SearchView
4 |
5 | urlpatterns = [
6 | path("", SearchView.as_view(), name="search"),
7 | ]
8 |
--------------------------------------------------------------------------------
/example/core/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class CoreConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "core"
7 |
--------------------------------------------------------------------------------
/example/core/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from .models import Category, Product, Tag, Vendor
4 |
5 | admin.site.register(Tag)
6 | admin.site.register(Category)
7 | admin.site.register(Product)
8 | admin.site.register(Vendor)
9 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include CHANGELOG.md
2 | include README.md
3 | include LICENSE
4 | include pyproject.toml
5 | include redis_search_django/py.typed
6 |
7 | recursive-include redis_search_django *
8 |
9 | global-exclude __pycache__
10 | global-exclude *.py[co]
11 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Version: 0.1.0
2 |
3 | * [#2](https://github.com/saadmk11/redis-search-django/pull/2): Bump actions/checkout from 2 to 3
4 | * [#5](https://github.com/saadmk11/redis-search-django/pull/5): Create LICENSE
5 | * [#4](https://github.com/saadmk11/redis-search-django/pull/4): Add Tests and Improvements
6 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 |
3 | services:
4 | redis:
5 | image: redis/redis-stack:latest
6 | ports:
7 | - "6379:6379"
8 | - "8001:8001"
9 | volumes:
10 | - redis_data:/data
11 | environment:
12 | - REDIS_PASSWORD=redis
13 |
14 | volumes:
15 | redis_data:
16 |
--------------------------------------------------------------------------------
/.github/workflows/changelog-ci.yaml:
--------------------------------------------------------------------------------
1 | name: Changelog CI
2 |
3 | on:
4 | pull_request:
5 | types: [opened]
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v3
13 |
14 | - name: Run Changelog CI
15 | uses: saadmk11/changelog-ci@v1.1.1
16 |
--------------------------------------------------------------------------------
/tests/helpers.py:
--------------------------------------------------------------------------------
1 | from functools import lru_cache
2 |
3 | import redis
4 | from redis_om import has_redisearch
5 |
6 |
7 | @lru_cache(maxsize=1)
8 | def is_redis_running():
9 | """Check if redis is running."""
10 | try:
11 | return has_redisearch()
12 | except redis.exceptions.ConnectionError:
13 | return False
14 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: pip
4 | directory: "/"
5 | schedule:
6 | interval: weekly
7 | labels:
8 | - skip-changelog
9 | - dependencies
10 | - package-ecosystem: github-actions
11 | directory: "/"
12 | schedule:
13 | interval: monthly
14 | labels:
15 | - skip-changelog
16 | - dependencies
17 |
--------------------------------------------------------------------------------
/example/core/templatetags/build_query_string.py:
--------------------------------------------------------------------------------
1 | from django import template
2 |
3 | register = template.Library()
4 |
5 |
6 | @register.simple_tag
7 | def query_string(request, **kwargs):
8 | """Build URL query string from request.GET."""
9 | params = request.GET.copy()
10 | params.pop("page", None)
11 |
12 | if params:
13 | return "&" + params.urlencode()
14 | return ""
15 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | isolated_build = True
3 | envlist = py{37,38,39,310}-django32,py{38,39,310}-django40,py{38,39,310}-django41
4 |
5 | [testenv]
6 | commands =
7 | python -m pytest {posargs:tests}
8 | deps =
9 | -r {toxinidir}/requirements_test.txt
10 | django32: Django >=3.2, <3.3
11 | django40: Django >=4.0, <4.1
12 | django41: Django >=4.1, <4.2
13 | setenv =
14 | PYTHONDEVMODE=1
15 |
--------------------------------------------------------------------------------
/example/example/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.conf.urls.static import static
3 | from django.contrib import admin
4 | from django.urls import include, path
5 |
6 | urlpatterns = [
7 | path("admin/", admin.site.urls),
8 | path("", include("core.urls")),
9 | ]
10 |
11 | if settings.DEBUG:
12 | # Serve media files in development
13 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
14 |
--------------------------------------------------------------------------------
/example/example/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for example project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
8 | """
9 |
10 |
11 | import os
12 |
13 | from django.core.asgi import get_asgi_application
14 |
15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings")
16 |
17 | application = get_asgi_application()
18 |
--------------------------------------------------------------------------------
/example/example/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for example project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
8 | """
9 |
10 |
11 | import os
12 |
13 | from django.core.wsgi import get_wsgi_application
14 |
15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings")
16 |
17 | application = get_wsgi_application()
18 |
--------------------------------------------------------------------------------
/tests/django_settings.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | BASE_DIR = Path(__file__).resolve().parent.parent
4 |
5 | SECRET_KEY = "django-insecure-secret"
6 |
7 | INSTALLED_APPS = [
8 | "django.contrib.contenttypes",
9 | "redis_search_django",
10 | "tests",
11 | ]
12 |
13 | DATABASES = {
14 | "default": {
15 | "ENGINE": "django.db.backends.sqlite3",
16 | "NAME": BASE_DIR / "db.sqlite3",
17 | }
18 | }
19 |
20 | REDIS_SEARCH_AUTO_INDEX = True
21 |
22 | USE_TZ = True
23 |
--------------------------------------------------------------------------------
/redis_search_django/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 | from django.utils.module_loading import autodiscover_modules
3 |
4 |
5 | class DjangoRedisSearchConfig(AppConfig):
6 | default_auto_field = "django.db.models.BigAutoField"
7 | name = "redis_search_django"
8 |
9 | def ready(self) -> None:
10 | # Auto Discover Document modules
11 | # Required for Document classes to be registered
12 | autodiscover_modules("documents")
13 | import redis_search_django.signals # noqa: F401
14 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | # Setup Example Project
2 |
3 | #### Clone the repository:
4 |
5 | ```bash
6 | git clone git@github.com:saadmk11/redis-search-django.git
7 | ```
8 |
9 | #### Create a virtual environment and activate it:
10 |
11 | ```bash
12 | virtualenv -p python3 venv
13 | source venv/bin/activate
14 | ```
15 |
16 | #### Change to project directory:
17 |
18 | ```bash
19 | cd redis-search-django/example
20 | ```
21 |
22 | #### Install requirements:
23 |
24 | ```bash
25 | pip install -r requirements.txt
26 | ```
27 |
28 | #### run migrations and runserver:
29 |
30 | ```bash
31 | python manage.py migrate
32 | python manage.py runserver # http://127.0.0.1:8000/
33 | ```
34 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [tool.pytest.ini_options]
6 | addopts = "--ds=tests.django_settings --cov=redis_search_django --cov-report xml"
7 |
8 | [tool.mypy]
9 | mypy_path = "redis_search_django/"
10 | check_untyped_defs = true
11 | warn_unreachable = true
12 | warn_unused_ignores = true
13 | show_error_codes = true
14 | disallow_any_generics = true
15 | disallow_incomplete_defs = true
16 | disallow_untyped_defs = true
17 | no_implicit_optional = true
18 |
19 | [[tool.mypy.overrides]]
20 | module = "tests.*"
21 | allow_untyped_defs = true
22 |
23 | [tool.black]
24 | target-version = ['py310']
25 |
26 | [tool.isort]
27 | profile = "black"
28 |
--------------------------------------------------------------------------------
/example/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 |
4 |
5 | import os
6 | import sys
7 |
8 |
9 | def main() -> None:
10 | """Run administrative tasks."""
11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings")
12 | try:
13 | from django.core.management import execute_from_command_line
14 | except ImportError as exc:
15 | raise ImportError(
16 | "Couldn't import Django. Are you sure it's installed and "
17 | "available on your PYTHONPATH environment variable? Did you "
18 | "forget to activate a virtual environment?"
19 | ) from exc
20 | execute_from_command_line(sys.argv)
21 |
22 |
23 | if __name__ == "__main__":
24 | main()
25 |
--------------------------------------------------------------------------------
/.github/workflows/pypi_publish.yaml:
--------------------------------------------------------------------------------
1 | name: Publish to PyPI
2 |
3 | on:
4 | workflow_dispatch:
5 | release:
6 | types: [published]
7 |
8 | permissions:
9 | contents: read
10 |
11 | jobs:
12 | deploy:
13 |
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - uses: actions/checkout@v3
18 |
19 | - name: Set up Python
20 | uses: actions/setup-python@v4
21 | with:
22 | python-version: '3.10'
23 |
24 | - name: Install dependencies
25 | run: |
26 | python -m pip install --upgrade pip
27 | pip install build
28 |
29 | - name: Build package
30 | run: python -m build
31 |
32 | - name: Publish package
33 | uses: pypa/gh-action-pypi-publish@release/v1
34 | with:
35 | user: __token__
36 | password: ${{ secrets.PYPI_API_TOKEN }}
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Maksudul Haque
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | default_language_version:
2 | python: python3.10
3 |
4 | repos:
5 | - repo: https://github.com/pre-commit/pre-commit-hooks
6 | rev: v4.4.0
7 | hooks:
8 | - id: end-of-file-fixer
9 | - id: trailing-whitespace
10 | - id: check-toml
11 | - id: check-case-conflict
12 | - id: check-merge-conflict
13 |
14 | - repo: https://github.com/asottile/pyupgrade
15 | rev: v3.9.0
16 | hooks:
17 | - id: pyupgrade
18 | args: [--py37-plus]
19 |
20 | - repo: https://github.com/psf/black
21 | rev: 23.7.0
22 | hooks:
23 | - id: black
24 |
25 | - repo: https://github.com/asottile/blacken-docs
26 | rev: 1.15.0
27 | hooks:
28 | - id: blacken-docs
29 | additional_dependencies:
30 | - black==22.6.0
31 |
32 | - repo: https://github.com/pycqa/isort
33 | rev: 5.12.0
34 | hooks:
35 | - id: isort
36 |
37 | - repo: https://github.com/PyCQA/flake8
38 | rev: 6.0.0
39 | hooks:
40 | - id: flake8
41 | additional_dependencies:
42 | - flake8-bugbear
43 | - flake8-comprehensions
44 | - flake8-tidy-imports
45 | - flake8-typing-imports
46 |
47 | - repo: https://github.com/pre-commit/mirrors-mypy
48 | rev: v1.4.1
49 | hooks:
50 | - id: mypy
51 | files: ^redis_search_django/
52 | additional_dependencies: [types-redis]
53 |
--------------------------------------------------------------------------------
/redis_search_django/management/commands/index.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | from typing import Any
3 |
4 | from django.core.management import BaseCommand
5 | from redis_om import Migrator, get_redis_connection
6 |
7 | from redis_search_django.registry import document_registry
8 |
9 |
10 | class Command(BaseCommand):
11 | help = "Index Documents to Redis Search"
12 |
13 | def add_arguments(self, parser: argparse.ArgumentParser) -> None:
14 | parser.add_argument(
15 | "--models",
16 | type=str,
17 | nargs="*",
18 | help="Django Models to index to Redis. e.g. 'app_name.ModelName'",
19 | )
20 | parser.add_argument(
21 | "--only-migrate",
22 | action="store_true",
23 | dest="only_migrate",
24 | help="Only update the indices schema.",
25 | )
26 |
27 | def handle(self, *args: Any, **options: Any) -> None:
28 | models = options["models"]
29 | only_migrate = options["only_migrate"]
30 |
31 | get_redis_connection()
32 | Migrator().run()
33 |
34 | self.stdout.write(self.style.SUCCESS("Successfully migrated indices"))
35 |
36 | if not only_migrate:
37 | document_registry.index_documents(models)
38 | self.stdout.write(self.style.SUCCESS("Successfully indexed documents"))
39 |
--------------------------------------------------------------------------------
/tests/test_paginator.py:
--------------------------------------------------------------------------------
1 | from unittest import mock
2 |
3 | import pytest
4 | from django.core.paginator import EmptyPage, PageNotAnInteger
5 |
6 | from redis_search_django.paginator import RediSearchPaginator
7 | from redis_search_django.query import RediSearchQuery, RediSearchResult
8 |
9 | from .models import Category
10 |
11 |
12 | def test_paginator_validate_number():
13 | paginator = RediSearchPaginator(range(10), 5)
14 | assert paginator.validate_number(1) == 1
15 | assert paginator.validate_number("1") == 1
16 |
17 | with pytest.raises(PageNotAnInteger):
18 | paginator.validate_number("abc")
19 |
20 | with pytest.raises(EmptyPage):
21 | paginator.validate_number(0)
22 |
23 | with pytest.raises(EmptyPage):
24 | paginator.validate_number(-1)
25 |
26 |
27 | @mock.patch("redis_search_django.query.RediSearchQuery.execute")
28 | def test_paginator(execute):
29 | query = RediSearchQuery(mock.MagicMock(), model=mock.MagicMock())
30 | result = RediSearchResult([mock.MagicMock()], 100, Category)
31 | query._model_cache = result
32 | execute.return_value = result
33 |
34 | paginator = RediSearchPaginator(query, 5)
35 | paginator.page(1)
36 |
37 | assert paginator.count == 100
38 | assert paginator.num_pages == 20
39 |
40 | with pytest.raises(EmptyPage):
41 | paginator.page(50)
42 |
--------------------------------------------------------------------------------
/tests/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 |
4 | class Tag(models.Model):
5 | name = models.CharField(max_length=30)
6 |
7 | class Meta:
8 | app_label = "tests"
9 |
10 | def __str__(self) -> str:
11 | return self.name
12 |
13 |
14 | class Category(models.Model):
15 | name = models.CharField(max_length=30)
16 |
17 | class Meta:
18 | app_label = "tests"
19 |
20 | def __str__(self) -> str:
21 | return self.name
22 |
23 |
24 | class Vendor(models.Model):
25 | name = models.CharField(max_length=30)
26 | establishment_date = models.DateField()
27 |
28 | class Meta:
29 | app_label = "tests"
30 |
31 | def __str__(self) -> str:
32 | return self.name
33 |
34 |
35 | class Product(models.Model):
36 | name = models.CharField(max_length=256)
37 | description = models.TextField(blank=True)
38 | category = models.ForeignKey(
39 | Category, on_delete=models.SET_NULL, blank=True, null=True
40 | )
41 | vendor = models.OneToOneField(Vendor, on_delete=models.CASCADE)
42 | tags = models.ManyToManyField(Tag, blank=True)
43 | price = models.DecimalField(max_digits=6, decimal_places=2)
44 | created_at = models.DateTimeField(auto_now_add=True)
45 |
46 | class Meta:
47 | app_label = "tests"
48 |
49 | def __str__(self) -> str:
50 | return self.name
51 |
--------------------------------------------------------------------------------
/redis_search_django/paginator.py:
--------------------------------------------------------------------------------
1 | from typing import Union
2 |
3 | from django.core.paginator import EmptyPage, Page, PageNotAnInteger, Paginator
4 |
5 |
6 | class RediSearchPaginator(Paginator):
7 | """Custom Paginator that Allows Pagination of a Redis Search Query"""
8 |
9 | def validate_number(self, number: Union[int, str]) -> int:
10 | try:
11 | number = int(number)
12 | except (TypeError, ValueError):
13 | raise PageNotAnInteger("That page number is not an integer")
14 | if number < 1:
15 | raise EmptyPage("That page number is less than 1")
16 | return number
17 |
18 | def page(self, number: Union[int, str]) -> Page:
19 | number = self.validate_number(number)
20 | bottom = (number - 1) * self.per_page
21 | # Set limit and offset for the redis search query
22 | self.object_list.paginate(offset=bottom, limit=self.per_page)
23 | # Executes the redis search query which returns RediSearchResult object
24 | result = self.object_list.execute(exhaust_results=False)
25 |
26 | page = Page(result, number, self)
27 |
28 | if number > self.num_pages:
29 | if number == 1 and self.allow_empty_first_page:
30 | pass
31 | else:
32 | raise EmptyPage("That page contains no results")
33 |
34 | return page
35 |
--------------------------------------------------------------------------------
/example/core/models.py:
--------------------------------------------------------------------------------
1 | import uuid
2 |
3 | from django.db import models
4 |
5 |
6 | class Tag(models.Model):
7 | name = models.CharField(max_length=30)
8 |
9 | def __str__(self) -> str:
10 | return self.name
11 |
12 |
13 | class Category(models.Model):
14 | name = models.CharField(max_length=30)
15 | slug = models.SlugField(max_length=30)
16 |
17 | def __str__(self) -> str:
18 | return self.name
19 |
20 |
21 | class Vendor(models.Model):
22 | logo = models.ImageField(upload_to="core/vendor_logo", blank=True)
23 | identifier = models.UUIDField(default=uuid.uuid4, editable=False)
24 | name = models.CharField(max_length=30)
25 | email = models.EmailField()
26 | establishment_date = models.DateField()
27 |
28 | def __str__(self) -> str:
29 | return self.name
30 |
31 |
32 | class Product(models.Model):
33 | name = models.CharField(max_length=256)
34 | description = models.TextField(blank=True)
35 | category = models.ForeignKey(
36 | Category, on_delete=models.SET_NULL, blank=True, null=True
37 | )
38 | vendor = models.OneToOneField(Vendor, on_delete=models.CASCADE)
39 | tags = models.ManyToManyField(Tag, blank=True)
40 | price = models.DecimalField(max_digits=6, decimal_places=2)
41 | available = models.BooleanField(default=True)
42 | quantity = models.IntegerField(default=1)
43 | created_at = models.DateTimeField(auto_now_add=True)
44 |
45 | def __str__(self) -> str:
46 | return self.name
47 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name = redis-search-django
3 | version = 0.1.0
4 | description = Django Integration with Redis Search
5 | long_description = file: README.md
6 | long_description_content_type = text/markdown
7 | license = MIT
8 | url = https://github.com/saadmk11/redis-search-django
9 | author = Maksudul Haque
10 | author_email = saad.mk112@gmail.com
11 | keywords =
12 | Django
13 | Redis
14 | Search
15 | RediSearch
16 | classifiers =
17 | Development Status :: 4 - Beta
18 | Natural Language :: English
19 | Intended Audience :: Developers
20 | License :: OSI Approved :: MIT License
21 | Operating System :: OS Independent
22 | Framework :: Django :: 3.2
23 | Framework :: Django :: 4.0
24 | Framework :: Django :: 4.1
25 | Programming Language :: Python :: 3 :: Only
26 | Programming Language :: Python :: 3
27 | Programming Language :: Python :: 3.7
28 | Programming Language :: Python :: 3.8
29 | Programming Language :: Python :: 3.9
30 | Programming Language :: Python :: 3.10
31 |
32 | [options]
33 | packages = redis_search_django
34 | include_package_data = True
35 | python_requires = >=3.7
36 | install_requires =
37 | django>=3.2
38 | redis-om>=0.0.27
39 |
40 | [options.packages.find]
41 | where = .
42 |
43 | [coverage:run]
44 | branch = True
45 | parallel = True
46 | source =
47 | redis_search_django
48 | tests
49 |
50 | [coverage:report]
51 | exclude_lines =
52 | pragma: no cover
53 | if TYPE_CHECKING:
54 |
55 | [coverage:paths]
56 | source = .
57 |
58 | [flake8]
59 | max-line-length = 88
60 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Django Tests
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 |
8 | jobs:
9 | test:
10 | runs-on: ubuntu-latest
11 |
12 | strategy:
13 | fail-fast: false
14 |
15 | matrix:
16 | python-version: ["3.7", "3.8", "3.9", "3.10"]
17 | django-version: ["3.2", "4.0", "4.1"]
18 |
19 | exclude:
20 | - python-version: "3.7"
21 | django-version: "4.0"
22 |
23 | - python-version: "3.7"
24 | django-version: "4.1"
25 |
26 | services:
27 | redis:
28 | image: redis/redis-stack-server:latest
29 | ports:
30 | - 6379:6379
31 | options: >-
32 | --health-cmd "redis-cli ping"
33 | --health-interval 10s
34 | --health-timeout 5s
35 | --health-retries 5
36 |
37 | steps:
38 | - uses: actions/checkout@v3
39 |
40 | - name: Install and Setup Python ${{ matrix.python-version }}
41 | uses: actions/setup-python@v4
42 | with:
43 | python-version: ${{ matrix.python-version }}
44 | cache: pip
45 | cache-dependency-path: requirements_test.txt
46 |
47 | - name: Install Dependencies
48 | run: python -m pip install --upgrade pip tox setuptools wheel
49 |
50 | - name: Run tests with Python ${{ matrix.python-version }} and Django ${{ matrix.django-version }}
51 | run: |
52 | TOX_ENV=$(echo "py${{ matrix.python-version}}-django${{ matrix.django-version}}" | tr -d .)
53 | python -m tox -e $TOX_ENV
54 |
55 | - name: Upload coverage to Codecov
56 | uses: codecov/codecov-action@v3
57 | with:
58 | files: coverage.xml
59 |
--------------------------------------------------------------------------------
/tests/test_management_command.py:
--------------------------------------------------------------------------------
1 | from unittest import mock
2 |
3 | from django.core.management import call_command
4 |
5 |
6 | @mock.patch("redis_search_django.management.commands.index.get_redis_connection")
7 | @mock.patch("redis_search_django.management.commands.index.Migrator")
8 | @mock.patch(
9 | "redis_search_django.management.commands.index.document_registry.index_documents"
10 | )
11 | def test_index_command_only_migrate(
12 | index_documents, migrator, get_redis_connection, document_class
13 | ):
14 | call_command("index", "--only-migrate")
15 |
16 | get_redis_connection.assert_called_once()
17 | migrator().run.assert_called_once()
18 | index_documents.assert_not_called()
19 |
20 |
21 | @mock.patch("redis_search_django.management.commands.index.get_redis_connection")
22 | @mock.patch("redis_search_django.management.commands.index.Migrator")
23 | @mock.patch(
24 | "redis_search_django.management.commands.index.document_registry.index_documents"
25 | )
26 | def test_index_command(index_documents, migrator, get_redis_connection, document_class):
27 | call_command("index")
28 |
29 | get_redis_connection.assert_called_once()
30 | migrator().run.assert_called_once()
31 | index_documents.assert_called_once()
32 |
33 |
34 | @mock.patch("redis_search_django.management.commands.index.get_redis_connection")
35 | @mock.patch("redis_search_django.management.commands.index.Migrator")
36 | @mock.patch(
37 | "redis_search_django.management.commands.index.document_registry.index_documents"
38 | )
39 | def test_index_command_with_models_option(
40 | index_documents, migrator, get_redis_connection, document_class
41 | ):
42 | call_command("index", "--models", "tests.Vendor", "tests.Category")
43 |
44 | get_redis_connection.assert_called_once()
45 | migrator().run.assert_called_once()
46 | index_documents.assert_called_once_with(["tests.Vendor", "tests.Category"])
47 |
--------------------------------------------------------------------------------
/tests/test_signals.py:
--------------------------------------------------------------------------------
1 | from unittest import mock
2 |
3 | import pytest
4 |
5 | from tests.models import Category
6 |
7 |
8 | @pytest.mark.django_db
9 | @mock.patch("redis_search_django.signals.document_registry")
10 | def test_add_document_to_redis_index(document_registry):
11 | category = Category.objects.create(name="Test")
12 | document_registry.update_document.assert_called_once_with(category, create=True)
13 |
14 | category.name = "Test2"
15 | category.save()
16 |
17 | document_registry.update_related_documents.assert_called_once_with(
18 | category, exclude=None
19 | )
20 |
21 |
22 | @pytest.mark.django_db
23 | @mock.patch("redis_search_django.signals.document_registry")
24 | def test_remove_document_from_redis_index(document_registry):
25 | category = Category.objects.create(name="Test")
26 | category.delete()
27 | document_registry.remove_document.assert_called_once_with(category)
28 |
29 |
30 | @pytest.mark.django_db
31 | @mock.patch("redis_search_django.signals.document_registry")
32 | def test_remove_related_documents_from_redis_index(document_registry):
33 | category = Category.objects.create(name="Test")
34 | category.delete()
35 | document_registry.update_related_documents.assert_called_once_with(
36 | category, exclude=category
37 | )
38 |
39 |
40 | @pytest.mark.django_db
41 | @mock.patch("redis_search_django.signals.document_registry")
42 | def test_update_redis_index_on_m2m_changed_add(document_registry, product_obj, tag_obj):
43 | product_obj.tags.add(tag_obj)
44 | document_registry.update_document.assert_called_once_with(product_obj, create=True)
45 | document_registry.update_related_documents.assert_called_once_with(
46 | product_obj, exclude=None
47 | )
48 |
49 |
50 | @pytest.mark.django_db
51 | @mock.patch("redis_search_django.signals.document_registry")
52 | def test_update_redis_index_on_m2m_changed_remove(document_registry, product_with_tag):
53 | product, tag = product_with_tag
54 | product.tags.clear()
55 | document_registry.update_related_documents.assert_called_with(product, exclude=None)
56 |
--------------------------------------------------------------------------------
/redis_search_django/signals.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Type
2 |
3 | from django.conf import settings
4 | from django.db import models
5 | from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete
6 |
7 | from .registry import document_registry
8 |
9 |
10 | def add_document_to_redis_index(
11 | sender: Type[models.Model], instance: models.Model, created: bool, **kwargs: Any
12 | ) -> None:
13 | """Signal handler for populating the redis index."""
14 | document_registry.update_document(instance, create=True)
15 |
16 | if not created:
17 | document_registry.update_related_documents(instance, exclude=None)
18 |
19 |
20 | def remove_document_from_redis_index(
21 | sender: Type[models.Model], instance: models.Model, **kwargs: Any
22 | ) -> None:
23 | """Signal handler for removing data from redis index."""
24 | document_registry.remove_document(instance)
25 |
26 |
27 | def remove_related_documents_from_redis_index(
28 | sender: Type[models.Model], instance: models.Model, **kwargs: Any
29 | ) -> None:
30 | """Signal handler for removing related model data from redis index."""
31 | document_registry.update_related_documents(instance, exclude=instance)
32 |
33 |
34 | def update_redis_index_on_m2m_changed(
35 | sender: Type[models.Model], instance: models.Model, action: str, **kwargs: Any
36 | ) -> None:
37 | """Signal handler for updating redis index on m2m changed."""
38 | if action in ("post_add", "post_remove", "post_clear"):
39 | document_registry.update_document(instance, create=True)
40 | document_registry.update_related_documents(instance, exclude=None)
41 | elif action in ("pre_remove", "pre_clear"):
42 | document_registry.update_related_documents(instance, exclude=instance)
43 |
44 |
45 | # Check if Auto Index is globally turned off using Django settings.
46 | if getattr(settings, "REDIS_SEARCH_AUTO_INDEX", True):
47 | post_save.connect(add_document_to_redis_index)
48 | pre_delete.connect(remove_related_documents_from_redis_index)
49 | post_delete.connect(remove_document_from_redis_index)
50 | m2m_changed.connect(update_redis_index_on_m2m_changed)
51 |
--------------------------------------------------------------------------------
/redis_search_django/mixins.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Iterable, List, Type, Union
2 |
3 | from django.core.exceptions import ImproperlyConfigured
4 | from django.http import HttpRequest
5 | from django.template.response import TemplateResponse
6 | from django.utils.functional import cached_property
7 | from django.views.generic.list import (
8 | MultipleObjectMixin,
9 | MultipleObjectTemplateResponseMixin,
10 | )
11 |
12 | from .documents import Document
13 | from .paginator import RediSearchPaginator
14 |
15 |
16 | class RediSearchMixin:
17 | paginator_class = RediSearchPaginator
18 | document_class: Type[Document]
19 |
20 | @cached_property
21 | def search_query_expression(self) -> Any:
22 | return None
23 |
24 | @cached_property
25 | def sort_by(self) -> Union[str, None]:
26 | return None
27 |
28 | def facets(self) -> Any:
29 | return None
30 |
31 | def search(self) -> Document:
32 | if not hasattr(self, "document_class") or not self.document_class:
33 | raise ImproperlyConfigured(
34 | "%(cls)s requires a 'document_class' attribute"
35 | % {"cls": self.__class__.__name__}
36 | )
37 | if self.search_query_expression:
38 | search_query = self.document_class.find(self.search_query_expression)
39 | else:
40 | search_query = self.document_class.find()
41 |
42 | if self.sort_by:
43 | search_query = search_query.sort_by(self.sort_by)
44 |
45 | return search_query
46 |
47 |
48 | class RediSearchTemplateResponseMixin(MultipleObjectTemplateResponseMixin):
49 | def get_template_names(self) -> List[str]:
50 | """
51 | Return a list of template names to be used for the request. Must return
52 | a list. May not be called if render_to_response() is overridden.
53 | """
54 | if self.template_name:
55 | return [self.template_name]
56 |
57 | template_names = []
58 |
59 | if self.document_class:
60 | opts = self.document_class._django.model._meta
61 | template_names.append(
62 | "%s/%s%s.html"
63 | % (opts.app_label, opts.model_name, self.template_name_suffix)
64 | )
65 | elif not template_names:
66 | raise ImproperlyConfigured(
67 | "%(cls)s requires either a 'template_name' attribute "
68 | "or a get_queryset() method that returns a QuerySet."
69 | % {
70 | "cls": self.__class__.__name__,
71 | }
72 | )
73 | return template_names
74 |
75 |
76 | class RediSearchMultipleObjectMixin(MultipleObjectMixin):
77 | def get_context_object_name(self, object_list: Iterable[Any]) -> Union[str, None]:
78 | """Get the name of the item to be used in the context."""
79 | if self.context_object_name:
80 | return self.context_object_name
81 | elif self.document_class:
82 | return "%s_list" % self.document_class._django.model._meta.model_name
83 | else:
84 | return None
85 |
86 |
87 | class RediSearchListViewMixin(
88 | RediSearchMixin, RediSearchTemplateResponseMixin, RediSearchMultipleObjectMixin
89 | ):
90 | def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> TemplateResponse:
91 | self.object_list = self.search()
92 | context = self.get_context_data(facets=self.facets())
93 | return self.render_to_response(context)
94 |
--------------------------------------------------------------------------------
/example/core/documents.py:
--------------------------------------------------------------------------------
1 | from typing import List, Optional
2 |
3 | from django.db import models
4 | from redis_om import Field
5 |
6 | from redis_search_django.documents import EmbeddedJsonDocument, JsonDocument
7 |
8 | from .models import Category, Product, Tag, Vendor
9 |
10 |
11 | class CategoryDocument(EmbeddedJsonDocument):
12 | # name: str = Field(index=True, full_text_search=True)
13 | # slug: str = Field(index=True)
14 | custom_field: str = Field(index=True, full_text_search=True)
15 |
16 | class Django:
17 | model = Category
18 | fields = ["name", "slug"]
19 |
20 | @classmethod
21 | def prepare_custom_field(cls, obj):
22 | return "CUSTOM FIELD VALUE"
23 |
24 |
25 | class TagDocument(EmbeddedJsonDocument):
26 | # name: str = Field(index=True)
27 |
28 | class Django:
29 | model = Tag
30 | fields = ["name"]
31 |
32 |
33 | class VendorDocument(EmbeddedJsonDocument):
34 | # logo: str = Field(index=True)
35 | # identifier: str = Field(index=True)
36 | # name: str = Field(index=True, full_text_search=True, sortable=True)
37 | # email: str = Field(index=True, full_text_search=True, sortable=True)
38 | # establishment_date: datetime.date = Field(
39 | # index=True, full_text_search=True, sortable=True
40 | # )
41 |
42 | class Django:
43 | model = Vendor
44 | fields = ["logo", "identifier", "name", "email", "establishment_date"]
45 |
46 | @classmethod
47 | def prepare_logo(cls, obj):
48 | return obj.logo.url if obj.logo else ""
49 |
50 |
51 | class ProductDocument(JsonDocument):
52 | # Fields can be defined manually or
53 | # `Django.fields` can be used to define the Django Model fields automatically.
54 |
55 | # name: str = Field(index=True, full_text_search=True, sortable=True)
56 | # description: str = Field(index=True, full_text_search=True)
57 | # price: Decimal = Field(index=True, sortable=True)
58 | # created_at: Optional[datetime.date] = Field(index=True)
59 | # quantity: Optional[int] = Field(index=True, sortable=True)
60 | # available: int = Field(index=True, sortable=True)
61 |
62 | # OnetoOneField
63 | vendor: VendorDocument
64 | # ForeignKey field
65 | category: Optional[CategoryDocument]
66 | # ManyToManyField
67 | tags: List[TagDocument]
68 |
69 | # class Meta:
70 | # model_key_prefix = "product"
71 | # global_key_prefix = "redis_search"
72 |
73 | class Django:
74 | model = Product
75 | # Django Model fields can be added to the Document automatically.
76 | fields = ["name", "description", "price", "created_at", "quantity", "available"]
77 | prefetch_related_fields = ["tags"]
78 | select_related_fields = ["vendor", "category"]
79 | related_models = {
80 | Vendor: {
81 | "related_name": "product",
82 | "many": False,
83 | },
84 | Category: {
85 | "related_name": "product_set",
86 | "many": True,
87 | },
88 | Tag: {
89 | "related_name": "product_set",
90 | "many": True,
91 | },
92 | }
93 |
94 | @classmethod
95 | def get_queryset(cls) -> models.QuerySet:
96 | return super().get_queryset().filter(available=True)
97 |
98 | @classmethod
99 | def prepare_name(cls, obj):
100 | return obj.name.upper()
101 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import uuid
3 | from typing import List, Optional
4 |
5 | import pytest
6 |
7 | from redis_search_django.documents import EmbeddedJsonDocument, JsonDocument
8 |
9 | from .models import Category, Product, Tag, Vendor
10 |
11 |
12 | @pytest.fixture(scope="session")
13 | def document_class():
14 | def build_document_class(
15 | document_type,
16 | model_class,
17 | model_fields,
18 | enable_auto_index=True,
19 | ):
20 | class DocumentClass(document_type):
21 | class Meta:
22 | model_key_prefix = (
23 | f"{model_class.__name__.lower()}-{uuid.uuid4().hex[:5]}"
24 | )
25 | global_key_prefix = "test_redis_search"
26 |
27 | class Django:
28 | model = model_class
29 | fields = model_fields
30 | auto_index = enable_auto_index
31 |
32 | return DocumentClass
33 |
34 | return build_document_class
35 |
36 |
37 | @pytest.fixture(scope="session")
38 | def nested_document_class(document_class):
39 | CategoryEmbeddedJsonDocument = document_class(
40 | EmbeddedJsonDocument, Category, ["name"]
41 | )
42 | TagEmbeddedJsonDocument = document_class(EmbeddedJsonDocument, Tag, ["name"])
43 | VendorEmbeddedJsonDocument = document_class(
44 | EmbeddedJsonDocument, Vendor, ["name", "establishment_date"]
45 | )
46 |
47 | class ProductJsonDocument(JsonDocument):
48 | # OnetoOneField with null=True
49 | vendor: VendorEmbeddedJsonDocument
50 | # ForeignKey field
51 | category: Optional[CategoryEmbeddedJsonDocument]
52 | # ManyToManyField
53 | tags: List[TagEmbeddedJsonDocument]
54 |
55 | class Meta:
56 | model_key_prefix = f"{Product.__name__.lower()}-{uuid.uuid4().hex[:5]}"
57 | global_key_prefix = "test_redis_search"
58 |
59 | class Django:
60 | model = Product
61 | fields = ["name", "description", "price", "created_at"]
62 | select_related_fields = ["vendor", "category"]
63 | prefetch_related_fields = ["tags"]
64 | related_models = {
65 | Vendor: {
66 | "related_name": "product",
67 | "many": False,
68 | },
69 | Category: {
70 | "related_name": "product_set",
71 | "many": True,
72 | },
73 | Tag: {
74 | "related_name": "product_set",
75 | "many": True,
76 | },
77 | }
78 |
79 | return ProductJsonDocument, (
80 | CategoryEmbeddedJsonDocument,
81 | TagEmbeddedJsonDocument,
82 | VendorEmbeddedJsonDocument,
83 | )
84 |
85 |
86 | @pytest.fixture
87 | def product_obj():
88 | return Product.objects.create(
89 | name="Test",
90 | price=10.0,
91 | vendor=Vendor.objects.create(
92 | name="Test", establishment_date=datetime.date.today()
93 | ),
94 | )
95 |
96 |
97 | @pytest.fixture
98 | def tag_obj():
99 | return Tag.objects.create(name="Test")
100 |
101 |
102 | @pytest.fixture
103 | def category_obj():
104 | return Category.objects.create(name="Test")
105 |
106 |
107 | @pytest.fixture
108 | def product_with_tag(product_obj, tag_obj):
109 | product_obj.tags.add(tag_obj)
110 | return product_obj, tag_obj
111 |
--------------------------------------------------------------------------------
/example/core/views.py:
--------------------------------------------------------------------------------
1 | from django.utils.functional import cached_property
2 | from django.views.generic import ListView
3 | from redis.commands.search import reducers
4 |
5 | from redis_search_django.mixins import RediSearchListViewMixin
6 |
7 | from .documents import ProductDocument
8 | from .models import Product
9 |
10 |
11 | class SearchView(RediSearchListViewMixin, ListView):
12 | paginate_by = 20
13 | model = Product
14 | template_name = "core/search.html"
15 | document_class = ProductDocument
16 |
17 | def get_context_data(self, *, object_list=None, **kwargs):
18 | return super().get_context_data(query_data=dict(self.request.GET), **kwargs)
19 |
20 | @cached_property
21 | def search_query_expression(self):
22 | query = self.request.GET.get("query")
23 | min_price = self.request.GET.get("min_price")
24 | max_price = self.request.GET.get("max_price")
25 | categories = list(filter(None, self.request.GET.getlist("category")))
26 | tags = list(filter(None, self.request.GET.getlist("tags")))
27 | query_expression = None
28 |
29 | if query:
30 | query_expression = (
31 | self.document_class.name % query
32 | | self.document_class.description % query
33 | )
34 |
35 | if min_price:
36 | min_price_expression = self.document_class.price >= float(min_price)
37 | query_expression = (
38 | query_expression & min_price_expression
39 | if query_expression
40 | else min_price_expression
41 | )
42 |
43 | if max_price:
44 | max_price_expression = self.document_class.price <= float(max_price)
45 | query_expression = (
46 | query_expression & max_price_expression
47 | if query_expression
48 | else max_price_expression
49 | )
50 |
51 | if categories:
52 | category_expression = self.document_class.category.name << categories
53 | query_expression = (
54 | query_expression & category_expression
55 | if query_expression
56 | else category_expression
57 | )
58 |
59 | if tags:
60 | tag_expression = self.document_class.tags.name << tags
61 | query_expression = (
62 | query_expression & tag_expression
63 | if query_expression
64 | else tag_expression
65 | )
66 |
67 | return query_expression
68 |
69 | @cached_property
70 | def sort_by(self):
71 | return self.request.GET.get("sort")
72 |
73 | def facets(self):
74 | if self.search_query_expression:
75 | request1 = self.document_class.build_aggregate_request(
76 | self.search_query_expression
77 | )
78 | request2 = self.document_class.build_aggregate_request(
79 | self.search_query_expression
80 | )
81 | else:
82 | request1 = self.document_class.build_aggregate_request()
83 | request2 = self.document_class.build_aggregate_request()
84 | result = self.document_class.aggregate(
85 | request1.group_by(
86 | ["@category_name"],
87 | reducers.count().alias("count"),
88 | )
89 | )
90 | result2 = self.document_class.aggregate(
91 | request2.group_by(
92 | ["@tags_name"],
93 | reducers.count().alias("count"),
94 | )
95 | )
96 | return [result, result2]
97 |
--------------------------------------------------------------------------------
/example/core/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.15 on 2022-08-12 13:08
2 |
3 | import uuid
4 |
5 | import django.db.models.deletion
6 | from django.db import migrations, models
7 |
8 |
9 | class Migration(migrations.Migration):
10 | initial = True
11 |
12 | dependencies = []
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name="Category",
17 | fields=[
18 | (
19 | "id",
20 | models.BigAutoField(
21 | auto_created=True,
22 | primary_key=True,
23 | serialize=False,
24 | verbose_name="ID",
25 | ),
26 | ),
27 | ("name", models.CharField(max_length=30)),
28 | ("slug", models.SlugField(max_length=30)),
29 | ],
30 | ),
31 | migrations.CreateModel(
32 | name="Tag",
33 | fields=[
34 | (
35 | "id",
36 | models.BigAutoField(
37 | auto_created=True,
38 | primary_key=True,
39 | serialize=False,
40 | verbose_name="ID",
41 | ),
42 | ),
43 | ("name", models.CharField(max_length=30)),
44 | ],
45 | ),
46 | migrations.CreateModel(
47 | name="Vendor",
48 | fields=[
49 | (
50 | "id",
51 | models.BigAutoField(
52 | auto_created=True,
53 | primary_key=True,
54 | serialize=False,
55 | verbose_name="ID",
56 | ),
57 | ),
58 | ("logo", models.ImageField(blank=True, upload_to="core/vendor_logo")),
59 | ("identifier", models.UUIDField(default=uuid.uuid4, editable=False)),
60 | ("name", models.CharField(max_length=30)),
61 | ("email", models.EmailField(max_length=254)),
62 | ("establishment_date", models.DateField()),
63 | ],
64 | ),
65 | migrations.CreateModel(
66 | name="Product",
67 | fields=[
68 | (
69 | "id",
70 | models.BigAutoField(
71 | auto_created=True,
72 | primary_key=True,
73 | serialize=False,
74 | verbose_name="ID",
75 | ),
76 | ),
77 | ("name", models.CharField(max_length=256)),
78 | ("description", models.TextField(blank=True)),
79 | ("price", models.DecimalField(decimal_places=2, max_digits=6)),
80 | ("available", models.BooleanField(default=True)),
81 | ("quantity", models.IntegerField(default=1)),
82 | ("created_at", models.DateTimeField(auto_now_add=True)),
83 | (
84 | "category",
85 | models.ForeignKey(
86 | blank=True,
87 | null=True,
88 | on_delete=django.db.models.deletion.SET_NULL,
89 | to="core.category",
90 | ),
91 | ),
92 | ("tags", models.ManyToManyField(blank=True, to="core.Tag")),
93 | (
94 | "vendor",
95 | models.OneToOneField(
96 | on_delete=django.db.models.deletion.CASCADE, to="core.vendor"
97 | ),
98 | ),
99 | ],
100 | ),
101 | ]
102 |
--------------------------------------------------------------------------------
/redis_search_django/registry.py:
--------------------------------------------------------------------------------
1 | from collections import defaultdict
2 | from dataclasses import dataclass
3 | from typing import TYPE_CHECKING, Dict, List, Set, Type, Union
4 |
5 | from django.conf import settings
6 | from django.db import models
7 |
8 | if TYPE_CHECKING:
9 | from .documents import Document
10 |
11 |
12 | @dataclass
13 | class DocumentRegistry:
14 | """Registry for Document classes."""
15 |
16 | django_model_map: Dict[Type[models.Model], Set[Type["Document"]]]
17 | related_django_model_map: Dict[Type[models.Model], Set[Type["Document"]]]
18 |
19 | def __init__(self) -> None:
20 | """Initialize the registry."""
21 | self.django_model_map = defaultdict(set)
22 | self.related_django_model_map = defaultdict(set)
23 |
24 | def register(self, document_class: Type["Document"]) -> None:
25 | """Register a Document class."""
26 | self.django_model_map[document_class._django.model].add(document_class)
27 |
28 | for related_model in document_class._django.related_models:
29 | self.related_django_model_map[related_model].add(document_class)
30 |
31 | def update_document(self, model_object: models.Model, create: bool = True) -> None:
32 | """Update document of specific model."""
33 | if not getattr(settings, "REDIS_SEARCH_AUTO_INDEX", True):
34 | return
35 |
36 | document_classes = self.django_model_map.get(model_object.__class__, set())
37 |
38 | for document_class in document_classes:
39 | # Check if Auto Index is turned off for this specific Document.
40 | if not document_class._django.auto_index:
41 | continue
42 |
43 | # Try to Update the Document if not object is created.
44 | document_class.update_from_model_instance(model_object, create=create)
45 |
46 | def update_related_documents(
47 | self,
48 | model_object: models.Model,
49 | exclude: models.Model = None,
50 | ) -> None:
51 | """Update related documents of a specific model."""
52 | if not getattr(settings, "REDIS_SEARCH_AUTO_INDEX", True):
53 | return
54 |
55 | document_classes = self.related_django_model_map.get(
56 | model_object.__class__, set()
57 | )
58 |
59 | # Update the related Documents if any.
60 | for document_class in document_classes:
61 | if not document_class._django.auto_index:
62 | continue
63 |
64 | document_class.update_from_related_model_instance(
65 | model_object, exclude=exclude
66 | )
67 |
68 | def remove_document(self, model_object: models.Model) -> None:
69 | """Remove document of a specific model."""
70 | if not getattr(settings, "REDIS_SEARCH_AUTO_INDEX", True):
71 | return
72 |
73 | document_classes = self.django_model_map.get(model_object.__class__, set())
74 |
75 | for document_class in document_classes:
76 | # Check if Auto Index is turned off for this specific Document.
77 | if not document_class._django.auto_index:
78 | continue
79 |
80 | # Try to Delete the Document from Redis Index.
81 | document_class.delete(model_object.pk)
82 |
83 | def index_documents(self, models: Union[List[str], None] = None) -> None:
84 | """Index documents for all or specific registered Django models."""
85 | for (
86 | django_model,
87 | document_classes,
88 | ) in self.django_model_map.items():
89 | for document_class in document_classes:
90 | if models:
91 | if django_model._meta.label in models:
92 | document_class.index_all()
93 | else:
94 | document_class.index_all()
95 |
96 |
97 | document_registry: DocumentRegistry = DocumentRegistry()
98 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | .idea/
161 |
162 | media/
163 |
--------------------------------------------------------------------------------
/example/example/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for example project.
3 |
4 | Generated by 'django-admin startproject' using Django 3.2.15.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.2/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/3.2/ref/settings/
11 | """
12 |
13 |
14 | from pathlib import Path
15 |
16 | # Build paths inside the project like this: BASE_DIR / 'subdir'.
17 | from typing import List
18 |
19 | BASE_DIR = Path(__file__).resolve().parent.parent
20 |
21 |
22 | # Quick-start development settings - unsuitable for production
23 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
24 |
25 | # SECURITY WARNING: keep the secret key used in production secret!
26 | SECRET_KEY = "django-insecure-*^iil*n4=4iv%d*)#9c!b5s=54cyl$@r&a^lm_$f@gb-*%e^q^"
27 |
28 | # SECURITY WARNING: don't run with debug turned on in production!
29 | DEBUG = True
30 |
31 | ALLOWED_HOSTS: List[str] = []
32 |
33 |
34 | # Application definition
35 |
36 | INSTALLED_APPS = [
37 | "django.contrib.admin",
38 | "django.contrib.auth",
39 | "django.contrib.contenttypes",
40 | "django.contrib.sessions",
41 | "django.contrib.messages",
42 | "django.contrib.staticfiles",
43 | "redis_search_django",
44 | "core",
45 | ]
46 |
47 | MIDDLEWARE = [
48 | "django.middleware.security.SecurityMiddleware",
49 | "django.contrib.sessions.middleware.SessionMiddleware",
50 | "django.middleware.common.CommonMiddleware",
51 | "django.middleware.csrf.CsrfViewMiddleware",
52 | "django.contrib.auth.middleware.AuthenticationMiddleware",
53 | "django.contrib.messages.middleware.MessageMiddleware",
54 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
55 | ]
56 |
57 | ROOT_URLCONF = "example.urls"
58 |
59 | TEMPLATES = [
60 | {
61 | "BACKEND": "django.template.backends.django.DjangoTemplates",
62 | "DIRS": [],
63 | "APP_DIRS": True,
64 | "OPTIONS": {
65 | "context_processors": [
66 | "django.template.context_processors.debug",
67 | "django.template.context_processors.request",
68 | "django.contrib.auth.context_processors.auth",
69 | "django.contrib.messages.context_processors.messages",
70 | ],
71 | },
72 | },
73 | ]
74 |
75 | WSGI_APPLICATION = "example.wsgi.application"
76 |
77 |
78 | # Database
79 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases
80 |
81 | DATABASES = {
82 | "default": {
83 | "ENGINE": "django.db.backends.sqlite3",
84 | "NAME": BASE_DIR / "db.sqlite3",
85 | }
86 | }
87 |
88 |
89 | # Password validation
90 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
91 |
92 | AUTH_PASSWORD_VALIDATORS = [
93 | {
94 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # noqa: E501
95 | },
96 | {
97 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
98 | },
99 | {
100 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
101 | },
102 | {
103 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
104 | },
105 | ]
106 |
107 |
108 | # Internationalization
109 | # https://docs.djangoproject.com/en/3.2/topics/i18n/
110 |
111 | LANGUAGE_CODE = "en-us"
112 |
113 | TIME_ZONE = "UTC"
114 |
115 | USE_I18N = True
116 |
117 | USE_L10N = True
118 |
119 | USE_TZ = True
120 |
121 |
122 | # Static files (CSS, JavaScript, Images)
123 | # https://docs.djangoproject.com/en/3.2/howto/static-files/
124 |
125 | STATIC_URL = "/static/"
126 |
127 | # URL to serve media files
128 | MEDIA_URL = "/media/"
129 |
130 | # Path where media is stored
131 | MEDIA_ROOT = BASE_DIR / "media/"
132 |
133 | # Default primary key field type
134 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
135 |
136 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
137 |
138 | # redis-search-django settings
139 |
140 | REDIS_SEARCH_AUTO_INDEX = True
141 |
--------------------------------------------------------------------------------
/redis_search_django/config.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import uuid
3 | from decimal import Decimal
4 | from typing import Any, Dict, Type
5 |
6 | from django.db import models
7 |
8 | # A mapping for Django model fields and Redis OM fields data
9 | model_field_class_config: Dict[Type[models.Field], Dict[str, Any]] = {
10 | models.AutoField: {
11 | "type": int,
12 | "full_text_search": False,
13 | "index": True,
14 | "sortable": True,
15 | },
16 | models.BigAutoField: {
17 | "type": int,
18 | "full_text_search": False,
19 | "index": True,
20 | "sortable": True,
21 | },
22 | models.BigIntegerField: {
23 | "type": int,
24 | "full_text_search": False,
25 | "index": True,
26 | "sortable": True,
27 | },
28 | # We are using int type for models.BooleanField
29 | # as redis-om creates schema for this field as NUMERIC field.
30 | # see https://github.com/redis/redis-om-python/issues/193
31 | models.BooleanField: {
32 | "type": int,
33 | "full_text_search": False,
34 | "index": True,
35 | "sortable": True,
36 | },
37 | models.CharField: {
38 | "type": str,
39 | "full_text_search": True,
40 | "index": True,
41 | "sortable": True,
42 | },
43 | models.DateField: {
44 | "type": datetime.date,
45 | "full_text_search": False,
46 | "index": True,
47 | "sortable": False,
48 | },
49 | models.DateTimeField: {
50 | "type": datetime.datetime,
51 | "full_text_search": False,
52 | "index": True,
53 | "sortable": False,
54 | },
55 | models.DecimalField: {
56 | "type": Decimal,
57 | "full_text_search": False,
58 | "index": True,
59 | "sortable": True,
60 | },
61 | models.EmailField: {
62 | "type": str,
63 | "full_text_search": True,
64 | "index": True,
65 | "sortable": False,
66 | },
67 | models.FileField: {
68 | "type": str,
69 | "full_text_search": False,
70 | "index": True,
71 | "sortable": False,
72 | },
73 | models.FilePathField: {
74 | "type": str,
75 | "full_text_search": False,
76 | "index": True,
77 | "sortable": False,
78 | },
79 | models.FloatField: {
80 | "type": float,
81 | "full_text_search": False,
82 | "index": True,
83 | "sortable": True,
84 | },
85 | models.ImageField: {
86 | "type": str,
87 | "full_text_search": False,
88 | "index": True,
89 | "sortable": False,
90 | },
91 | models.IntegerField: {
92 | "type": int,
93 | "full_text_search": False,
94 | "index": True,
95 | "sortable": True,
96 | },
97 | models.PositiveIntegerField: {
98 | "type": int,
99 | "full_text_search": False,
100 | "index": True,
101 | "sortable": True,
102 | },
103 | models.PositiveSmallIntegerField: {
104 | "type": int,
105 | "full_text_search": False,
106 | "index": True,
107 | "sortable": True,
108 | },
109 | models.SlugField: {
110 | "type": str,
111 | "full_text_search": False,
112 | "index": True,
113 | "sortable": False,
114 | },
115 | models.SmallIntegerField: {
116 | "type": int,
117 | "full_text_search": False,
118 | "index": True,
119 | "sortable": True,
120 | },
121 | models.TextField: {
122 | "type": str,
123 | "full_text_search": True,
124 | "index": True,
125 | "sortable": False,
126 | },
127 | models.TimeField: {
128 | "type": datetime.time,
129 | "full_text_search": False,
130 | "index": True,
131 | "sortable": False,
132 | },
133 | models.URLField: {
134 | "type": str,
135 | "full_text_search": False,
136 | "index": True,
137 | "sortable": False,
138 | },
139 | models.UUIDField: {
140 | "type": uuid.UUID,
141 | "full_text_search": False,
142 | "index": True,
143 | "sortable": False,
144 | },
145 | }
146 |
--------------------------------------------------------------------------------
/tests/test_mixins.py:
--------------------------------------------------------------------------------
1 | from unittest import mock
2 |
3 | import pytest
4 | from django.core.exceptions import ImproperlyConfigured
5 | from django.test import RequestFactory
6 | from django.utils.functional import cached_property
7 | from django.views import View
8 | from django.views.generic import ListView
9 |
10 | from redis_search_django.documents import JsonDocument
11 | from redis_search_django.mixins import (
12 | RediSearchListViewMixin,
13 | RediSearchMixin,
14 | RediSearchMultipleObjectMixin,
15 | RediSearchTemplateResponseMixin,
16 | )
17 | from redis_search_django.paginator import RediSearchPaginator
18 | from redis_search_django.query import RediSearchQuery
19 |
20 | from .helpers import is_redis_running
21 | from .models import Category
22 |
23 |
24 | def test_redis_search_mixin(document_class):
25 | DocumentClass = document_class(JsonDocument, Category, ["name"])
26 | DocumentClass.db = mock.MagicMock()
27 | mixin = RediSearchMixin()
28 |
29 | with pytest.raises(ImproperlyConfigured):
30 | mixin.search()
31 |
32 | mixin.document_class = DocumentClass
33 |
34 | assert mixin.search_query_expression is None
35 | assert mixin.sort_by is None
36 | assert mixin.facets() is None
37 | assert isinstance(mixin.search(), RediSearchQuery)
38 |
39 |
40 | def test_redis_search_mixin_with_view(document_class):
41 | DocumentClass = document_class(JsonDocument, Category, ["name"])
42 | DocumentClass.db = mock.MagicMock()
43 |
44 | class SearchView(RediSearchMixin, View):
45 | document_class = DocumentClass
46 |
47 | @cached_property
48 | def search_query_expression(self):
49 | return self.document_class.name % "test"
50 |
51 | @cached_property
52 | def sort_by(self):
53 | return "name"
54 |
55 | view = SearchView()
56 | search = view.search()
57 |
58 | assert isinstance(search, RediSearchQuery)
59 | assert search.sort_fields == ["name"]
60 | assert search.expressions
61 |
62 |
63 | def test_redis_search_template_response_mixin(document_class):
64 | DocumentClass = document_class(JsonDocument, Category, ["name"])
65 | DocumentClass.db = mock.MagicMock()
66 |
67 | class SearchView(RediSearchMixin, RediSearchTemplateResponseMixin, View):
68 | document_class = DocumentClass
69 | template_name = "search.html"
70 |
71 | view = SearchView()
72 |
73 | assert view.get_template_names() == ["search.html"]
74 |
75 |
76 | def test_redis_search_template_response_mixin_without_template_name(document_class):
77 | DocumentClass = document_class(JsonDocument, Category, ["name"])
78 | DocumentClass.db = mock.MagicMock()
79 |
80 | class SearchView(RediSearchMixin, RediSearchTemplateResponseMixin, View):
81 | document_class = DocumentClass
82 |
83 | view = SearchView()
84 |
85 | assert view.get_template_names() == ["tests/category_list.html"]
86 |
87 |
88 | def test_redis_search_template_response_mixin_without_document_class(document_class):
89 | DocumentClass = document_class(JsonDocument, Category, ["name"])
90 | DocumentClass.db = mock.MagicMock()
91 |
92 | class SearchView(RediSearchMixin, RediSearchTemplateResponseMixin, View):
93 | document_class = None
94 |
95 | view = SearchView()
96 |
97 | with pytest.raises(ImproperlyConfigured):
98 | view.get_template_names()
99 |
100 |
101 | def test_redis_search_multiple_object_mixin(document_class):
102 | DocumentClass = document_class(JsonDocument, Category, ["name"])
103 | DocumentClass.db = mock.MagicMock()
104 |
105 | class SearchView(RediSearchMixin, RediSearchMultipleObjectMixin, View):
106 | document_class = DocumentClass
107 | context_object_name = "categories"
108 |
109 | view = SearchView()
110 |
111 | assert view.get_context_object_name([]) == "categories"
112 |
113 |
114 | def test_redis_search_multiple_object_mixin_without_object_name(document_class):
115 | DocumentClass = document_class(JsonDocument, Category, ["name"])
116 | DocumentClass.db = mock.MagicMock()
117 |
118 | class SearchView(RediSearchMixin, RediSearchMultipleObjectMixin, View):
119 | document_class = DocumentClass
120 |
121 | view = SearchView()
122 |
123 | assert view.get_context_object_name([]) == "category_list"
124 |
125 |
126 | def test_redis_search_multiple_object_mixin_without_document_class(document_class):
127 | DocumentClass = document_class(JsonDocument, Category, ["name"])
128 | DocumentClass.db = mock.MagicMock()
129 |
130 | class SearchView(RediSearchMixin, RediSearchMultipleObjectMixin, View):
131 | document_class = None
132 |
133 | view = SearchView()
134 |
135 | assert view.get_context_object_name([]) is None
136 |
137 |
138 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
139 | @mock.patch("redis_search_django.documents.JsonDocument.find")
140 | def test_list_view_mixin(find, document_class):
141 | DocumentClass = document_class(JsonDocument, Category, ["name"])
142 |
143 | class SearchView(RediSearchListViewMixin, ListView):
144 | paginate_by = 20
145 | model = Category
146 | template_name = "core/search.html"
147 | document_class = DocumentClass
148 |
149 | request = RequestFactory().get("/some_url")
150 | response = SearchView.as_view()(request)
151 |
152 | find.assert_called_once()
153 | assert isinstance(response.context_data["paginator"], RediSearchPaginator)
154 |
--------------------------------------------------------------------------------
/example/core/templates/core/search.html:
--------------------------------------------------------------------------------
1 | {% load build_query_string %}
2 |
3 |
4 |
5 |
6 |
28 |
29 |
30 | Django Redis Search Example
31 |
68 |
69 | Search results ({{ object_list.count }})
70 |
71 |
72 |
73 | | # ID |
74 | Product Name |
75 | Vendor |
76 | Price |
77 | Quantity |
78 | Category |
79 | Tags |
80 | Added At |
81 |
82 | {% for result in object_list %}
83 |
84 | | {{ result.id }} |
85 | {{ result.name }} |
86 | {{ result.vendor.name }} |
87 | {{ result.price }} $ |
88 | {{ result.quantity }} |
89 | {{ result.category.name }} |
90 | {% for tag in result.tags %} {{ tag.name }} {% if not forloop.last %}, {% endif %}{% endfor %} |
91 | {{ result.created_at }} |
92 |
93 | {% empty %}
94 | No results found. |
95 | {% endfor %}
96 |
97 |
98 | {% if is_paginated %}
99 |
120 | {% endif %}
121 |
122 |
123 |
124 |
--------------------------------------------------------------------------------
/redis_search_django/query.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Generator, List, Type, Union
2 |
3 | from django.db import models
4 | from django.db.models import Case, IntegerField, When
5 | from redis_om import FindQuery, RedisModel
6 |
7 |
8 | class RediSearchResult:
9 | """Class That Stores Redis Search Results"""
10 |
11 | def __init__(
12 | self,
13 | results: List[RedisModel],
14 | hit_count: int,
15 | django_model: Union[Type[models.Model], None],
16 | ):
17 | self.results = results
18 | self.hit_count = hit_count
19 | self.django_model = django_model
20 |
21 | def __str__(self) -> str:
22 | return repr(self)
23 |
24 | def __repr__(self) -> str:
25 | return "<{} {} of {}>".format(
26 | self.__class__.__name__, len(self), self.hit_count
27 | )
28 |
29 | def __iter__(self, *args: Any, **kwargs: Any) -> Generator[Any, None, None]:
30 | yield from self.results
31 |
32 | def __len__(self) -> int:
33 | return len(self.results)
34 |
35 | def __getitem__(self, index: int) -> RedisModel:
36 | return self.results[index]
37 |
38 | def __setitem__(self, index: int, value: RedisModel) -> None:
39 | self.results[index] = value
40 |
41 | def __contains__(self, item: RedisModel) -> bool:
42 | return item in self.results
43 |
44 | def __bool__(self) -> bool:
45 | return bool(self.results)
46 |
47 | def clear(self) -> None:
48 | """Clears the results"""
49 | self.results = []
50 | self.hit_count = 0
51 |
52 | def add(self, data: Union[RedisModel, List[RedisModel]]) -> None:
53 | """Adds data to the results"""
54 | if isinstance(data, list):
55 | self.results += data
56 | else:
57 | self.results.append(data)
58 |
59 | def count(self) -> int:
60 | """
61 | Returns the number of hits.
62 |
63 | This is not same as `len(self.results)` as
64 | `self.results` may contain only paginated results.
65 | """
66 | return self.hit_count
67 |
68 | def exists(self) -> bool:
69 | """Returns True if there are results"""
70 | return bool(self.results)
71 |
72 | def to_queryset(self) -> models.QuerySet:
73 | """Converts the search results to a Django QuerySet"""
74 | if not self.django_model:
75 | raise ValueError("No Django Model has been set")
76 |
77 | # if no results, return empty queryset
78 | if not self.results:
79 | return self.django_model.objects.none()
80 |
81 | pks = [result.pk for result in self.results]
82 | return self.django_model.objects.filter(pk__in=pks).order_by(
83 | Case(
84 | *[When(pk=pk, then=position) for position, pk in enumerate(pks)],
85 | output_field=IntegerField(),
86 | )
87 | )
88 |
89 |
90 | class RediSearchQuery(FindQuery):
91 | def __init__(self, *args: Any, **kwargs: Any) -> None:
92 | self.django_model = kwargs.pop("django_model", None)
93 | super().__init__(*args, **kwargs)
94 | # Initialize the cache with empty RediSearchResult.
95 | self._model_cache: RediSearchResult = RediSearchResult(
96 | results=[], hit_count=0, django_model=self.django_model
97 | )
98 |
99 | def to_queryset(self) -> models.QuerySet:
100 | """Converts the search results to a Django QuerySet"""
101 | if self._model_cache:
102 | return self._model_cache.to_queryset()
103 | # If no results, then execute the query and return Django QuerySet.
104 | return self.execute().to_queryset()
105 |
106 | def all(self, batch_size: int = 10) -> "RediSearchQuery":
107 | """
108 | Only Update batch size and return instance.
109 |
110 | Overridden so that we do not run `execute()` if this method is called.
111 | """
112 | if batch_size != self.page_size:
113 | query = self.copy(page_size=batch_size, limit=batch_size)
114 | return query
115 | return self
116 |
117 | def count(self) -> int:
118 | """Return the number of hits from `_model_cache`"""
119 | return self._model_cache.count()
120 |
121 | def exists(self) -> bool:
122 | """Returns True if there are results from `_model_cache`"""
123 | return self._model_cache.exists()
124 |
125 | def copy(self, **kwargs: Any) -> "RediSearchQuery":
126 | """ "Returns a copy of the class"""
127 | original = self.dict()
128 | original.update(**kwargs)
129 | return self.__class__(**original)
130 |
131 | def paginate(self, offset: int = 0, limit: int = 100) -> None:
132 | """Paginates the results."""
133 | self.offset = offset
134 | self.limit = limit
135 |
136 | def execute(self, exhaust_results: bool = True) -> RediSearchResult:
137 | """Executes the search query and returns a RediSearchResult"""
138 | args = ["ft.search", self.model.Meta.index_name, self.query, *self.pagination]
139 |
140 | if self.sort_fields:
141 | args += self.resolve_redisearch_sort_fields()
142 |
143 | # Reset the cache if we're executing from offset 0.
144 | if self.offset == 0:
145 | self._model_cache.clear()
146 |
147 | # If the offset is greater than 0, we're paginating through a result set,
148 | # so append the new results to results already in the cache.
149 | raw_result = self.model.db().execute_command(*args)
150 | count = raw_result[0]
151 | results = self.model.from_redis(raw_result)
152 | # Update the cache with the new results.
153 | self._model_cache.add(results)
154 | self._model_cache.hit_count = count
155 |
156 | if not exhaust_results:
157 | return self._model_cache
158 |
159 | # The query returned all results, so we have no more work to do.
160 | if count <= len(results):
161 | return self._model_cache
162 |
163 | # Transparently (to the user) make subsequent requests to paginate
164 | # through the results and finally return them all.
165 | query = self
166 | while True:
167 | # Make a query for each pass of the loop, with a new offset equal to the
168 | # current offset plus `page_size`, until we stop getting results back.
169 | query = query.copy(offset=query.offset + query.page_size)
170 | _results = query.execute(exhaust_results=False)
171 | if not _results:
172 | break
173 | self._model_cache.add(_results)
174 |
175 | return self._model_cache
176 |
--------------------------------------------------------------------------------
/tests/test_integration.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | import pytest
4 | from redis_om import NotFoundError
5 |
6 | from redis_search_django.documents import JsonDocument
7 |
8 | from .helpers import is_redis_running
9 | from .models import Category, Product, Tag, Vendor
10 |
11 |
12 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
13 | @pytest.mark.django_db
14 | def test_object_create(document_class):
15 | DocumentClass = document_class(JsonDocument, Category, ["name"])
16 |
17 | category = Category.objects.create(name="test")
18 | document_obj = DocumentClass.get(pk=category.pk)
19 |
20 | assert int(document_obj.pk) == category.pk
21 | assert document_obj.name == category.name
22 |
23 |
24 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
25 | @pytest.mark.django_db
26 | def test_object_update(document_class):
27 | DocumentClass = document_class(JsonDocument, Category, ["name"])
28 |
29 | category = Category.objects.create(name="test")
30 | document_obj = DocumentClass.get(pk=category.pk)
31 |
32 | assert document_obj.name == "test"
33 |
34 | category.name = "test2"
35 | category.save()
36 |
37 | document_obj = DocumentClass.get(pk=category.pk)
38 |
39 | assert document_obj.name == "test2"
40 |
41 |
42 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
43 | @pytest.mark.django_db
44 | def test_object_delete(document_class):
45 | DocumentClass = document_class(JsonDocument, Category, ["name"])
46 |
47 | category = Category.objects.create(name="test")
48 | document_obj = DocumentClass.get(pk=category.pk)
49 |
50 | assert int(document_obj.pk) == category.pk
51 |
52 | category.delete()
53 |
54 | with pytest.raises(NotFoundError):
55 | DocumentClass.get(pk=category.pk)
56 |
57 |
58 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
59 | @pytest.mark.django_db
60 | def test_related_object_add(nested_document_class):
61 | ProductDocumentCalss = nested_document_class[0]
62 | vendor = Vendor.objects.create(
63 | name="test", establishment_date=datetime.date.today()
64 | )
65 | product = Product.objects.create(
66 | name="Test",
67 | price=10.0,
68 | vendor=vendor,
69 | )
70 |
71 | document_obj = ProductDocumentCalss.get(pk=product.pk)
72 |
73 | assert int(document_obj.pk) == product.pk
74 | assert document_obj.name == product.name
75 | assert document_obj.vendor.name == vendor.name
76 | assert document_obj.category is None
77 | assert document_obj.tags == []
78 |
79 | category = Category.objects.create(name="test")
80 | product.category = category
81 | product.save()
82 |
83 | document_obj = ProductDocumentCalss.get(pk=product.pk)
84 |
85 | assert document_obj.category.name == category.name
86 | assert int(document_obj.category.pk) == category.pk
87 | assert document_obj.tags == []
88 |
89 | tag = Tag.objects.create(name="test")
90 | tag2 = Tag.objects.create(name="test2")
91 | product.tags.set([tag, tag2])
92 |
93 | document_obj = ProductDocumentCalss.get(pk=product.pk)
94 |
95 | assert len(document_obj.tags) == 2
96 | assert int(document_obj.tags[0].pk) == tag.pk
97 | assert int(document_obj.tags[1].pk) == tag2.pk
98 |
99 |
100 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
101 | @pytest.mark.django_db
102 | def test_related_object_update(nested_document_class):
103 | ProductDocumentCalss = nested_document_class[0]
104 | vendor = Vendor.objects.create(
105 | name="test", establishment_date=datetime.date.today()
106 | )
107 | product = Product.objects.create(
108 | name="Test",
109 | price=10.0,
110 | vendor=vendor,
111 | )
112 |
113 | document_obj = ProductDocumentCalss.get(pk=product.pk)
114 |
115 | assert document_obj.vendor.name == "test"
116 |
117 | vendor.name = "test2"
118 | vendor.save()
119 |
120 | document_obj = ProductDocumentCalss.get(pk=product.pk)
121 |
122 | assert document_obj.vendor.name == "test2"
123 |
124 | category = Category.objects.create(name="test")
125 | product.category = category
126 | product.save()
127 |
128 | document_obj = ProductDocumentCalss.get(pk=product.pk)
129 |
130 | assert document_obj.category.name == "test"
131 |
132 | category.name = "test2"
133 | category.save()
134 |
135 | document_obj = ProductDocumentCalss.get(pk=product.pk)
136 |
137 | assert document_obj.category.name == "test2"
138 |
139 | tag = Tag.objects.create(name="test")
140 | tag2 = Tag.objects.create(name="test2")
141 | product.tags.set([tag, tag2])
142 |
143 | document_obj = ProductDocumentCalss.get(pk=product.pk)
144 |
145 | assert document_obj.tags[0].name == "test"
146 | assert document_obj.tags[1].name == "test2"
147 |
148 | tag.name = "test3"
149 | tag.save()
150 | tag2.name = "test4"
151 | tag2.save()
152 |
153 | document_obj = ProductDocumentCalss.get(pk=product.pk)
154 |
155 | assert document_obj.tags[0].name == "test3"
156 | assert document_obj.tags[1].name == "test4"
157 |
158 |
159 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
160 | @pytest.mark.django_db
161 | def test_related_object_delete(nested_document_class):
162 | ProductDocumentCalss = nested_document_class[0]
163 | vendor = Vendor.objects.create(
164 | name="test", establishment_date=datetime.date.today()
165 | )
166 | product = Product.objects.create(
167 | name="Test",
168 | price=10.0,
169 | vendor=vendor,
170 | )
171 |
172 | category = Category.objects.create(name="test")
173 | product.category = category
174 | product.save()
175 |
176 | document_obj = ProductDocumentCalss.get(pk=product.pk)
177 |
178 | assert int(document_obj.category.pk) == category.pk
179 |
180 | category.delete()
181 |
182 | document_obj = ProductDocumentCalss.get(pk=product.pk)
183 |
184 | assert document_obj.category is None
185 |
186 | tag = Tag.objects.create(name="test")
187 | tag2 = Tag.objects.create(name="test2")
188 | product.tags.set([tag, tag2])
189 |
190 | document_obj = ProductDocumentCalss.get(pk=product.pk)
191 |
192 | assert int(document_obj.tags[0].pk) == tag.pk
193 | assert int(document_obj.tags[1].pk) == tag2.pk
194 |
195 | tag.delete()
196 |
197 | document_obj = ProductDocumentCalss.get(pk=product.pk)
198 |
199 | assert len(document_obj.tags) == 1
200 | assert int(document_obj.tags[0].pk) == tag2.pk
201 |
202 | tag2.delete()
203 |
204 | document_obj = ProductDocumentCalss.get(pk=product.pk)
205 |
206 | assert len(document_obj.tags) == 0
207 |
208 | vendor.delete()
209 |
210 | with pytest.raises(NotFoundError):
211 | ProductDocumentCalss.get(pk=product.pk)
212 |
--------------------------------------------------------------------------------
/tests/test_query.py:
--------------------------------------------------------------------------------
1 | from unittest import mock
2 |
3 | import pytest
4 | from pytest_django.asserts import assertQuerysetEqual
5 |
6 | from redis_search_django.query import RediSearchQuery, RediSearchResult
7 |
8 | from .models import Category
9 |
10 |
11 | def test_search_result_str():
12 | result = RediSearchResult([], 10, None)
13 | assert str(result) == ""
14 |
15 |
16 | def test_search_result_repr():
17 | result = RediSearchResult([], 10, None)
18 | assert repr(result) == ""
19 |
20 |
21 | def test_search_result_iter():
22 | item_1 = mock.MagicMock()
23 | item_2 = mock.MagicMock()
24 | result = RediSearchResult([item_1, item_2], 10, None)
25 | assert list(result) == [item_1, item_2]
26 |
27 |
28 | def test_search_result_len():
29 | result = RediSearchResult([mock.MagicMock(), mock.MagicMock()], 10, None)
30 | assert len(result) == 2
31 |
32 |
33 | def test_search_result_getitem():
34 | item_1 = mock.MagicMock()
35 | item_2 = mock.MagicMock()
36 | result = RediSearchResult([item_1, item_2], 10, None)
37 |
38 | assert result[0] == item_1
39 | assert result[1] == item_2
40 |
41 |
42 | def test_search_result_setitem():
43 | item = mock.MagicMock()
44 | result = RediSearchResult([mock.MagicMock()], 10, None)
45 | result[0] = item
46 |
47 | assert result[0] == item
48 |
49 |
50 | def test_search_result_contains():
51 | item = mock.MagicMock()
52 | result = RediSearchResult([item], 10, None)
53 | assert item in result
54 |
55 |
56 | def test_search_result_bool():
57 | result = RediSearchResult([mock.MagicMock()], 10, None)
58 | assert bool(result)
59 |
60 |
61 | def test_search_result_clear():
62 | item = mock.MagicMock()
63 | result = RediSearchResult([item], 10, None)
64 | assert len(result) == 1
65 | assert result.hit_count == 10
66 |
67 | result.clear()
68 |
69 | assert len(result) == 0
70 | assert result.hit_count == 0
71 |
72 |
73 | def test_search_result_add():
74 | item = mock.MagicMock()
75 | result = RediSearchResult([], 10, None)
76 |
77 | assert len(result) == 0
78 |
79 | result.add(item)
80 |
81 | assert len(result) == 1
82 | assert result.hit_count == 10
83 |
84 | result.add([item, item])
85 |
86 | assert len(result) == 3
87 |
88 |
89 | def test_search_result_count():
90 | result = RediSearchResult([], 10, None)
91 |
92 | assert result.count() == 10
93 |
94 | result.clear()
95 |
96 | assert result.count() == 0
97 |
98 | result.add([mock.MagicMock(), mock.MagicMock()])
99 |
100 | assert result.count() == 0
101 |
102 |
103 | def test_search_result_exists():
104 | result = RediSearchResult([mock.MagicMock()], 10, None)
105 | assert result.exists()
106 | result.clear()
107 | assert not result.exists()
108 |
109 |
110 | @pytest.mark.django_db
111 | def test_search_result_to_queryset(category_obj):
112 | item = mock.MagicMock(name="Item", pk=category_obj.pk)
113 |
114 | assertQuerysetEqual(
115 | RediSearchResult([item], 10, Category).to_queryset(),
116 | Category.objects.all(),
117 | )
118 | assertQuerysetEqual(
119 | RediSearchResult([], 10, Category).to_queryset(), Category.objects.none()
120 | )
121 |
122 |
123 | @pytest.mark.django_db
124 | def test_search_result_to_queryset_no_model_set():
125 | with pytest.raises(ValueError):
126 | RediSearchResult([], 10, None).to_queryset()
127 |
128 |
129 | @pytest.mark.django_db
130 | def test_search_query_to_queryset(category_obj):
131 | item = mock.MagicMock(name="Item", pk=category_obj.pk)
132 |
133 | query = RediSearchQuery(mock.MagicMock(), model=mock.MagicMock())
134 | query._model_cache = RediSearchResult([item], 10, Category)
135 |
136 | assertQuerysetEqual(query.to_queryset(), Category.objects.all())
137 |
138 |
139 | @mock.patch("redis_search_django.query.RediSearchQuery.execute")
140 | @pytest.mark.django_db
141 | def test_search_query_to_queryset_without_result(execute, category_obj):
142 | query = RediSearchQuery(mock.MagicMock(), model=mock.MagicMock())
143 |
144 | query.to_queryset()
145 |
146 | execute().to_queryset.assert_called_once()
147 |
148 |
149 | @pytest.mark.django_db
150 | def test_search_query_all():
151 | query = RediSearchQuery(mock.MagicMock(), model=mock.MagicMock())
152 | assert isinstance(query.all(), RediSearchQuery)
153 | new_query = query.all(batch_size=1000)
154 | assert isinstance(new_query, RediSearchQuery)
155 | assert new_query.limit == 1000
156 |
157 |
158 | def test_search_query_count():
159 | query = RediSearchQuery(mock.MagicMock(), model=mock.MagicMock())
160 | query._model_cache = RediSearchResult([], 10, None)
161 |
162 | assert query.count() == 10
163 |
164 |
165 | def test_search_query_exists():
166 | query = RediSearchQuery(mock.MagicMock(), model=mock.MagicMock())
167 | query._model_cache = RediSearchResult([mock.MagicMock()], 10, None)
168 | assert query.exists()
169 |
170 |
171 | def test_search_query_copy():
172 | query = RediSearchQuery(mock.MagicMock(), model=mock.MagicMock())
173 | new_query = query.copy()
174 | assert query is not new_query
175 |
176 |
177 | def test_search_query_paginate():
178 | query = RediSearchQuery(mock.MagicMock(), model=mock.MagicMock())
179 | offset = 10
180 | limit = 20
181 | query.paginate(offset=offset, limit=limit)
182 | assert query.offset == offset
183 | assert query.limit == limit
184 |
185 |
186 | def test_search_query_execute():
187 | model = mock.MagicMock()
188 | model.db().execute_command.return_value = [1, [mock.MagicMock()]]
189 | model.from_redis.return_value = [mock.MagicMock()]
190 | query = RediSearchQuery([], model=model)
191 |
192 | result = query.execute()
193 |
194 | assert isinstance(result, RediSearchResult)
195 | assert len(result) == 1
196 | assert result.hit_count == 1
197 |
198 |
199 | def test_search_query_execute_with_exhaust_results():
200 | model = mock.MagicMock()
201 | model.db().execute_command.return_value = [2, [mock.MagicMock()]]
202 | model.from_redis.side_effect = [[mock.MagicMock()], [mock.MagicMock()], []]
203 | query = RediSearchQuery([], model=model)
204 |
205 | result = query.execute(exhaust_results=True)
206 |
207 | assert isinstance(result, RediSearchResult)
208 | assert len(result) == 2
209 | assert result.hit_count == 2
210 |
211 |
212 | def test_search_query_execute_without_offset():
213 | model = mock.MagicMock()
214 | model.db().execute_command.return_value = [1, [mock.MagicMock()]]
215 | model.from_redis.return_value = [mock.MagicMock()]
216 | query = RediSearchQuery([], model=model, offset=0)
217 | query._model_cache = RediSearchResult([mock.MagicMock()], 10, None)
218 |
219 | result = query.execute()
220 |
221 | assert isinstance(result, RediSearchResult)
222 | assert len(result) == 1
223 | assert result.hit_count == 1
224 |
225 |
226 | def test_search_query_execute_with_offset():
227 | model = mock.MagicMock()
228 | model.db().execute_command.return_value = [1, [mock.MagicMock()]]
229 | model.from_redis.return_value = [mock.MagicMock()]
230 | query = RediSearchQuery([], model=model, offset=10)
231 | query._model_cache = RediSearchResult([mock.MagicMock()], 10, None)
232 |
233 | result = query.execute()
234 |
235 | assert isinstance(result, RediSearchResult)
236 | assert len(result) == 2
237 | assert result.hit_count == 1
238 |
--------------------------------------------------------------------------------
/tests/test_registry.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from collections import defaultdict
3 | from typing import List, Optional
4 | from unittest import mock
5 |
6 | from redis_search_django.documents import (
7 | EmbeddedJsonDocument,
8 | HashDocument,
9 | JsonDocument,
10 | )
11 | from redis_search_django.registry import DocumentRegistry
12 | from tests.models import Category, Product, Tag, Vendor
13 |
14 |
15 | def test_empty_registry():
16 | registry = DocumentRegistry()
17 | assert registry.django_model_map == {}
18 | assert registry.related_django_model_map == defaultdict(set)
19 |
20 |
21 | def test_register(document_class):
22 | registry = DocumentRegistry()
23 | VendorDocumentClass = document_class(JsonDocument, Vendor, ["name"])
24 | CategoryDocumentClass = document_class(HashDocument, Category, ["name"])
25 | registry.register(VendorDocumentClass)
26 | registry.register(CategoryDocumentClass)
27 |
28 | assert registry.django_model_map == {
29 | Vendor: {VendorDocumentClass},
30 | Category: {CategoryDocumentClass},
31 | }
32 | assert registry.related_django_model_map == defaultdict(set)
33 |
34 |
35 | def test_register_with_nested_documents(document_class):
36 | CategoryJsonDocument = document_class(JsonDocument, Category, ["name"])
37 | CategoryEmbeddedJsonDocument = document_class(
38 | EmbeddedJsonDocument, Category, ["name"]
39 | )
40 | TagEmbeddedJsonDocument = document_class(EmbeddedJsonDocument, Tag, ["name"])
41 | VendorEmbeddedJsonDocument = document_class(
42 | EmbeddedJsonDocument, Vendor, ["name", "establishment_date"]
43 | )
44 |
45 | class ProductJsonDocument(JsonDocument):
46 | # OnetoOneField
47 | vendor: VendorEmbeddedJsonDocument
48 | # ForeignKey field
49 | category: Optional[CategoryEmbeddedJsonDocument]
50 | # ManyToManyField
51 | tags: List[TagEmbeddedJsonDocument]
52 |
53 | class Django:
54 | model = Product
55 | fields = ["name", "description", "price", "created_at"]
56 | related_models = {
57 | Vendor: {
58 | "related_name": "product",
59 | "many": False,
60 | },
61 | Category: {
62 | "related_name": "product_set",
63 | "many": True,
64 | },
65 | Tag: {
66 | "related_name": "product_set",
67 | "many": True,
68 | },
69 | }
70 |
71 | class ProductLightJsonDocument(JsonDocument):
72 | category: Optional[CategoryEmbeddedJsonDocument]
73 |
74 | class Django:
75 | model = Product
76 | fields = ["name", "description", "price", "created_at"]
77 | related_models = {
78 | Category: {
79 | "related_name": "product_set",
80 | "many": True,
81 | },
82 | }
83 |
84 | registry = DocumentRegistry()
85 | registry.register(ProductJsonDocument)
86 | registry.register(ProductLightJsonDocument)
87 | registry.register(CategoryJsonDocument)
88 |
89 | assert registry.django_model_map == {
90 | Product: {ProductJsonDocument, ProductLightJsonDocument},
91 | Category: {CategoryJsonDocument},
92 | }
93 | assert registry.related_django_model_map == {
94 | Category: {ProductJsonDocument, ProductLightJsonDocument},
95 | Vendor: {ProductJsonDocument},
96 | Tag: {ProductJsonDocument},
97 | }
98 |
99 |
100 | @mock.patch("redis_search_django.documents.JsonDocument.update_from_model_instance")
101 | def test_update_document(update_from_model_instance, document_class):
102 | CategoryJsonDocument = document_class(JsonDocument, Category, ["name"])
103 | registry = DocumentRegistry()
104 | registry.register(CategoryJsonDocument)
105 |
106 | model_obj1 = Category(name="test")
107 | # Tag is not registered, so it should not be updated.
108 | model_obj2 = Tag(name="test")
109 |
110 | registry.update_document(model_obj1)
111 | registry.update_document(model_obj2)
112 |
113 | update_from_model_instance.assert_called_once_with(model_obj1, create=True)
114 |
115 |
116 | @mock.patch("redis_search_django.documents.JsonDocument.update_from_model_instance")
117 | def test_update_document_with_auto_index_disabled(
118 | update_from_model_instance, document_class
119 | ):
120 | CategoryJsonDocument = document_class(
121 | JsonDocument, Category, ["name"], enable_auto_index=False
122 | )
123 | registry = DocumentRegistry()
124 | registry.register(CategoryJsonDocument)
125 | model_obj1 = Category(name="test")
126 |
127 | registry.update_document(model_obj1)
128 | update_from_model_instance.assert_not_called()
129 |
130 |
131 | @mock.patch("redis_search_django.documents.JsonDocument.update_from_model_instance")
132 | def test_update_document_with_global_auto_index_disabled(
133 | update_from_model_instance, settings, document_class
134 | ):
135 | settings.REDIS_SEARCH_AUTO_INDEX = False
136 |
137 | CategoryJsonDocument = document_class(JsonDocument, Category, ["name"])
138 | registry = DocumentRegistry()
139 | registry.register(CategoryJsonDocument)
140 | model_obj1 = Category(name="test")
141 |
142 | registry.update_document(model_obj1)
143 | update_from_model_instance.assert_not_called()
144 |
145 |
146 | @mock.patch(
147 | "redis_search_django.documents.JsonDocument.update_from_related_model_instance"
148 | )
149 | def test_update_related_documents(update_from_related_model_instance, document_class):
150 | CategoryEmbeddedJsonDocument = document_class(
151 | EmbeddedJsonDocument, Category, ["name"]
152 | )
153 |
154 | class ProductJsonDocument(JsonDocument):
155 | category: Optional[CategoryEmbeddedJsonDocument]
156 |
157 | class Django:
158 | model = Product
159 | fields = ["name", "description", "price", "created_at"]
160 | related_models = {
161 | Category: {
162 | "related_name": "product_set",
163 | "many": True,
164 | },
165 | }
166 |
167 | registry = DocumentRegistry()
168 | registry.register(ProductJsonDocument)
169 |
170 | model_obj1 = Category(name="test")
171 | # Tag and Vendor are not registered, so it should not be updated.
172 | model_obj2 = Tag(name="test")
173 | model_obj3 = Vendor(name="test", establishment_date=datetime.date.today())
174 |
175 | registry.update_related_documents(model_obj1)
176 | registry.update_related_documents(model_obj2)
177 | registry.update_related_documents(model_obj3)
178 |
179 | update_from_related_model_instance.assert_called_once_with(model_obj1, exclude=None)
180 |
181 |
182 | @mock.patch(
183 | "redis_search_django.documents.JsonDocument.update_from_related_model_instance"
184 | )
185 | def test_update_related_documents_with_auto_index_disabled(
186 | update_from_related_model_instance, document_class
187 | ):
188 | CategoryEmbeddedJsonDocument = document_class(
189 | EmbeddedJsonDocument, Category, ["name"], enable_auto_index=False
190 | )
191 |
192 | class ProductJsonDocument(JsonDocument):
193 | category: Optional[CategoryEmbeddedJsonDocument]
194 |
195 | class Django:
196 | model = Product
197 | fields = ["name", "description", "price", "created_at"]
198 | related_models = {
199 | Category: {
200 | "related_name": "product_set",
201 | "many": True,
202 | },
203 | }
204 | auto_index = False
205 |
206 | registry = DocumentRegistry()
207 | registry.register(ProductJsonDocument)
208 |
209 | model_obj1 = Category(name="test")
210 |
211 | registry.update_related_documents(model_obj1)
212 |
213 | update_from_related_model_instance.assert_not_called()
214 |
215 |
216 | @mock.patch(
217 | "redis_search_django.documents.JsonDocument.update_from_related_model_instance"
218 | )
219 | def test_update_related_documents_with_global_auto_index_disabled(
220 | update_from_related_model_instance, settings, document_class
221 | ):
222 | settings.REDIS_SEARCH_AUTO_INDEX = False
223 | CategoryEmbeddedJsonDocument = document_class(
224 | EmbeddedJsonDocument, Category, ["name"]
225 | )
226 |
227 | class ProductJsonDocument(JsonDocument):
228 | category: Optional[CategoryEmbeddedJsonDocument]
229 |
230 | class Django:
231 | model = Product
232 | fields = ["name", "description", "price", "created_at"]
233 | related_models = {
234 | Category: {
235 | "related_name": "product_set",
236 | "many": True,
237 | },
238 | }
239 | auto_index = True
240 |
241 | registry = DocumentRegistry()
242 | registry.register(ProductJsonDocument)
243 |
244 | model_obj1 = Category(name="test")
245 |
246 | registry.update_related_documents(model_obj1)
247 |
248 | update_from_related_model_instance.assert_not_called()
249 |
250 |
251 | @mock.patch("redis_search_django.documents.JsonDocument.delete")
252 | def test_remove_document(delete, document_class):
253 | CategoryJsonDocument = document_class(JsonDocument, Category, ["name"])
254 | registry = DocumentRegistry()
255 | registry.register(CategoryJsonDocument)
256 |
257 | model_obj1 = Category(name="test")
258 | # Tag is not registered, so it should not be deleted.
259 | model_obj2 = Tag(name="test")
260 |
261 | registry.remove_document(model_obj1)
262 | registry.remove_document(model_obj2)
263 |
264 | delete.assert_called_once_with(model_obj1.pk)
265 |
266 |
267 | @mock.patch("redis_search_django.documents.JsonDocument.delete")
268 | def test_remove_document_with_auto_index_disabled(delete, document_class):
269 | CategoryJsonDocument = document_class(
270 | JsonDocument, Category, ["name"], enable_auto_index=False
271 | )
272 | registry = DocumentRegistry()
273 | registry.register(CategoryJsonDocument)
274 |
275 | model_obj1 = Category(name="test")
276 |
277 | registry.remove_document(model_obj1)
278 |
279 | delete.assert_not_called()
280 |
281 |
282 | @mock.patch("redis_search_django.documents.JsonDocument.delete")
283 | def test_remove_document_with_global_auto_index_disabled(
284 | delete, settings, document_class
285 | ):
286 | settings.REDIS_SEARCH_AUTO_INDEX = False
287 |
288 | CategoryJsonDocument = document_class(JsonDocument, Category, ["name"])
289 | registry = DocumentRegistry()
290 | registry.register(CategoryJsonDocument)
291 |
292 | model_obj1 = Category(name="test")
293 |
294 | registry.remove_document(model_obj1)
295 |
296 | delete.assert_not_called()
297 |
298 |
299 | @mock.patch("redis_search_django.documents.Document.index_all")
300 | def test_index_documents(index_all, document_class):
301 | registry = DocumentRegistry()
302 | VendorDocumentClass = document_class(JsonDocument, Vendor, ["name"])
303 | CategoryDocumentClass = document_class(HashDocument, Category, ["name"])
304 | registry.register(VendorDocumentClass)
305 | registry.register(CategoryDocumentClass)
306 |
307 | registry.index_documents()
308 |
309 | assert index_all.call_count == 2
310 |
311 |
312 | @mock.patch("redis_search_django.documents.Document.index_all")
313 | def test_index_documents_with_specific_model(index_all, document_class):
314 | registry = DocumentRegistry()
315 | VendorDocumentClass = document_class(JsonDocument, Vendor, ["name"])
316 | CategoryDocumentClass = document_class(HashDocument, Category, ["name"])
317 | registry.register(VendorDocumentClass)
318 | registry.register(CategoryDocumentClass)
319 |
320 | registry.index_documents(["tests.Category"])
321 |
322 | index_all.assert_called_once()
323 |
--------------------------------------------------------------------------------
/redis_search_django/documents.py:
--------------------------------------------------------------------------------
1 | import operator
2 | from abc import ABC
3 | from dataclasses import dataclass
4 | from functools import reduce
5 | from typing import Any, Dict, List, Type, Union
6 |
7 | from django.core.exceptions import ImproperlyConfigured
8 | from django.db import models
9 | from pydantic.fields import ModelField
10 | from redis.commands.search.aggregation import AggregateRequest
11 | from redis_om import Field, HashModel, JsonModel
12 | from redis_om.model.model import (
13 | EmbeddedJsonModel,
14 | Expression,
15 | NotFoundError,
16 | RedisModel,
17 | )
18 |
19 | from .config import model_field_class_config
20 | from .query import RediSearchQuery
21 | from .registry import document_registry
22 |
23 |
24 | @dataclass
25 | class DjangoOptions:
26 | """Settings for a Django model."""
27 |
28 | model: models.Model
29 | fields: List[str]
30 | select_related_fields: List[str]
31 | prefetch_related_fields: List[str]
32 | related_models: Dict[Type[models.Model], Dict[str, Union[str, bool]]]
33 | auto_index: bool
34 |
35 | def __init__(self, options: Any = None) -> None:
36 | self.model = getattr(options, "model", None)
37 |
38 | if not self.model:
39 | raise ImproperlyConfigured("Django options requires field `model`.")
40 |
41 | # If the attributes do not exist or explicitly set to None,
42 | # use the default value
43 | self.fields = getattr(options, "fields", None) or []
44 | self.select_related_fields = (
45 | getattr(options, "select_related_fields", None) or []
46 | )
47 | self.prefetch_related_fields = (
48 | getattr(options, "prefetch_related_fields", None) or []
49 | )
50 | self.related_models = getattr(options, "related_models", None) or {}
51 | self.auto_index = getattr(options, "auto_index", True)
52 |
53 |
54 | class Document(RedisModel, ABC):
55 | """Base class for all documents."""
56 |
57 | _query_class = RediSearchQuery
58 |
59 | class Meta:
60 | global_key_prefix = "redis_search"
61 |
62 | @classmethod
63 | def find(
64 | cls, *expressions: Union[Any, Expression], **kwargs: Any
65 | ) -> RediSearchQuery:
66 | """Query Redis Search Index for documents."""
67 | return cls._query_class(
68 | expressions=expressions, django_model=cls._django.model, model=cls, **kwargs
69 | )
70 |
71 | @classmethod
72 | def build_aggregate_request(
73 | cls, *expressions: Union[Any, Expression]
74 | ) -> AggregateRequest:
75 | """Build an aggregation request using the given expressions"""
76 | search_query_expression = (
77 | cls._query_class.resolve_redisearch_query(
78 | reduce(operator.and_, expressions)
79 | )
80 | if expressions
81 | else "*"
82 | )
83 | return AggregateRequest(search_query_expression)
84 |
85 | @classmethod
86 | def aggregate(cls, aggregate_request: AggregateRequest) -> List[Dict[str, Any]]:
87 | """Aggregate data and return a list of dictionaries containing the results"""
88 | results = cls.db().ft(cls._meta.index_name).aggregate(aggregate_request)
89 |
90 | return [
91 | {
92 | decode_string(result[i]): decode_string(result[i + 1])
93 | for i in range(0, len(result), 2)
94 | }
95 | for result in results.rows
96 | ]
97 |
98 | @classmethod
99 | def data_from_model_instance(
100 | cls, instance: models.Model, exclude_obj: Union[models.Model, None] = None
101 | ) -> Dict[str, Any]:
102 | """Build a document data dictionary from a Django Model instance"""
103 | data = {}
104 |
105 | for field_name, field in cls.__fields__.items():
106 | field_type = field.type_
107 | # Check if Document is embedded in another Document
108 | is_embedded = issubclass(field_type, EmbeddedJsonDocument)
109 | # Get Target Field Value
110 | target = getattr(instance, field_name, None)
111 | # If the target field is a ManyToManyField,
112 | # get the related objects and build data using the objects
113 | if (
114 | is_embedded
115 | and target
116 | and hasattr(target, "all")
117 | and callable(target.all)
118 | ):
119 | data[field_name] = [
120 | field_type.data_from_model_instance(obj, exclude_obj=exclude_obj)
121 | for obj in target.all()
122 | if obj != exclude_obj
123 | ]
124 | # If the target field is a ForeignKey or OneToOneField,
125 | # get the related object to build data
126 | elif is_embedded and target:
127 | if target != exclude_obj:
128 | data[field_name] = field_type.data_from_model_instance(
129 | target, exclude_obj=exclude_obj
130 | )
131 | else:
132 | # Check if the Document class has a `prepare_{field_name}` class method
133 | # and use it to prepare the field value
134 | prepare_func = getattr(cls, f"prepare_{field_name}", None)
135 | value = prepare_func(instance) if prepare_func else target
136 |
137 | # If the field is required and the value is None, raise an error
138 | if value is None and field.required:
139 | raise ValueError(
140 | f"Field '{field_name}' is required, either use a Django "
141 | f"model field or define 'prepare_{field_name}' class "
142 | f"method on the '{cls.__name__}' class "
143 | f"that returns a value of type {field_type}"
144 | )
145 |
146 | if field_name == "pk":
147 | value = str(value)
148 |
149 | # This is added to convert models.BooleanField value to int
150 | # as redis-om creates schema for the field as NUMERIC field.
151 | # see https://github.com/redis/redis-om-python/issues/193
152 | data[field_name] = int(value) if isinstance(value, bool) else value
153 | return data
154 |
155 | @classmethod
156 | def from_data(cls, data: Dict[str, Any]) -> RedisModel:
157 | """Build a document from a data dictionary"""
158 | return cls(**data)
159 |
160 | @classmethod
161 | def from_model_instance(
162 | cls,
163 | instance: models.Model,
164 | exclude_obj: Union[models.Model, None] = None,
165 | save: bool = True,
166 | ) -> RedisModel:
167 | """Build a document from a Django Model instance"""
168 | assert instance._meta.model == cls._django.model
169 |
170 | obj = cls.from_data(
171 | cls.data_from_model_instance(instance, exclude_obj=exclude_obj)
172 | )
173 |
174 | if save:
175 | obj.save()
176 |
177 | return obj
178 |
179 | @classmethod
180 | def update_from_model_instance(
181 | cls, instance: models.Model, create: bool = True
182 | ) -> RedisModel:
183 | """Update a document from a Django Model instance"""
184 | try:
185 | obj = cls.get(instance.pk)
186 | obj.update(**cls.data_from_model_instance(instance))
187 | except NotFoundError:
188 | if not create:
189 | raise
190 | # Create the Document if not found.
191 | obj = cls.from_model_instance(instance, save=True)
192 | return obj
193 |
194 | @classmethod
195 | def update_from_related_model_instance(
196 | cls, instance: models.Model, exclude: models.Model = None
197 | ) -> None:
198 | """Update a document from a Django Related Model instance"""
199 | related_model = instance.__class__
200 | related_model_config = cls._django.related_models.get(related_model)
201 |
202 | # If the related model is not configured, return
203 | if not related_model_config:
204 | return
205 |
206 | related_name = related_model_config["related_name"]
207 | attribute = getattr(instance, related_name, None)
208 |
209 | # If the related name attribute is not found, return
210 | if not attribute:
211 | return
212 |
213 | if related_model_config["many"]:
214 | cls.index_queryset(attribute.all(), exclude_obj=exclude)
215 | else:
216 | # If the related model instance will delete
217 | # the document's Django model instance
218 | # Then we need to skip re-indexing the document
219 | if (
220 | exclude
221 | and related_model._meta.get_field(related_name).on_delete
222 | == models.CASCADE
223 | ):
224 | exclude = None
225 | cls.from_model_instance(attribute, exclude_obj=exclude, save=True)
226 |
227 | @classmethod
228 | def index_queryset(
229 | cls,
230 | queryset: models.QuerySet,
231 | exclude_obj: Union[models.Model, None] = None,
232 | ) -> None:
233 | """Index all items in the Django model queryset"""
234 | obj_list = []
235 |
236 | for instance in queryset.iterator(chunk_size=2000):
237 | obj_list.append(
238 | cls.from_model_instance(instance, exclude_obj=exclude_obj, save=False)
239 | )
240 |
241 | cls.add(obj_list)
242 |
243 | @classmethod
244 | def index_all(cls) -> None:
245 | """Index all instances of the model"""
246 | cls.index_queryset(cls.get_queryset())
247 |
248 | @classmethod
249 | def get_queryset(cls) -> models.QuerySet:
250 | """Get Django model queryset, can be overridden to filter queryset"""
251 | queryset = cls._django.model._default_manager.all()
252 |
253 | if cls._django.select_related_fields:
254 | queryset = queryset.select_related(*cls._django.select_related_fields)
255 |
256 | if cls._django.prefetch_related_fields:
257 | queryset = queryset.prefetch_related(*cls._django.prefetch_related_fields)
258 | return queryset
259 |
260 | @classmethod
261 | def add_django_fields(cls, field_names: List[str]) -> None:
262 | """Dynamically add fields to the document"""
263 | fields: Dict[str, ModelField] = {}
264 | type_annotations: Dict[str, type] = {}
265 | is_embedded = issubclass(cls, EmbeddedJsonDocument)
266 |
267 | for field_name in field_names:
268 | if field_name in cls.__fields__ or field_name in ["id", "pk"]:
269 | continue
270 |
271 | field_type = cls._django.model._meta.get_field(field_name)
272 |
273 | field_config = model_field_class_config.get(field_type.__class__)
274 |
275 | if not field_config:
276 | raise ImproperlyConfigured(
277 | f"Either the field '{field_type}' is not a Django model field or "
278 | "is a Related Model Field (OneToOneField, ForeignKey, ManyToMany) "
279 | f"which needs to be explicitly added to the '{cls.__name__}' "
280 | f"document class using 'EmbeddedJsonDocument'"
281 | )
282 |
283 | field_config = field_config.copy()
284 | annotation = field_config.pop("type")
285 |
286 | if is_embedded:
287 | field_config["full_text_search"] = False
288 |
289 | if annotation == str:
290 | field_config["sortable"] = False
291 |
292 | required = not (field_type.null or field_type.blank)
293 | field_info = Field(**field_config)
294 | type_annotations[field_name] = annotation
295 |
296 | # Create a new Field object with the field_info dict
297 | fields[field_name] = ModelField(
298 | name=field_name,
299 | class_validators={},
300 | model_config=cls.__config__,
301 | type_=annotation,
302 | required=required,
303 | field_info=field_info,
304 | )
305 |
306 | cls.__fields__.update(fields)
307 | cls.__annotations__.update(type_annotations)
308 |
309 | @property
310 | def id(self) -> Union[int, str]:
311 | """Alias for the primary key of the document"""
312 | return self.pk
313 |
314 |
315 | def decode_string(value: Union[str, bytes]) -> str:
316 | """Decode a string from bytes to str"""
317 |
318 | if isinstance(value, bytes):
319 | return value.decode("utf-8")
320 | return value
321 |
322 |
323 | class JsonDocument(Document, JsonModel, ABC):
324 | """A Document that uses Redis JSON storage"""
325 |
326 | def __init_subclass__(cls, **kwargs: Any) -> None:
327 | """Initialize the Subclass class with proper Django options and fields"""
328 | cls._django = DjangoOptions(getattr(cls, "Django", None))
329 |
330 | if cls._django.fields:
331 | cls.add_django_fields(cls._django.fields)
332 |
333 | super().__init_subclass__(**kwargs)
334 | document_registry.register(cls)
335 |
336 |
337 | class EmbeddedJsonDocument(Document, EmbeddedJsonModel, ABC):
338 | """An Embedded Document that uses Redis JSON storage"""
339 |
340 | def __init_subclass__(cls, **kwargs: Any) -> None:
341 | """Initialize the Subclass class with proper Django options and fields"""
342 | cls._django = DjangoOptions(getattr(cls, "Django", None))
343 |
344 | if cls._django.fields:
345 | cls.add_django_fields(cls._django.fields)
346 |
347 | super().__init_subclass__(**kwargs)
348 | document_registry.register(cls)
349 |
350 |
351 | class HashDocument(Document, HashModel, ABC):
352 | """A Document that uses Redis Hash storage"""
353 |
354 | def __init_subclass__(cls, **kwargs: Any) -> None:
355 | """Initialize the Subclass class with proper Django options and fields"""
356 | cls._django = DjangoOptions(getattr(cls, "Django", None))
357 |
358 | if cls._django.fields:
359 | cls.add_django_fields(cls._django.fields)
360 |
361 | super().__init_subclass__(**kwargs)
362 | document_registry.register(cls)
363 |
--------------------------------------------------------------------------------
/tests/test_documents.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from unittest import mock
3 |
4 | import pytest
5 | from django.core.exceptions import ImproperlyConfigured
6 | from redis.commands.search import reducers
7 | from redis_om import Migrator, NotFoundError
8 |
9 | from redis_search_django.documents import (
10 | DjangoOptions,
11 | EmbeddedJsonDocument,
12 | HashDocument,
13 | JsonDocument,
14 | )
15 |
16 | from .helpers import is_redis_running
17 | from .models import Category, Product, Tag, Vendor
18 |
19 |
20 | def test_django_options():
21 | class Django:
22 | model = Product
23 | fields = ["name", "description", "price", "created_at"]
24 | select_related_fields = ["vendor", "category"]
25 | prefetch_related_fields = ["tags"]
26 | auto_index = True
27 | related_models = {
28 | Vendor: {
29 | "related_name": "product",
30 | "many": False,
31 | },
32 | Category: {
33 | "related_name": "product_set",
34 | "many": True,
35 | },
36 | Tag: {
37 | "related_name": "product_set",
38 | "many": True,
39 | },
40 | }
41 |
42 | options = DjangoOptions(Django)
43 |
44 | assert options.model == Product
45 | assert options.auto_index is True
46 | assert options.fields == ["name", "description", "price", "created_at"]
47 | assert options.select_related_fields == ["vendor", "category"]
48 | assert options.prefetch_related_fields == ["tags"]
49 | assert options.related_models == {
50 | Vendor: {
51 | "related_name": "product",
52 | "many": False,
53 | },
54 | Category: {
55 | "related_name": "product_set",
56 | "many": True,
57 | },
58 | Tag: {
59 | "related_name": "product_set",
60 | "many": True,
61 | },
62 | }
63 |
64 |
65 | def test_django_options_with_empty_optional_value():
66 | class Django:
67 | model = Product
68 | fields = None
69 | auto_index = False
70 | related_models = None
71 | select_related_fields = None
72 | prefetch_related_fields = None
73 |
74 | options = DjangoOptions(Django)
75 |
76 | assert options.model == Product
77 | assert options.auto_index is False
78 | assert options.fields == []
79 | assert options.select_related_fields == []
80 | assert options.prefetch_related_fields == []
81 | assert options.related_models == {}
82 |
83 |
84 | def test_django_options_with_empty_required_value():
85 | class Django:
86 | model = None
87 |
88 | with pytest.raises(ImproperlyConfigured):
89 | DjangoOptions(Django)
90 |
91 |
92 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
93 | @pytest.mark.django_db
94 | def test_document_id(document_class, category_obj):
95 | CategoryJsonDocument = document_class(JsonDocument, Category, ["name"])
96 |
97 | document = CategoryJsonDocument.from_model_instance(category_obj, save=False)
98 |
99 | assert document.id == str(category_obj.id)
100 | assert document.id == document.pk
101 |
102 |
103 | def test_json_document_with_django_fields_including_related_field(document_class):
104 | with pytest.raises(ImproperlyConfigured):
105 | document_class(JsonDocument, Product, ["name", "vendor"])
106 |
107 |
108 | def test_json_document_with_django_fields(document_class):
109 | CategoryJsonDocument = document_class(JsonDocument, Category, ["name"])
110 |
111 | assert list(CategoryJsonDocument.__fields__.keys()) == ["pk", "name"]
112 |
113 |
114 | def test_json_document_with_django_fields_including_id(document_class):
115 | CategoryJsonDocument = document_class(JsonDocument, Category, ["name", "id"])
116 |
117 | assert list(CategoryJsonDocument.__fields__.keys()) == ["pk", "name"]
118 |
119 |
120 | def test_json_document_without_django_fields(document_class):
121 | CategoryJsonDocument = document_class(JsonDocument, Category, [])
122 |
123 | assert list(CategoryJsonDocument.__fields__.keys()) == ["pk"]
124 |
125 |
126 | def test_embedded_json_document_with_django_fields(document_class):
127 | CategoryJsonDocument = document_class(EmbeddedJsonDocument, Category, ["name"])
128 |
129 | assert list(CategoryJsonDocument.__fields__.keys()) == ["pk", "name"]
130 |
131 |
132 | def test_embedded_json_document_without_django_fields(document_class):
133 | CategoryJsonDocument = document_class(EmbeddedJsonDocument, Category, [])
134 |
135 | assert list(CategoryJsonDocument.__fields__.keys()) == ["pk"]
136 |
137 |
138 | def test_hash_document_with_django_fields(document_class):
139 | CategoryJsonDocument = document_class(HashDocument, Category, ["name"])
140 |
141 | assert list(CategoryJsonDocument.__fields__.keys()) == ["pk", "name"]
142 |
143 |
144 | def test_hash_document_without_django_fields(document_class):
145 | CategoryJsonDocument = document_class(HashDocument, Category, [])
146 |
147 | assert list(CategoryJsonDocument.__fields__.keys()) == ["pk"]
148 |
149 |
150 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
151 | @pytest.mark.django_db
152 | def test_get_queryset(document_class, product_obj):
153 | ProductJsonDocument = document_class(JsonDocument, Product, ["name"])
154 | assert ProductJsonDocument.get_queryset().count() == 1
155 |
156 |
157 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
158 | @pytest.mark.django_db
159 | def test_index_all(nested_document_class):
160 | ProductDocumentCalss = nested_document_class[0]
161 | vendor = Vendor.objects.create(
162 | name="test", establishment_date=datetime.date.today()
163 | )
164 | product = Product.objects.create(
165 | name="Test",
166 | price=10.0,
167 | vendor=vendor,
168 | category=Category.objects.create(name="test"),
169 | )
170 |
171 | tag = Tag.objects.create(name="test")
172 | tag2 = Tag.objects.create(name="test2")
173 | product.tags.set([tag, tag2])
174 |
175 | ProductDocumentCalss.delete(pk=product.pk)
176 |
177 | with pytest.raises(NotFoundError):
178 | ProductDocumentCalss.get(pk=product.pk)
179 |
180 | ProductDocumentCalss.index_all()
181 |
182 | assert ProductDocumentCalss.get(pk=product.pk).pk == str(product.pk)
183 |
184 |
185 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
186 | @pytest.mark.django_db
187 | def test_index_queryset(nested_document_class):
188 | ProductDocumentCalss = nested_document_class[0]
189 | vendor = Vendor.objects.create(
190 | name="test", establishment_date=datetime.date.today()
191 | )
192 | product = Product.objects.create(
193 | name="Test",
194 | price=10.0,
195 | vendor=vendor,
196 | category=Category.objects.create(name="test"),
197 | )
198 |
199 | tag = Tag.objects.create(name="test")
200 | tag2 = Tag.objects.create(name="test2")
201 | product.tags.set([tag, tag2])
202 |
203 | ProductDocumentCalss.delete(pk=product.pk)
204 |
205 | with pytest.raises(NotFoundError):
206 | ProductDocumentCalss.get(pk=product.pk)
207 |
208 | ProductDocumentCalss.index_queryset(Product.objects.all())
209 |
210 | assert ProductDocumentCalss.get(pk=product.pk).pk == str(product.pk)
211 |
212 |
213 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
214 | @pytest.mark.django_db
215 | def test_update_from_model_instance(document_class, category_obj):
216 | CategoryDocumentClass = document_class(JsonDocument, Category, ["name"])
217 |
218 | with pytest.raises(NotFoundError):
219 | CategoryDocumentClass.get(pk=category_obj.pk)
220 |
221 | CategoryDocumentClass.update_from_model_instance(category_obj, create=True)
222 |
223 | assert CategoryDocumentClass.get(pk=category_obj.pk) is not None
224 |
225 |
226 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
227 | @pytest.mark.django_db
228 | def test_update_from_model_instance_no_create(document_class, category_obj):
229 | CategoryDocumentClass = document_class(JsonDocument, Category, ["name"])
230 |
231 | with pytest.raises(NotFoundError):
232 | CategoryDocumentClass.get(pk=category_obj.pk)
233 |
234 | with pytest.raises(NotFoundError):
235 | CategoryDocumentClass.update_from_model_instance(category_obj, create=False)
236 |
237 |
238 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
239 | def test_from_data(document_class):
240 | CategoryDocumentClass = document_class(JsonDocument, Category, ["name"])
241 |
242 | data = {"name": "test"}
243 | document = CategoryDocumentClass.from_data(data)
244 |
245 | assert document.name == "test"
246 |
247 |
248 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
249 | @pytest.mark.django_db
250 | def test_from_model_instance(document_class, category_obj):
251 | CategoryDocumentClass = document_class(JsonDocument, Category, ["name"])
252 |
253 | with pytest.raises(NotFoundError):
254 | CategoryDocumentClass.get(pk=category_obj.pk)
255 |
256 | CategoryDocumentClass.from_model_instance(category_obj)
257 |
258 | assert CategoryDocumentClass.get(pk=category_obj.pk) is not None
259 |
260 |
261 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
262 | @pytest.mark.django_db
263 | def test_update_from_related_model_instance(nested_document_class, product_with_tag):
264 | ProductDocumentCalss = nested_document_class[0]
265 | product = Product.objects.create(
266 | name="Test",
267 | price=10.0,
268 | vendor=Vendor.objects.create(
269 | name="test", establishment_date=datetime.date.today()
270 | ),
271 | )
272 |
273 | tag = Tag.objects.create(name="test")
274 | product.tags.add(tag)
275 |
276 | with mock.patch(
277 | "redis_search_django.registry.DocumentRegistry.update_related_documents"
278 | ):
279 | tag.name = "test2"
280 | tag.save()
281 |
282 | assert ProductDocumentCalss.get(pk=product.pk).tags[0].name == "test"
283 |
284 | ProductDocumentCalss.update_from_related_model_instance(tag)
285 |
286 | assert ProductDocumentCalss.get(pk=product.pk).tags[0].name == "test2"
287 |
288 |
289 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
290 | @pytest.mark.django_db
291 | def test_update_from_related_model_instance_one_to_one(
292 | nested_document_class, product_with_tag
293 | ):
294 | ProductDocumentCalss = nested_document_class[0]
295 | vendor = Vendor.objects.create(
296 | name="test", establishment_date=datetime.date.today()
297 | )
298 | product = Product.objects.create(name="Test", price=10.0, vendor=vendor)
299 |
300 | with mock.patch(
301 | "redis_search_django.registry.DocumentRegistry.update_related_documents"
302 | ):
303 | vendor.name = "test2"
304 | vendor.save()
305 |
306 | assert ProductDocumentCalss.get(pk=product.pk).vendor.name == "test"
307 |
308 | ProductDocumentCalss.update_from_related_model_instance(vendor)
309 |
310 | assert ProductDocumentCalss.get(pk=product.pk).vendor.name == "test2"
311 |
312 |
313 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
314 | @pytest.mark.django_db
315 | def test_update_from_related_model_instance_exclude_required_field(
316 | nested_document_class, product_with_tag
317 | ):
318 | ProductDocumentCalss = nested_document_class[0]
319 | vendor = Vendor.objects.create(
320 | name="test", establishment_date=datetime.date.today()
321 | )
322 | product = Product.objects.create(name="Test", price=10.0, vendor=vendor)
323 |
324 | assert ProductDocumentCalss.get(pk=product.pk).vendor.name == "test"
325 |
326 | ProductDocumentCalss.update_from_related_model_instance(vendor, exclude=vendor)
327 |
328 | assert ProductDocumentCalss.get(pk=product.pk).vendor.name == "test"
329 |
330 |
331 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
332 | @pytest.mark.django_db
333 | def test_update_from_related_model_instance_with_no_related_model_config(
334 | document_class, product_with_tag
335 | ):
336 | ProductDocumentCalss = document_class(JsonDocument, Product, ["name"])
337 | product = Product.objects.create(
338 | name="Test",
339 | price=10.0,
340 | vendor=Vendor.objects.create(
341 | name="test", establishment_date=datetime.date.today()
342 | ),
343 | )
344 |
345 | tag = Tag.objects.create(name="test")
346 | product.tags.add(tag)
347 |
348 | with mock.patch(
349 | "redis_search_django.registry.DocumentRegistry.update_related_documents"
350 | ):
351 | tag.name = "test2"
352 | tag.save()
353 |
354 | assert not hasattr(ProductDocumentCalss.get(pk=product.pk), "tags")
355 |
356 | ProductDocumentCalss.update_from_related_model_instance(tag)
357 |
358 | assert not hasattr(ProductDocumentCalss.get(pk=product.pk), "tags")
359 |
360 |
361 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
362 | @pytest.mark.django_db
363 | def test_update_from_related_model_instance_with_exclude_optional_field(
364 | nested_document_class, product_with_tag
365 | ):
366 | ProductDocumentCalss = nested_document_class[0]
367 | product = Product.objects.create(
368 | name="Test",
369 | price=10.0,
370 | vendor=Vendor.objects.create(
371 | name="test", establishment_date=datetime.date.today()
372 | ),
373 | )
374 |
375 | tag = Tag.objects.create(name="test")
376 | product.tags.add(tag)
377 |
378 | assert ProductDocumentCalss.get(pk=product.pk).tags[0].name == "test"
379 |
380 | ProductDocumentCalss.update_from_related_model_instance(tag, exclude=tag)
381 |
382 | assert ProductDocumentCalss.get(pk=product.pk).tags == []
383 |
384 |
385 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
386 | @pytest.mark.django_db
387 | def test_data_from_model_instance(nested_document_class, product_with_tag):
388 | ProductDocumentCalss = nested_document_class[0]
389 | vendor = Vendor.objects.create(name="test", establishment_date="2022-08-18")
390 | category = Category.objects.create(name="test")
391 | product = Product.objects.create(
392 | name="Test", price=10.0, vendor=vendor, category=category
393 | )
394 |
395 | tag = Tag.objects.create(name="test")
396 | tag2 = Tag.objects.create(name="test2")
397 | product.tags.set([tag, tag2])
398 |
399 | data = ProductDocumentCalss.data_from_model_instance(product)
400 |
401 | assert data == {
402 | "pk": str(product.pk),
403 | "vendor": {
404 | "pk": str(vendor.pk),
405 | "name": vendor.name,
406 | "establishment_date": vendor.establishment_date,
407 | },
408 | "category": {"pk": str(category.pk), "name": category.name},
409 | "tags": [
410 | {"pk": str(tag.pk), "name": tag.name},
411 | {"pk": str(tag2.pk), "name": tag2.name},
412 | ],
413 | "name": product.name,
414 | "description": product.description,
415 | "price": product.price,
416 | "created_at": product.created_at,
417 | }
418 |
419 |
420 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
421 | @pytest.mark.django_db
422 | def test_data_from_model_instance_exclude_obj(nested_document_class, product_with_tag):
423 | ProductDocumentCalss = nested_document_class[0]
424 | vendor = Vendor.objects.create(name="test", establishment_date="2022-08-18")
425 | category = Category.objects.create(name="test")
426 | product = Product.objects.create(
427 | name="Test", price=10.0, vendor=vendor, category=category
428 | )
429 |
430 | tag = Tag.objects.create(name="test")
431 | tag2 = Tag.objects.create(name="test2")
432 | product.tags.set([tag, tag2])
433 |
434 | data = ProductDocumentCalss.data_from_model_instance(product, category)
435 | print(data)
436 |
437 | assert data == {
438 | "pk": str(product.pk),
439 | "vendor": {
440 | "pk": str(vendor.pk),
441 | "name": vendor.name,
442 | "establishment_date": vendor.establishment_date,
443 | },
444 | "tags": [
445 | {"pk": str(tag.pk), "name": tag.name},
446 | {"pk": str(tag2.pk), "name": tag2.name},
447 | ],
448 | "name": product.name,
449 | "description": product.description,
450 | "price": product.price,
451 | "created_at": product.created_at,
452 | }
453 |
454 |
455 | def test_build_aggregate_request_without_expressions(document_class):
456 | CategoryDocumentCalss = document_class(JsonDocument, Category, ["name"])
457 | request = CategoryDocumentCalss.build_aggregate_request()
458 |
459 | assert request._query == "*"
460 |
461 |
462 | def test_build_aggregate_request_with_expressions(document_class):
463 | CategoryDocumentCalss = document_class(JsonDocument, Category, ["name"])
464 | request = CategoryDocumentCalss.build_aggregate_request(
465 | CategoryDocumentCalss.name == "test"
466 | )
467 | assert request._query == "@name:{test}"
468 |
469 |
470 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
471 | @pytest.mark.django_db
472 | def test_aggregate(document_class):
473 | CategoryDocumentClass = document_class(JsonDocument, Category, ["name"])
474 |
475 | Migrator().run()
476 |
477 | Category.objects.create(name="test")
478 | Category.objects.create(name="test2")
479 |
480 | request = CategoryDocumentClass.build_aggregate_request()
481 |
482 | result = CategoryDocumentClass.aggregate(
483 | request.group_by(
484 | ["@pk"],
485 | reducers.count().alias("count"),
486 | )
487 | )
488 |
489 | assert len(result) == 2
490 | assert result[0]["count"] == "1"
491 |
492 |
493 | @pytest.mark.skipif(not is_redis_running(), reason="Redis is not running")
494 | @pytest.mark.django_db
495 | def test_find(document_class):
496 | CategoryDocumentClass = document_class(JsonDocument, Category, ["name"])
497 |
498 | Migrator().run()
499 |
500 | category_1 = Category.objects.create(name="shoes")
501 | Category.objects.create(name="toys")
502 |
503 | result = CategoryDocumentClass.find(CategoryDocumentClass.name % "shoes").execute()
504 |
505 | assert len(result) == 1
506 | assert result[0].pk == str(category_1.pk)
507 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # redis-search-django
2 |
3 | [](https://pypi.org/project/redis-search-django/)
4 | [](https://pypi.org/project/redis-search-django/)
5 | [](https://pypi.org/project/redis-search-django/)
6 | [](https://github.com/saadmk11/redis-search-django/blob/main/LICENSE)
7 |
8 | 
9 | 
10 | 
11 | 
12 | 
13 |
14 | # About
15 |
16 | A Django package that provides **auto indexing** and **searching** capabilities for Django model instances using **[RediSearch](https://redis.io/docs/stack/search/)**.
17 |
18 | # Features
19 |
20 | - Management Command to create, update and populate the RediSearch Index.
21 | - Auto Index on Model object Create, Update and Delete.
22 | - Auto Index on Related Model object Add, Update, Remove and Delete.
23 | - Easy to create Document classes (Uses Django Model Form Class like structure).
24 | - Index nested models (e.g: `OneToOneField`, `ForeignKey` and `ManyToManyField`).
25 | - Search documents using `redis-om`.
26 | - Search Result Pagination.
27 | - Search Result Sorting.
28 | - RediSearch Result to Django QuerySet.
29 | - Faceted Search.
30 |
31 | # Requirements
32 |
33 | - Python: 3.7, 3.8, 3.9, 3.10
34 | - Django: 3.2, 4.0, 4.1
35 | - redis-om: >= 0.0.27
36 |
37 | # Redis
38 |
39 | ## Downloading Redis
40 |
41 | The latest version of Redis is available from [Redis.io](https://redis.io/). You can also install Redis with your operating system's package manager.
42 |
43 | ## RediSearch and RedisJSON
44 |
45 | `redis-search-django` relies on the [RediSearch](https://redis.io/docs/stack/search/) and [RedisJSON](https://redis.io/docs/stack/json/) Redis modules to support rich queries and embedded models.
46 | You need these Redis modules to use `redis-search-django`.
47 |
48 | The easiest way to run these Redis modules during local development is to use the [redis-stack](https://hub.docker.com/r/redis/redis-stack) Docker image.
49 |
50 | ## Docker Compose
51 |
52 | There is a `docker-compose.yaml` file provided in the project's root directory.
53 | This file will run Redis with RedisJSON and RediSearch modules during development.
54 |
55 | Run the following command to start the Redis container:
56 |
57 | ```bash
58 | docker compose up -d
59 | ```
60 |
61 | # Example Project
62 |
63 | There is an example project available at [Example Project](https://github.com/saadmk11/redis-search-django/tree/main/example).
64 |
65 |
66 | # Documentation
67 |
68 | ## Installation
69 |
70 | ```bash
71 | pip install redis-search-django
72 | ```
73 |
74 | Then add `redis_search_django` to your `INSTALLED_APPS`:
75 |
76 | ```bash
77 | INSTALLED_APPS = [
78 | ...
79 | 'redis_search_django',
80 | ]
81 | ```
82 |
83 | ## Usage
84 |
85 | ### Document Types
86 |
87 | There are **3 types** of documents class available:
88 |
89 | - **JsonDocument:** This uses `RedisJSON` to store the document. If you want to use Embedded Documents (Required For `OneToOneField`, `ForeignKey` and `ManyToManyField`) then use `JsonDocument`.
90 | - **EmbeddedJsonDocument:** If the document will be embedded inside another document class then use this. Embedded Json Documents are used for `OneToOneField`, `ForeignKey` and `ManyToManyField` or any types of nested documents.
91 | - **HashDocument:** This uses `RedisHash` to store the documents. It can not be used for nested documents.
92 |
93 | ### Creating Document Classes
94 |
95 | You need to inherit from The Base Document Classes mentioned above to build a document class.
96 |
97 | #### Simple Example
98 |
99 | **1. For Django Model:**
100 |
101 | ```python
102 | # models.py
103 |
104 | from django.db import models
105 |
106 |
107 | class Category(models.Model):
108 | name = models.CharField(max_length=30)
109 | slug = models.SlugField(max_length=30)
110 |
111 | def __str__(self) -> str:
112 | return self.name
113 | ```
114 |
115 | **2. You can create a document class like this:**
116 |
117 | **Note:** Document classes must be stored in `documents.py` file.
118 |
119 | ```python
120 | # documents.py
121 |
122 | from redis_search_django.documents import JsonDocument
123 |
124 | from .models import Category
125 |
126 |
127 | class CategoryDocument(JsonDocument):
128 | class Django:
129 | model = Category
130 | fields = ["name", "slug"]
131 | ```
132 |
133 | **3. Run Index Django Management Command to create the index on Redis:**
134 |
135 | ```bash
136 | python manage.py index
137 | ```
138 |
139 | **Note:** This will also populate the index with existing data from the database
140 |
141 | Now category objects will be indexed on create/update/delete.
142 |
143 | #### More Complex Example
144 |
145 | **1. For Django Models:**
146 |
147 | ```python
148 | # models.py
149 |
150 | from django.db import models
151 |
152 |
153 | class Tag(models.Model):
154 | name = models.CharField(max_length=30)
155 |
156 | def __str__(self) -> str:
157 | return self.name
158 |
159 |
160 | class Vendor(models.Model):
161 | name = models.CharField(max_length=30)
162 | email = models.EmailField()
163 | establishment_date = models.DateField()
164 |
165 | def __str__(self) -> str:
166 | return self.name
167 |
168 |
169 | class Product(models.Model):
170 | name = models.CharField(max_length=256)
171 | description = models.TextField(blank=True)
172 | vendor = models.OneToOneField(Vendor, on_delete=models.CASCADE)
173 | tags = models.ManyToManyField(Tag, blank=True)
174 | price = models.DecimalField(max_digits=6, decimal_places=2)
175 |
176 | def __str__(self) -> str:
177 | return self.name
178 | ```
179 |
180 | **2. You can create a document classes like this:**
181 |
182 | **Note:** Document classes must be stored in `documents.py` file.
183 |
184 | ```python
185 | # documents.py
186 |
187 | from typing import List
188 |
189 | from django.db import models
190 | from redis_om import Field
191 |
192 | from redis_search_django.documents import EmbeddedJsonDocument, JsonDocument
193 |
194 | from .models import Product, Tag, Vendor
195 |
196 |
197 | class TagDocument(EmbeddedJsonDocument):
198 | custom_field: str = Field(index=True, full_text_search=True)
199 |
200 | class Django:
201 | model = Tag
202 | # Model Fields
203 | fields = ["name"]
204 |
205 | @classmethod
206 | def prepare_custom_field(cls, obj):
207 | return "CUSTOM FIELD VALUE"
208 |
209 |
210 | class VendorDocument(EmbeddedJsonDocument):
211 | class Django:
212 | model = Vendor
213 | # Model Fields
214 | fields = ["name", "establishment_date"]
215 |
216 |
217 | class ProductDocument(JsonDocument):
218 | # OnetoOneField, with null=False
219 | vendor: VendorDocument
220 | # ManyToManyField
221 | tags: List[TagDocument]
222 |
223 | class Django:
224 | model = Product
225 | # Model Fields
226 | fields = ["name", "description", "price"]
227 | # Related Model Options
228 | related_models = {
229 | Vendor: {
230 | "related_name": "product",
231 | "many": False,
232 | },
233 | Tag: {
234 | "related_name": "product_set",
235 | "many": True,
236 | },
237 | }
238 |
239 | @classmethod
240 | def get_queryset(cls) -> models.QuerySet:
241 | """Override Queryset to filter out available products."""
242 | return super().get_queryset().filter(available=True)
243 |
244 | @classmethod
245 | def prepare_name(cls, obj):
246 | """Use this to update field value."""
247 | return obj.name.upper()
248 | ```
249 |
250 | **Note:**
251 |
252 | - You can not inherit from `HashDocument` for documents that include nested fields.
253 | - You need to inherit from `EmbeddedJsonDocument` for document classes that will be embedded inside another document class.
254 | - You need to explicitly add `OneToOneField`, `ForeignKey` or `ManyToManyField` (e.g: `tags: List[TagDocument]`) with an embedded document class if you want to index them.
255 | you can not add it in the `Django.fields` option.
256 | - For `related_models` option, you need to specify the fields `related_name` and if it is a `ManyToManyField` or a `ForeignKey` Field then specify `"many": True`.
257 | - `related_models` will be used when a related object is saved that contributes to the document.
258 | - You can define `prepare_{field_name}` method to update the value of a field before indexing.
259 | - If it is a custom field (not a model field) you must define a `prepare_{field_name}` method that returns the value of the field.
260 | - You can override `get_queryset` method to provide more filtering. This will be used while indexing a queryset.
261 | - Field names must match model field names or define a `prepare_{field_name}` method.
262 |
263 |
264 | **3. Run Index Django Management Command to create the index on Redis:**
265 |
266 | ```bash
267 | python manage.py index
268 | ```
269 |
270 | **Note:** This will also populate the index with existing data from the database
271 |
272 |
273 | ### Management Command
274 |
275 | This package comes with `index` management command that can be used to index all the model instances to Redis index if it has a Document class defined.
276 |
277 | **Note:** Make sure that Redis is running before running the command.
278 |
279 | Run the following command to index **all** models that have Document classes defined:
280 |
281 | ```bash
282 | python manage.py index
283 | ```
284 |
285 | You can use `--migrate-only` option to **only** update the **index schema**.
286 |
287 | ```bash
288 | python manage.py index --migrate-only
289 | ```
290 |
291 | You can use `--models` to **specify** which models to index (models must have a Document class defined to be indexed).
292 |
293 | ```bash
294 | python manage.py index --models app_name.ModelName app_name2.ModelName2
295 | ```
296 |
297 | ### Views
298 |
299 | You can use the `redis_search_django.mixin.RediSearchListViewMixin` with a Django Generic View to search for documents.
300 | `RediSearchPaginator` which helps paginate `ReadiSearch` results is also added to this mixin.
301 |
302 | #### Example
303 |
304 | ```python
305 | # views.py
306 |
307 | from django.utils.functional import cached_property
308 | from django.views.generic import ListView
309 | from redis.commands.search import reducers
310 |
311 | from redis_search_django.mixins import RediSearchListViewMixin
312 |
313 | from .documents import ProductDocument
314 | from .models import Product
315 |
316 |
317 | class SearchView(RediSearchListViewMixin, ListView):
318 | paginate_by = 20
319 | model = Product
320 | template_name = "core/search.html"
321 | document_class = ProductDocument
322 |
323 | @cached_property
324 | def search_query_expression(self):
325 | query = self.request.GET.get("query")
326 | query_expression = None
327 |
328 | if query:
329 | query_expression = (
330 | self.document_class.name % query
331 | | self.document_class.description % query
332 | )
333 |
334 | return query_expression
335 |
336 | @cached_property
337 | def sort_by(self):
338 | return self.request.GET.get("sort")
339 |
340 | def facets(self):
341 | if self.search_query_expression:
342 | request = self.document_class.build_aggregate_request(
343 | self.search_query_expression
344 | )
345 | else:
346 | request = self.document_class.build_aggregate_request()
347 |
348 | result = self.document_class.aggregate(
349 | request.group_by(
350 | ["@tags_name"],
351 | reducers.count().alias("count"),
352 | )
353 | )
354 | return result
355 | ```
356 |
357 | ### Search
358 |
359 | This package uses `redis-om` to search for documents.
360 |
361 | #### Example
362 |
363 | ```python
364 | from .documents import ProductDocument
365 |
366 |
367 | categories = ["category1", "category2"]
368 | tags = ["tag1", "tag2"]
369 |
370 | # Search For Products That Match The Search Query (name or description)
371 | query_expression = (
372 | ProductDocument.name % "Some search query"
373 | | ProductDocument.description % "Some search query"
374 | )
375 |
376 | # Search For Products That Match The Price Range
377 | query_expression = (
378 | ProductDocument.price >= float(10) & ProductDocument.price <= float(100)
379 | )
380 |
381 | # Search for Products that include following Categories
382 | query_expression = ProductDocument.category.name << ["category1", "category2"]
383 |
384 | # Search for Products that include following Tags
385 | query_expression = ProductDocument.tags.name << ["tag1", "tag2"]
386 |
387 | # Query expression can be passed on the `find` method
388 | result = ProductDocument.find(query_expression).sort_by("-price").execute()
389 | ```
390 |
391 | For more details checkout [redis-om docs](https://github.com/redis/redis-om-python/blob/main/docs/getting_started.md)
392 |
393 | ### RediSearch Aggregation / Faceted Search
394 |
395 | `redis-om` does not support faceted search (RediSearch Aggregation). So this package uses `redis-py` to do faceted search.
396 |
397 | #### Example
398 |
399 | ```python
400 | from redis.commands.search import reducers
401 |
402 | from .documents import ProductDocument
403 |
404 |
405 | query_expression = (
406 | ProductDocument.name % "Some search query"
407 | | ProductDocument.description % "Some search query"
408 | )
409 |
410 | # First we need to build the aggregation request
411 | request1 = ProductDocument.build_aggregate_request(query_expression)
412 | request2 = ProductDocument.build_aggregate_request(query_expression)
413 |
414 | # Get the number of products for each category
415 | ProductDocument.aggregate(
416 | request1.group_by(
417 | ["@category_name"],
418 | reducers.count().alias("count"),
419 | )
420 | )
421 | # >> [{"category_name": "Shoes", "count": "112"}, {"category_name": "Cloths", "count": "200"}]
422 |
423 |
424 | # Get the number of products for each tag
425 | ProductDocument.aggregate(
426 | request2.group_by(
427 | ["@tags_name"],
428 | reducers.count().alias("count"),
429 | )
430 | )
431 | # >> [{"tags_name": "Blue", "count": "14"}, {"tags_name": "Small", "count": "57"}]
432 | ```
433 |
434 | For more details checkout [redis-py docs](https://redis.readthedocs.io/en/stable/examples/search_json_examples.html?highlight=aggregate#Aggregation) and
435 | [RediSearch Aggregation docs](https://redis.io/docs/stack/search/reference/aggregations/)
436 |
437 | ### Settings
438 |
439 | #### Environment Variables
440 |
441 | - **`REDIS_OM_URL`** (Default: `redis://localhost:6379`): This environment variable follows the `redis-py` URL format. If you are using external redis server
442 | You need to set this variable with the URL of the redis server following this pattern: `redis://[[username]:[password]]@[host]:[post]/[database number]`
443 |
444 | **Example:** `redis://redis_user:password@some.other.part.cloud.redislabs.com:6379/0`
445 |
446 | For more details checkout [redis-om docs](https://github.com/redis/redis-om-python/blob/main/docs/getting_started.md#setting-the-redis-url-environment-variable)
447 |
448 |
449 | #### Django Document Options
450 |
451 | You can add these options on the `Django` class of each Document class:
452 |
453 | ```python
454 | # documents.py
455 |
456 | from redis_search_django.documents import JsonDocument
457 |
458 | from .models import Category, Product, Tag, Vendor
459 |
460 |
461 | class ProductDocument(JsonDocument):
462 | class Django:
463 | model = Product
464 | fields = ["name", "description", "price", "created_at"]
465 | select_related_fields = ["vendor", "category"]
466 | prefetch_related_fields = ["tags"]
467 | auto_index = True
468 | related_models = {
469 | Vendor: {
470 | "related_name": "product",
471 | "many": False,
472 | },
473 | Category: {
474 | "related_name": "product_set",
475 | "many": True,
476 | },
477 | Tag: {
478 | "related_name": "product_set",
479 | "many": True,
480 | },
481 | }
482 | ```
483 |
484 | - **`model`** (Required): Django Model class to index.
485 | - **`auto_index`** (Default: `True`, Optional): If True, the model instances will be indexed on create/update/delete.
486 | - **`fields`** (Default: `[]`, Optional): List of model fields to index. (Do not add `OneToOneField`, `ForeignKey` or `ManyToManyField` here. These need to be explicitly added to the Document class using `EmbeddedJsonDocument`.)
487 | - **`select_related_fields`** (Default: `[]`, Optional): List of fields to use on `queryset.select_related()`.
488 | - **`prefetch_related_fields`** (Default: `[]`, Optional): List of fields to use on `queryset.prefetch_related()`.
489 | - **`related_models`** (Default: `{}`, Optional): Dictionary of related models.
490 | You need to specify the fields `related_name` and if it is a `ManyToManyField` or a `ForeignKey` Field then specify `"many": True`.
491 | These are used to update the document data if any of the related model instances are updated.
492 | `related_models` will be used when a related object is saved/added/removed/deleted that contributes to the document.
493 |
494 | For `redis-om` specific options checkout [redis-om docs](https://github.com/redis/redis-om-python/blob/main/docs/models.md)
495 |
496 | #### Global Options
497 |
498 | You can add these options to your Django `settings.py` File:
499 |
500 | - **`REDIS_SEARCH_AUTO_INDEX`** (Default: `True`): Enable or Disable Auto Index when model instance is created/updated/deleted for all document classes.
501 |
502 |
503 | # Example Application Screenshot
504 |
505 | 
506 |
507 |
508 | # License
509 |
510 | The code in this project is released under the [MIT License](LICENSE).
511 |
--------------------------------------------------------------------------------