├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── README.md ├── demo ├── __init__.py ├── categories │ ├── __init__.py │ ├── ltree.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_category_ltree.py │ │ └── __init__.py │ ├── models.py │ └── sql │ │ ├── constraint.sql │ │ ├── index.sql │ │ └── triggers.sql ├── settings.py ├── urls.py └── wsgi.py ├── manage.py ├── pytest.ini ├── requirements.txt ├── setup.py └── tests └── test_ltree.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: ~/django-ltree-demo 5 | docker: 6 | - image: circleci/python:3.6 7 | environment: 8 | PGHOST: localhost 9 | PGUSER: postgres 10 | - image: circleci/postgres:9.6 11 | environment: 12 | POSTGRES_USER: ubuntu 13 | POSTGRES_DB: django_ltree_demo 14 | steps: 15 | - checkout 16 | # Download and cache dependencies 17 | - restore_cache: 18 | keys: 19 | - v1-dependencies-{{ checksum "requirements.txt" }} 20 | # fallback to using the latest cache if no exact match is found 21 | - v1-dependencies- 22 | 23 | - run: 24 | name: install dependencies 25 | command: | 26 | python3 -m venv venv 27 | . venv/bin/activate 28 | pip install -r requirements.txt 29 | 30 | - save_cache: 31 | paths: 32 | - ./venv 33 | key: v1-dependencies-{{ checksum "requirements.txt" }} 34 | 35 | # run tests! 36 | - run: 37 | name: run tests 38 | command: | 39 | . venv/bin/activate 40 | pytest -v 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | *.sw[po] 3 | *.egg* 4 | __pycache__ 5 | .coverage 6 | build 7 | dist 8 | .DS_Store 9 | *.swp 10 | *~ 11 | .tox 12 | .cache 13 | htmlcov 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 PeopleDoc 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # How to store trees with Django & PostgreSQL 2 | 3 | 4 | ## Rationale 5 | 6 | If you ever had the need to store hierarchical data (trees) with Django, you 7 | probably had to use a library like 8 | [django-mptt](https://github.com/django-mptt/django-mptt) or 9 | [django-treebeard](https://github.com/django-treebeard/django-treebeard). 10 | Those libraries work fine at a small scale, but here at PeopleDoc we have 11 | encountered a lot of issues when using them at a bigger scale (tables with 12 | hundreds of thousands of rows and quite a lot of writings). 13 | 14 | It turns out that storing trees in a database has been a solved problem since 15 | a long time, at least with PostgreSQL. The 16 | [ltree](https://www.postgresql.org/docs/9.6/static/ltree.html) extension 17 | provides a convenient data structure which is very fast on reads, and with 18 | almost no impact on writes. The algorithm used is very close to 19 | django-treebeard's materialized paths, but with all the power of PostgreSQL. 20 | 21 | The main downside of using ltree is that you have to maintain the materialized 22 | path yourself. It doesn't come with any tool to do it automatically. 23 | But fortunately, it's actually quite simple to maintain this path using 24 | PostgreSQL triggers! 25 | 26 | 27 | ## Integration with Django 28 | 29 | In [`demo/categories/ltree.py`](/demo/categories/ltree.py) you will find a very 30 | simple Django field for the ltree data type. This field can be used in any 31 | Django model, and adds two lookups: `descendant` and `ancestor`. Those lookups 32 | allow you to query the descendants or the ancestors of any object with a very 33 | simple SQL query. 34 | 35 | For example, let's say you have the following model: 36 | ```python 37 | from django.db import models 38 | 39 | from project.ltree import LtreeField 40 | 41 | 42 | class Category(models.Model): 43 | parent = models.ForeignKey('self', null=True) 44 | code = models.CharField(maxlength=32, unique=True) 45 | path = LtreeField() 46 | ``` 47 | 48 | The `path` field represents the path from the root to the node, where each 49 | node is represented by its code (it could also be its id, but using the code 50 | is more readable when debugging). For example, if you have a `genetic` 51 | category, under a `science` category, under a `top` category, its path would be 52 | `top.science.category`. 53 | 54 | Thanks to the `descendant` and `ancestor` lookups, the `get_descendants` 55 | method in django-mptt can be rewritten as: 56 | ```python 57 | def get_descendants(self): 58 | return Category.objects.filter(path__descendant=self.path) 59 | ``` 60 | 61 | This would generate a SQL query close to: 62 | ```sql 63 | SELECT * FROM category WHERE path <@ 'science.biology' 64 | ``` 65 | 66 | ## The magic part: PostgreSQL triggers 67 | 68 | If you add a ltree field to your model, you will have to keep the field 69 | up-to-date when inserting or updating instances. We could do that with Django 70 | signals, but it turns out that PostgreSQL is far better for maintaining 71 | integrity & writing efficient code. 72 | 73 | Every time we insert or update a row, we can reconstruct its path by appending 74 | its code to the path of its parent. If the path has changed, we'll also need to 75 | update the path of the children, which can be written as a simple `UPDATE` 76 | query. 77 | 78 | All that can be done easily with [PostgreSQL 79 | triggers](https://www.postgresql.org/docs/current/static/sql-createtrigger.html). 80 | You can find an implementation of those triggers in the file 81 | [`demo/categories/sql/triggers.sql`](/demo/categories/sql/triggers.sql). 82 | 83 | 84 | ## The demo 85 | 86 | In the demo, the following files are the most important: 87 | - [`demo/categories/models.py`](/demo/categories/models.py): The definition of 88 | a model using ltree 89 | - [`demo/categories/ltree.py`](/demo/categories/ltree.py): A very simple Django 90 | field for ltree. More lookups could be added (one for `~` for instance). 91 | - [`demo/categories/migrations/0002_category_ltree.py`](/demo/categories/migrations/0002_category_ltree.py): 92 | The Django migration for creating the ltree field with the SQL triggers. 93 | - [`demo/categories/sql/index.sql`](/demo/categories/sql/index.sql): The indexes recommended for the ltree 94 | field 95 | - [`demo/categories/sql/constraint.sql`](/demo/categories/sql/constraint.sql): A 96 | SQL constraint to make sure that we never have loops in our trees. 97 | - [`demo/categories/sql/triggers.sql`](/demo/categories/sql/triggers.sql): The 98 | implementation of the triggers for maintaining the integrity of the trees. 99 | - [`tests/test_ltree.py`](/tests/test_ltree.py): Some tests to be sure that 100 | everything works as expected. 101 | 102 | ### How to install the demo 103 | 104 | - Create & activate a virtualenv 105 | - Install the dependencies with `pip install -r requirements.txt` 106 | - Install PostgreSQL with your favorite way 107 | - Export the `PGHOST` and `PGUSER` variables accordingly 108 | - Create the `django_ltree_demo` table 109 | - Run `python manage.py migrate` 110 | - Launch the test with `pytest -v` 111 | 112 | ## Conclusion 113 | 114 | With a few lines a declarative, idiomatic Django code and ~50 lines of SQL we 115 | have implemented a fast and consistent solution for storing and querying trees. 116 | 117 | Sometimes it's good to delegate complicated data manipulation to the database 118 | instead of doing everything in Python :) . 119 | -------------------------------------------------------------------------------- /demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peopledoc/django-ltree-demo/741de07db550bc8db1e7086ed8cb3d3c443ffe7d/demo/__init__.py -------------------------------------------------------------------------------- /demo/categories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peopledoc/django-ltree-demo/741de07db550bc8db1e7086ed8cb3d3c443ffe7d/demo/categories/__init__.py -------------------------------------------------------------------------------- /demo/categories/ltree.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class LtreeField(models.TextField): 5 | description = 'ltree' 6 | 7 | def __init__(self, *args, **kwargs): 8 | kwargs['editable'] = False 9 | kwargs['null'] = True 10 | kwargs['default'] = None 11 | super(LtreeField, self).__init__(*args, **kwargs) 12 | 13 | def db_type(self, connection): 14 | return 'ltree' 15 | 16 | 17 | class Ancestor(models.Lookup): 18 | lookup_name = 'ancestor' 19 | 20 | def as_sql(self, qn, connection): 21 | lhs, lhs_params = self.process_lhs(qn, connection) 22 | rhs, rhs_params = self.process_rhs(qn, connection) 23 | params = lhs_params + rhs_params 24 | return '%s @> %s' % (lhs, rhs), params 25 | 26 | 27 | class Descendant(models.Lookup): 28 | lookup_name = 'descendant' 29 | 30 | def as_sql(self, qn, connection): 31 | lhs, lhs_params = self.process_lhs(qn, connection) 32 | rhs, rhs_params = self.process_rhs(qn, connection) 33 | params = lhs_params + rhs_params 34 | return '%s <@ %s' % (lhs, rhs), params 35 | 36 | 37 | LtreeField.register_lookup(Ancestor) 38 | LtreeField.register_lookup(Descendant) 39 | -------------------------------------------------------------------------------- /demo/categories/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.4 on 2017-09-04 16:48 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='Category', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('code', models.CharField(max_length=32, unique=True)), 22 | ('name', models.TextField()), 23 | ('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='categories.Category')), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /demo/categories/migrations/0002_category_ltree.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations 5 | from django.contrib.postgres.operations import CreateExtension 6 | 7 | import demo.categories.ltree 8 | 9 | 10 | def get_sql(filename): 11 | with open('demo/categories/sql/' + filename) as f: 12 | return f.read() 13 | 14 | 15 | class Migration(migrations.Migration): 16 | 17 | dependencies = [ 18 | ('categories', '0001_initial'), 19 | ] 20 | 21 | operations = [ 22 | # Add the 'ltree' extension to PostgreSQL. Only needed once. 23 | CreateExtension('ltree'), 24 | # Add the 'path' field to the Category model 25 | migrations.AddField( 26 | model_name='category', 27 | name='path', 28 | field=demo.categories.ltree.LtreeField( 29 | editable=False, null=True, default=None 30 | ), 31 | ), 32 | # Create some indexes 33 | migrations.RunSQL(get_sql('index.sql')), 34 | # Add a constraint for recursivity 35 | migrations.RunSQL(get_sql('constraint.sql')), 36 | # Add a PostgreSQL trigger to manage the path automatically 37 | migrations.RunSQL(get_sql('triggers.sql')), 38 | ] 39 | -------------------------------------------------------------------------------- /demo/categories/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peopledoc/django-ltree-demo/741de07db550bc8db1e7086ed8cb3d3c443ffe7d/demo/categories/migrations/__init__.py -------------------------------------------------------------------------------- /demo/categories/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from .ltree import LtreeField 4 | 5 | 6 | class Category(models.Model): 7 | parent = models.ForeignKey('self', null=True, related_name='children', 8 | on_delete=models.CASCADE) 9 | code = models.CharField(max_length=32, unique=True) 10 | name = models.TextField() 11 | path = LtreeField() 12 | -------------------------------------------------------------------------------- /demo/categories/sql/constraint.sql: -------------------------------------------------------------------------------- 1 | -- make sure we cannot have a path where one of the ancestor is the row itself 2 | -- (this would cause an infinite recursion) 3 | ALTER TABLE categories_category 4 | ADD CONSTRAINT check_no_recursion 5 | CHECK(index(path, code::text::ltree) = (nlevel(path) - 1)); 6 | -------------------------------------------------------------------------------- /demo/categories/sql/index.sql: -------------------------------------------------------------------------------- 1 | -- used when we access the path directly 2 | CREATE INDEX categories_category_path 3 | ON categories_category 4 | USING btree(path); 5 | 6 | -- used when we get descendants or ancestors 7 | CREATE INDEX categories_category_path_gist 8 | ON categories_category 9 | USING GIST(path); 10 | -------------------------------------------------------------------------------- /demo/categories/sql/triggers.sql: -------------------------------------------------------------------------------- 1 | -- function to calculate the path of any given category 2 | CREATE OR REPLACE FUNCTION _update_category_path() RETURNS TRIGGER AS 3 | $$ 4 | BEGIN 5 | IF NEW.parent_id IS NULL THEN 6 | NEW.path = NEW.code::ltree; 7 | ELSE 8 | SELECT path || NEW.code 9 | FROM categories_category 10 | WHERE NEW.parent_id IS NULL or id = NEW.parent_id 11 | INTO NEW.path; 12 | END IF; 13 | RETURN NEW; 14 | END; 15 | $$ LANGUAGE plpgsql; 16 | 17 | 18 | -- function to update the path of the descendants of a category 19 | CREATE OR REPLACE FUNCTION _update_descendants_category_path() RETURNS TRIGGER AS 20 | $$ 21 | BEGIN 22 | UPDATE categories_category 23 | SET path = NEW.path || subpath(categories_category.path, nlevel(OLD.path)) 24 | WHERE categories_category.path <@ OLD.path AND id != NEW.id; 25 | RETURN NEW; 26 | END; 27 | $$ LANGUAGE plpgsql; 28 | 29 | 30 | -- calculate the path every time we insert a new category 31 | DROP TRIGGER IF EXISTS category_path_insert_trg ON categories_category; 32 | CREATE TRIGGER category_path_insert_trg 33 | BEFORE INSERT ON categories_category 34 | FOR EACH ROW 35 | EXECUTE PROCEDURE _update_category_path(); 36 | 37 | 38 | -- calculate the path when updating the parent or the code 39 | DROP TRIGGER IF EXISTS category_path_update_trg ON categories_category; 40 | CREATE TRIGGER category_path_update_trg 41 | BEFORE UPDATE ON categories_category 42 | FOR EACH ROW 43 | WHEN (OLD.parent_id IS DISTINCT FROM NEW.parent_id 44 | OR OLD.code IS DISTINCT FROM NEW.code) 45 | EXECUTE PROCEDURE _update_category_path(); 46 | 47 | 48 | -- if the path was updated, update the path of the descendants 49 | DROP TRIGGER IF EXISTS category_path_after_trg ON categories_category; 50 | CREATE TRIGGER category_path_after_trg 51 | AFTER UPDATE ON categories_category 52 | FOR EACH ROW 53 | WHEN (NEW.path IS DISTINCT FROM OLD.path) 54 | EXECUTE PROCEDURE _update_descendants_category_path(); 55 | -------------------------------------------------------------------------------- /demo/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for demo project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/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.11/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'vw9t*y1*=l&lydth7kr2xc^j-ifs6*4atak63z0nt7pp%-v2os' 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 | 'demo.categories', 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | 'django.middleware.security.SecurityMiddleware', 45 | 'django.contrib.sessions.middleware.SessionMiddleware', 46 | 'django.middleware.common.CommonMiddleware', 47 | 'django.middleware.csrf.CsrfViewMiddleware', 48 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 49 | 'django.contrib.messages.middleware.MessageMiddleware', 50 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 51 | ] 52 | 53 | ROOT_URLCONF = 'demo.urls' 54 | 55 | TEMPLATES = [ 56 | { 57 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 58 | 'DIRS': [], 59 | 'APP_DIRS': True, 60 | 'OPTIONS': { 61 | 'context_processors': [ 62 | 'django.template.context_processors.debug', 63 | 'django.template.context_processors.request', 64 | 'django.contrib.auth.context_processors.auth', 65 | 'django.contrib.messages.context_processors.messages', 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = 'demo.wsgi.application' 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/1.11/ref/settings/#databases 76 | 77 | DATABASES = { 78 | 'default': { 79 | 'ENGINE': 'django.db.backends.postgresql', 80 | 'NAME': 'django_ltree_demo', 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 91 | }, 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 106 | 107 | LANGUAGE_CODE = 'en-us' 108 | 109 | TIME_ZONE = 'UTC' 110 | 111 | USE_I18N = True 112 | 113 | USE_L10N = True 114 | 115 | USE_TZ = True 116 | 117 | 118 | # Static files (CSS, JavaScript, Images) 119 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 120 | 121 | STATIC_URL = '/static/' 122 | -------------------------------------------------------------------------------- /demo/urls.py: -------------------------------------------------------------------------------- 1 | """demo URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.11/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 17 | from django.contrib import admin 18 | 19 | urlpatterns = [ 20 | url(r'^admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /demo/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for demo 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.11/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", "demo.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /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", "demo.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 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = demo.settings 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django<2.0 2 | psycopg2 3 | -e . 4 | 5 | # for the tests 6 | pytest 7 | pytest-django 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | 5 | DESCRIPTION = "A demo for manipulating trees in Django using PostgreSQL" 6 | LICENSE = u'MIT' 7 | NAME = 'django-ltree-demo' 8 | PACKAGES = ['demo'] 9 | URL = 'https://github.com/peopledoc/django-ltree-demo' 10 | VERSION = 1.0 11 | 12 | 13 | if __name__ == '__main__': 14 | setup( 15 | description=DESCRIPTION, 16 | include_package_data=True, 17 | license=LICENSE, 18 | name=NAME, 19 | packages=PACKAGES, 20 | url=URL, 21 | version=VERSION, 22 | ) 23 | -------------------------------------------------------------------------------- /tests/test_ltree.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.db import IntegrityError 4 | 5 | from demo.categories.models import Category 6 | 7 | 8 | pytestmark = pytest.mark.django_db 9 | 10 | 11 | def test_create_category(): 12 | category = Category.objects.create(name='Foo', code='bar') 13 | # we need to do a full refresh to get the value of the path 14 | category.refresh_from_db() 15 | 16 | assert category.id > 0 17 | assert category.name == 'Foo' 18 | assert category.code == 'bar' 19 | assert category.path == 'bar' 20 | 21 | 22 | def test_direct_children(): 23 | top = Category.objects.create(code='top') 24 | science = Category.objects.create(code='science', parent=top) 25 | sport = Category.objects.create(code='sport', parent=top) 26 | news = Category.objects.create(code='news', parent=top) 27 | Category.objects.create(code='politics', parent=news) 28 | 29 | # we can acess direct children using the `children` property 30 | assert list(top.children.order_by('code')) == [news, science, sport] 31 | 32 | 33 | def test_descendants(): 34 | top = Category.objects.create(code='top') 35 | top.refresh_from_db() 36 | 37 | science = Category.objects.create(code='science', parent=top) 38 | Category.objects.create(code='maths', parent=science) 39 | biology = Category.objects.create(code='biology', parent=science) 40 | Category.objects.create(code='genetics', parent=biology) 41 | Category.objects.create(code='neuroscience', parent=biology) 42 | 43 | sport = Category.objects.create(code='sport', parent=top) 44 | Category.objects.create(code='rugby', parent=sport) 45 | football = Category.objects.create(code='football', parent=sport) 46 | Category.objects.create(code='champions_league', parent=football) 47 | Category.objects.create(code='world_cup', parent=football) 48 | 49 | # we can get all the ancestors of a category (including itself) 50 | assert list( 51 | Category.objects 52 | .filter(path__descendant=top.path) 53 | .values_list('path', flat=True) 54 | .order_by('path') 55 | ) == [ 56 | 'top', 57 | 'top.science', 58 | 'top.science.biology', 59 | 'top.science.biology.genetics', 60 | 'top.science.biology.neuroscience', 61 | 'top.science.maths', 62 | 'top.sport', 63 | 'top.sport.football', 64 | 'top.sport.football.champions_league', 65 | 'top.sport.football.world_cup', 66 | 'top.sport.rugby', 67 | ] 68 | 69 | 70 | def test_ancestors(): 71 | top = Category.objects.create(code='top') 72 | top.refresh_from_db() 73 | 74 | Category.objects.create(code='sport', parent=top) 75 | science = Category.objects.create(code='science', parent=top) 76 | Category.objects.create(code='maths', parent=science) 77 | biology = Category.objects.create(code='biology', parent=science) 78 | Category.objects.create(code='genetics', parent=biology) 79 | neuroscience = Category.objects.create(code='neuroscience', parent=biology) 80 | neuroscience.refresh_from_db() 81 | 82 | # we can get all the ancestors of a category (including itself) 83 | assert list( 84 | Category.objects 85 | .filter(path__ancestor=neuroscience.path) 86 | .values_list('path', flat=True) 87 | .order_by('path') 88 | ) == [ 89 | 'top', 90 | 'top.science', 91 | 'top.science.biology', 92 | 'top.science.biology.neuroscience', 93 | ] 94 | 95 | 96 | def test_update_code(): 97 | top = Category.objects.create(code='top') 98 | top.refresh_from_db() 99 | 100 | Category.objects.create(code='sport', parent=top) 101 | science = Category.objects.create(code='science', parent=top) 102 | biology = Category.objects.create(code='biology', parent=science) 103 | Category.objects.create(code='genetics', parent=biology) 104 | Category.objects.create(code='neuroscience', parent=biology) 105 | 106 | # update the code of a category, it should update its path as well as 107 | # the path of all of its descendants 108 | science.code = 'magic' 109 | science.save() 110 | 111 | assert list( 112 | Category.objects 113 | .filter(path__descendant=top.path) 114 | .values_list('path', flat=True) 115 | .order_by('path') 116 | ) == [ 117 | 'top', 118 | 'top.magic', 119 | 'top.magic.biology', 120 | 'top.magic.biology.genetics', 121 | 'top.magic.biology.neuroscience', 122 | 'top.sport', 123 | ] 124 | 125 | 126 | def test_update_parent(): 127 | top = Category.objects.create(code='top') 128 | top.refresh_from_db() 129 | 130 | Category.objects.create(code='sport', parent=top) 131 | science = Category.objects.create(code='science', parent=top) 132 | biology = Category.objects.create(code='biology', parent=science) 133 | Category.objects.create(code='genetics', parent=biology) 134 | Category.objects.create(code='neuroscience', parent=biology) 135 | 136 | # update the parent of a category, it should update its path as well as 137 | # the path of all of its descendants 138 | biology.parent = top 139 | biology.save() 140 | 141 | assert list( 142 | Category.objects 143 | .filter(path__descendant=top.path) 144 | .values_list('path', flat=True) 145 | .order_by('path') 146 | ) == [ 147 | 'top', 148 | 'top.biology', 149 | 'top.biology.genetics', 150 | 'top.biology.neuroscience', 151 | 'top.science', 152 | 'top.sport', 153 | ] 154 | 155 | 156 | def test_simple_recursion(): 157 | foo = Category.objects.create(code='foo') 158 | 159 | # we cannot be our own parent... 160 | foo.parent = foo 161 | with pytest.raises(IntegrityError): 162 | foo.save() 163 | 164 | 165 | def test_nested_recursion(): 166 | foo = Category.objects.create(code='foo') 167 | bar = Category.objects.create(code='bar', parent=foo) 168 | baz = Category.objects.create(code='baz', parent=bar) 169 | 170 | # we cannot be the descendant of one of our parent 171 | foo.parent = baz 172 | with pytest.raises(IntegrityError): 173 | foo.save() 174 | --------------------------------------------------------------------------------