├── .gitignore ├── README.md ├── blog ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_initial_data.py │ ├── 0003_post_search_vector.py │ ├── 0004_auto_20170503_2020.py │ └── __init__.py ├── models.py ├── templates │ └── blog │ │ └── post_list.html ├── tests.py ├── urls.py └── views.py ├── manage.py └── pgfulltext ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | .venv 89 | venv/ 90 | ENV/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | .idea 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Postgres Full-Text Searching with Django 2 | 3 | This is an example project demonstrating how to use Postgres Full-Text search features with Django. Please see [the article](http://blog.lotech.org/postgres-full-text-search-with-django.html) for details. 4 | 5 | ## Setup 6 | 7 | ```bash 8 | ./manage.py migrate 9 | ./manage.py createsuperuser 10 | ./manage.py runserver 11 | ``` 12 | 13 | -------------------------------------------------------------------------------- /blog/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nshafer/pgfulltext/43750e48d5b7870b804c6519b34121af511c8767/blog/__init__.py -------------------------------------------------------------------------------- /blog/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from blog.models import Author, Tag, Post 4 | 5 | 6 | @admin.register(Author) 7 | class AuthorAdmin(admin.ModelAdmin): 8 | pass 9 | 10 | 11 | @admin.register(Tag) 12 | class TagAdmin(admin.ModelAdmin): 13 | pass 14 | 15 | 16 | @admin.register(Post) 17 | class PostAdmin(admin.ModelAdmin): 18 | list_display = ('title', 'author') 19 | readonly_fields = ('search_vector',) 20 | 21 | -------------------------------------------------------------------------------- /blog/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BlogConfig(AppConfig): 5 | name = 'blog' 6 | -------------------------------------------------------------------------------- /blog/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2017-05-03 16:51 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Author', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('name', models.CharField(max_length=50)), 22 | ], 23 | ), 24 | migrations.CreateModel( 25 | name='Post', 26 | fields=[ 27 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ('title', models.CharField(max_length=50)), 29 | ('content', models.TextField()), 30 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.Author')), 31 | ], 32 | ), 33 | migrations.CreateModel( 34 | name='Tag', 35 | fields=[ 36 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 37 | ('name', models.CharField(max_length=20)), 38 | ], 39 | ), 40 | migrations.AddField( 41 | model_name='post', 42 | name='tags', 43 | field=models.ManyToManyField(to='blog.Tag'), 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /blog/migrations/0002_initial_data.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2017-05-03 17:56 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | def load_initial_data(apps, schema_editor): 9 | Author = apps.get_model('blog', 'Author') 10 | Tag = apps.get_model('blog', 'Tag') 11 | Post = apps.get_model('blog', 'Post') 12 | 13 | jim = Author.objects.create(name="Jim Blogwriter") 14 | nancy = Author.objects.create(name="Nancy Blogaday") 15 | 16 | databases = Tag.objects.create(name="Databases") 17 | programming = Tag.objects.create(name="Programming") 18 | python = Tag.objects.create(name="Python") 19 | postgres = Tag.objects.create(name="Postgres") 20 | django = Tag.objects.create(name="Django") 21 | 22 | django_post = Post.objects.create( 23 | title="Django, the western character", 24 | content="Django is a character who appears in a number of spaghetti western films.", 25 | author=jim 26 | ) 27 | django_post.tags.add(django) 28 | 29 | python_post = Post.objects.create( 30 | title="Python is a programming language", 31 | content="Python is a programming language created by Guido van Rossum and first released " 32 | "in 1991. Django is written in Python. Python can connect to databases.", 33 | author=nancy 34 | ) 35 | python_post.tags.add(django, programming, python) 36 | 37 | postgres_post = Post.objects.create( 38 | title="What is Postgres", 39 | content="PostgreSQL, commonly Postgres, is an open-source, object-relational database (ORDBMS).", 40 | author=nancy 41 | ) 42 | postgres_post.tags.add(databases, postgres) 43 | 44 | 45 | class Migration(migrations.Migration): 46 | 47 | dependencies = [ 48 | ('blog', '0001_initial'), 49 | ] 50 | 51 | operations = [ 52 | migrations.RunPython(load_initial_data, migrations.RunPython.noop) 53 | ] 54 | -------------------------------------------------------------------------------- /blog/migrations/0003_post_search_vector.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2017-05-03 19:41 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.postgres.search 6 | from django.db import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('blog', '0002_initial_data'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='post', 18 | name='search_vector', 19 | field=django.contrib.postgres.search.SearchVectorField(null=True), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /blog/migrations/0004_auto_20170503_2020.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2017-05-03 20:20 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.postgres.indexes 6 | from django.db import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('blog', '0003_post_search_vector'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddIndex( 17 | model_name='post', 18 | index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='blog_post_search__528e75_gin'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /blog/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nshafer/pgfulltext/43750e48d5b7870b804c6519b34121af511c8767/blog/migrations/__init__.py -------------------------------------------------------------------------------- /blog/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.aggregates import StringAgg 2 | from django.contrib.postgres.indexes import GinIndex 3 | from django.db import models 4 | from django.contrib.postgres.search import SearchVectorField, SearchVector 5 | from django.db.models.signals import post_save, m2m_changed 6 | from django.dispatch import receiver 7 | 8 | 9 | class Author(models.Model): 10 | name = models.CharField(max_length=50) 11 | 12 | def __str__(self): 13 | return self.name 14 | 15 | 16 | class Tag(models.Model): 17 | name = models.CharField(max_length=20) 18 | 19 | def __str__(self): 20 | return self.name 21 | 22 | 23 | class PostManager(models.Manager): 24 | def with_documents(self): 25 | vector = SearchVector('title', weight='A') + \ 26 | SearchVector('content', weight='C') + \ 27 | SearchVector('author__name', weight='B') + \ 28 | SearchVector(StringAgg('tags__name', delimiter=' '), weight='B') 29 | return self.get_queryset().annotate(document=vector) 30 | 31 | 32 | class Post(models.Model): 33 | title = models.CharField(max_length=50) 34 | content = models.TextField() 35 | author = models.ForeignKey(Author) 36 | tags = models.ManyToManyField(Tag) 37 | search_vector = SearchVectorField(null=True) 38 | 39 | objects = PostManager() 40 | 41 | class Meta: 42 | indexes = [ 43 | GinIndex(fields=['search_vector']) 44 | ] 45 | 46 | def save(self, *args, **kwargs): 47 | super().save(*args, **kwargs) 48 | if 'update_fields' not in kwargs or 'search_vector' not in kwargs['update_fields']: 49 | instance = self._meta.default_manager.with_documents().get(pk=self.pk) 50 | instance.search_vector = instance.document 51 | instance.save(update_fields=['search_vector']) 52 | 53 | def __str__(self): 54 | return self.title 55 | 56 | 57 | @receiver(post_save, sender=Author) 58 | def author_changed(sender, instance, **kwargs): 59 | print("author_changed", sender, instance) 60 | for post in instance.post_set.with_documents(): 61 | post.search_vector = post.document 62 | post.save(update_fields=['search_vector']) 63 | 64 | 65 | @receiver(m2m_changed, sender=Post.tags.through) 66 | def post_tags_changed(sender, instance, action, **kwargs): 67 | print("post_tags_changed", sender, instance, action) 68 | if action in ('post_add', 'post_remove', 'post_clear'): 69 | instance.save() 70 | -------------------------------------------------------------------------------- /blog/templates/blog/post_list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Posts 6 | 7 | 8 |

Posts

9 | 10 |
11 | Search: 12 | 13 | 14 |
15 | 16 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /blog/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /blog/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from blog import views 4 | 5 | 6 | urlpatterns = [ 7 | url(r'^$', views.PostListView.as_view(), name="posts"), 8 | ] 9 | -------------------------------------------------------------------------------- /blog/views.py: -------------------------------------------------------------------------------- 1 | from django.db.models import F 2 | from django.contrib.postgres.search import SearchQuery, SearchRank 3 | from django.shortcuts import render 4 | from django.views.generic import ListView 5 | 6 | from blog.models import Post 7 | 8 | 9 | class PostListView(ListView): 10 | model = Post 11 | 12 | def get_queryset(self): 13 | qs = super().get_queryset() 14 | 15 | q = self.request.GET.get('q') 16 | if q: 17 | query = SearchQuery(q) 18 | qs = qs.annotate(rank=SearchRank(F('search_vector'), query))\ 19 | .filter(search_vector=query) \ 20 | .order_by('-rank') 21 | 22 | return qs 23 | 24 | def get_context_data(self, **kwargs): 25 | return super().get_context_data( 26 | q=self.request.GET.get('q', "") 27 | ) 28 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pgfulltext.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /pgfulltext/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nshafer/pgfulltext/43750e48d5b7870b804c6519b34121af511c8767/pgfulltext/__init__.py -------------------------------------------------------------------------------- /pgfulltext/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for pgfulltext project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.10. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.10/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'f6sl4ij^#6zvafl+-i59hf8##8*da3jazed99zcnmk1)unf+j^' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'django_extensions', 41 | 'blog', 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'pgfulltext.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [], 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | 'django.contrib.auth.context_processors.auth', 66 | 'django.contrib.messages.context_processors.messages', 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = 'pgfulltext.wsgi.application' 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/1.10/ref/settings/#databases 77 | 78 | DATABASES = { 79 | 'default': { 80 | 'ENGINE': os.getenv('DB_ENGINE', "django.db.backends.postgresql"), 81 | 'HOST': os.getenv('DB_HOST', "localhost"), 82 | 'PORT': os.getenv('DB_PORT', ""), 83 | 'USER': os.getenv('DB_USER', "pgfulltext"), 84 | 'PASSWORD': os.getenv('DB_PASS', "pgfulltext"), 85 | 'NAME': os.getenv('DB_NAME', "pgfulltext"), 86 | } 87 | } 88 | 89 | 90 | # Password validation 91 | # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators 92 | 93 | AUTH_PASSWORD_VALIDATORS = [ 94 | { 95 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 96 | }, 97 | { 98 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 99 | }, 100 | { 101 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 102 | }, 103 | { 104 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 105 | }, 106 | ] 107 | 108 | 109 | # Internationalization 110 | # https://docs.djangoproject.com/en/1.10/topics/i18n/ 111 | 112 | LANGUAGE_CODE = 'en-us' 113 | 114 | TIME_ZONE = 'UTC' 115 | 116 | USE_I18N = True 117 | 118 | USE_L10N = True 119 | 120 | USE_TZ = True 121 | 122 | 123 | # Static files (CSS, JavaScript, Images) 124 | # https://docs.djangoproject.com/en/1.10/howto/static-files/ 125 | 126 | STATIC_URL = '/static/' 127 | -------------------------------------------------------------------------------- /pgfulltext/urls.py: -------------------------------------------------------------------------------- 1 | """pgfulltext URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.10/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import url, include 17 | from django.contrib import admin 18 | 19 | urlpatterns = [ 20 | url(r'^admin/', admin.site.urls), 21 | url(r'^', include('blog.urls')), 22 | ] 23 | -------------------------------------------------------------------------------- /pgfulltext/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for pgfulltext 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/1.10/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pgfulltext.settings") 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------