├── .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 |