├── .dockerignore ├── .github └── workflows │ ├── main.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTORS ├── LICENSE ├── Makefile ├── README.md ├── django_ltree ├── __init__.py ├── apps.py ├── checks.py ├── fields.py ├── functions.py ├── lookups.py ├── managers.py ├── migrations │ ├── 0001_create_extension.py │ └── __init__.py ├── models.py ├── operations.py └── paths.py ├── docker-compose.yml ├── pyproject.toml ├── ruff.toml ├── setup.cfg └── tests ├── conftest.py ├── taxonomy ├── __init__.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py └── models.py ├── test_model.py ├── test_path_field.py ├── test_path_value.py └── test_register.py /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !python-versions.txt 3 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.11" 17 | 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip build twine 21 | 22 | - name: Build package 23 | run: python -m build 24 | 25 | - name: Publish package 26 | uses: pypa/gh-action-pypi-publish@release/v1 27 | with: 28 | user: __token__ 29 | password: ${{ secrets.PYPI_API_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | name: Python ${{ matrix.python-version }} sample 9 | strategy: 10 | matrix: 11 | python-version: ["3.10", "3.11", "3.12", "3.13"] 12 | continue-on-error: false 13 | services: 14 | database: 15 | image: postgres:14 16 | env: 17 | POSTGRES_USER: postgres 18 | POSTGRES_PASSWORD: postgres 19 | POSTGRES_DB: postgres 20 | ports: 21 | - 5432:5432 22 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 23 | env: 24 | DJANGO_DATABASE_HOST: localhost 25 | DJANGO_DATABASE_USER: postgres 26 | DJANGO_DATABASE_PASSWORD: postgres 27 | DJANGO_DATABASE_NAME: postgres 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: Set up Python 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | 37 | - name: Dependencies 38 | run: | 39 | python -m pip install pytest django pytest-django pytest-cov psycopg2-binary 40 | pip install -e . 41 | 42 | - name: Run tests 43 | run: >- 44 | pytest -v -x 45 | --cov=django_ltree 46 | --junitxml=junit/test-results-${{ matrix.python-version }}.xml 47 | tests/ 48 | 49 | - name: Upload pytest test results 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: pytest-results-${{ matrix.python-version }} 53 | path: junit/test-results-${{ matrix.python-version }}.xml 54 | if: ${{ always() }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | sdist/ 12 | *.egg-info/ 13 | .installed.cfg 14 | MANIFEST 15 | 16 | # Installer logs 17 | pip-log.txt 18 | pip-delete-this-directory.txt 19 | 20 | # Unit test / coverage reports 21 | htmlcov/ 22 | .tox/ 23 | .coverage 24 | .coverage.* 25 | .cache 26 | nosetests.xml 27 | coverage.xml 28 | *.cover 29 | .pytest_cache/ 30 | 31 | # pyenv 32 | .venv/ 33 | .python-version 34 | 35 | # Environments 36 | .envrc 37 | .env 38 | 39 | # IDEs and Code Editors 40 | .vscode 41 | .idea 42 | 43 | # mypy 44 | .mypy_cache/ 45 | 46 | # OS Stuff 47 | .DS_Store 48 | *.swp 49 | *~ 50 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.4.2 4 | hooks: 5 | - id: ruff-format 6 | - id: ruff 7 | args: [--fix, --exit-non-zero-on-fix] 8 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | "M. César Señoranis" (github.com/mariocesar) 2 | "Borys Szefczyk" (github.com/boryszef) 3 | "Thomas Stephenson" (github.com/ovangle) 4 | "Manuel Raynaud" (github.com/lunika) 5 | "Baseplatisphere" (github.com/baseplate-admin) 6 | "KimSia Sim" (github.com/simkimsia) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mario César 4 | Copyright (c) 2019 Borys Szefczyk 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | default: 3 | pip install -e .[develop] 4 | 5 | build: clean 6 | python -m build -s -w 7 | 8 | publish: build 9 | twine upload dist/* 10 | 11 | clean: 12 | rm -rf dist/ 13 | rm -rf build/ 14 | 15 | backend: 16 | docker-compose run --rm --service-ports backend bash 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-ltree 2 | 3 | A Django implementation for PostgreSQL's ltree extension, providing efficient storage and querying of hierarchical tree-like data. 4 | 5 | See PostgreSQL's [ltree](https://www.postgresql.org/docs/current/ltree.html) documentation to learn 6 | more about it. 7 | 8 | The main benefits of `ltree`: 9 | 10 | - Efficient path queries (ancestors, descendants, pattern matching) 11 | - Index-friendly hierarchical storage 12 | - Powerful label path searching 13 | - Native PostgreSQL performance for tree operations 14 | 15 | [![Test](https://github.com/mariocesar/django-ltree/actions/workflows/test.yml/badge.svg)](https://github.com/mariocesar/django-ltree/actions/workflows/test.yml) 16 | [![PyPI Version](https://img.shields.io/pypi/v/django-ltree.svg)](https://pypi.org/project/django-ltree/) 17 | 18 | ## Features 19 | 20 | - Django model fields for ltree data types 21 | - Query utilities for common tree operations 22 | - Migration support for ltree extension installation 23 | - Compatibility with Django's ORM and query syntax 24 | 25 | ## Requirements 26 | 27 | - Django 4.2+ 28 | - Python 3.11+ 29 | - PostgreSQL 14+ (with ltree extension enabled) 30 | 31 | ## Installation 32 | 33 | 1. Install the package: 34 | 35 | ```bash 36 | pip install django-ltree 37 | ``` 38 | 39 | 2. Add to your `INSTALLED_APPS`: 40 | 41 | ```python 42 | INSTALLED_APPS = [ 43 | ... 44 | "django_ltree", 45 | ... 46 | ] 47 | ``` 48 | 49 | 3. Run migrations to install the ltree extension: 50 | 51 | ```bash 52 | python manage.py migrate django_ltree 53 | ``` 54 | 55 | 4. Alternatively you can avoid install the application, and create the the extensions with a custom migration in an app in your project. 56 | 57 | ```python 58 | from django.db import migrations 59 | from django_ltree.operations import LtreeExtension 60 | 61 | class Migration(migrations.Migration): 62 | initial = True 63 | dependencies = [] 64 | 65 | operations = [LtreeExtension()] 66 | ``` 67 | 68 | ## Quick Start 69 | 70 | 1. Add a PathField to your model: 71 | ```python 72 | from django_ltree.fields import PathField 73 | 74 | class Category(models.Model): 75 | name = models.CharField(max_length=50) 76 | path = PathField() 77 | ``` 78 | 79 | 2. Create tree nodes: 80 | ```python 81 | root = Category.objects.create(name="Root", path="root") 82 | child = Category.objects.create(name="Child", path=f"{root.path}.child") 83 | ``` 84 | 85 | 3. Query ancestors and descendants: 86 | ```python 87 | # Get all ancestors 88 | Category.objects.filter(path__ancestor=child.path) 89 | 90 | # Get all descendants 91 | Category.objects.filter(path__descendant=root.path) 92 | ``` 93 | 94 | ## Migration Dependency 95 | 96 | Include django_ltree as a dependency in your app's migrations: 97 | 98 | ```python 99 | class Migration(migrations.Migration): 100 | dependencies = [ 101 | ("django_ltree", "__latest__"), 102 | ] 103 | ``` 104 | 105 | ## Documentation 106 | 107 | For complete documentation, see [TODO: Add Documentation Link]. 108 | 109 | ## Links 110 | 111 | - **Source Code**: https://github.com/mariocesar/django-ltree 112 | - **Bug Reports**: https://github.com/mariocesar/django-ltree/issues 113 | - **PyPI Package**: https://pypi.org/project/django-ltree/ 114 | - **PostgreSQL ltree Docs**: https://www.postgresql.org/docs/current/ltree.html 115 | 116 | ## Contributing 117 | 118 | Contributions are welcome! Please see [CONTRIBUTING.md](https://github.com/mariocesar/django-ltree/blob/main/CONTRIBUTING.md) for guidelines. 119 | 120 | ## License 121 | 122 | [MIT License](https://github.com/mariocesar/django-ltree/blob/main/LICENSE) 123 | -------------------------------------------------------------------------------- /django_ltree/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "django_ltree.apps.DjangoLtreeConfig" 5 | -------------------------------------------------------------------------------- /django_ltree/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoLtreeConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "django_ltree" 7 | 8 | def ready(self): 9 | from . import checks as checks 10 | from . import functions as functions 11 | from . import lookups as lookups 12 | -------------------------------------------------------------------------------- /django_ltree/checks.py: -------------------------------------------------------------------------------- 1 | from django.core.checks import Warning, register 2 | 3 | 4 | @register 5 | def check_database_backend_is_postgres(app_configs, **kwargs): 6 | from django.conf import settings 7 | 8 | errors = [] 9 | valid_dbs = ["postgres", "postgis"] 10 | 11 | if "default" in settings.DATABASES and all( 12 | d not in settings.DATABASES["default"]["ENGINE"] for d in valid_dbs 13 | ): 14 | errors.append( 15 | Warning( 16 | ( 17 | "The 'django_ltree' package requires a PostgreSQL-compatible database engine " 18 | "to enable the 'ltree' extension." 19 | ), 20 | hint=( 21 | "Ensure your DATABASES setting uses 'django.db.backends.postgresql' or a " 22 | "compatible engine. If using a custom backend for PostgreSQL, you may safely " 23 | "ignore this warning." 24 | ), 25 | id="django_ltree.W001", 26 | ) 27 | ) 28 | 29 | return errors 30 | -------------------------------------------------------------------------------- /django_ltree/fields.py: -------------------------------------------------------------------------------- 1 | from collections import UserList 2 | from collections.abc import Iterable 3 | 4 | from django import forms 5 | from django.core.validators import RegexValidator 6 | from django.db.models.fields import TextField 7 | from django.forms.widgets import TextInput 8 | 9 | path_label_validator = RegexValidator( 10 | r"^(?P[a-zA-Z0-9_-]+)(?:\.[a-zA-Z0-9_-]+)*$", 11 | "A label is a sequence of alphanumeric characters and underscores separated by dots.", 12 | "invalid", 13 | ) 14 | 15 | 16 | class PathValue(UserList): 17 | def __init__(self, value): 18 | if isinstance(value, str): 19 | split_by = "/" if "/" in value else "." 20 | value = value.strip().split(split_by) if value else [] 21 | elif isinstance(value, int): 22 | value = [str(value)] 23 | elif isinstance(value, Iterable): 24 | value = [str(v) for v in value] 25 | else: 26 | raise ValueError("Invalid value: {!r} for path".format(value)) 27 | 28 | super().__init__(initlist=value) 29 | 30 | def __repr__(self): 31 | return str(self) 32 | 33 | def __str__(self): 34 | return ".".join(self) 35 | 36 | 37 | class PathValueProxy: 38 | def __init__(self, field_name): 39 | self.field_name = field_name 40 | 41 | def __get__(self, instance, owner): 42 | if instance is None: 43 | return self 44 | 45 | value = instance.__dict__[self.field_name] 46 | 47 | if value is None: 48 | return value 49 | 50 | return PathValue(instance.__dict__[self.field_name]) 51 | 52 | def __set__(self, instance, value): 53 | if instance is None: 54 | return self 55 | 56 | instance.__dict__[self.field_name] = value 57 | 58 | 59 | class PathFormField(forms.CharField): 60 | default_validators = [path_label_validator] 61 | 62 | 63 | class PathField(TextField): 64 | default_validators = [path_label_validator] 65 | 66 | def db_type(self, connection): 67 | return "ltree" 68 | 69 | def formfield(self, **kwargs): 70 | kwargs["form_class"] = PathFormField 71 | kwargs["widget"] = TextInput(attrs={"class": "vTextField"}) 72 | return super().formfield(**kwargs) 73 | 74 | def contribute_to_class(self, cls, name, private_only=False): 75 | super().contribute_to_class(cls, name) 76 | setattr(cls, self.name, PathValueProxy(self.name)) 77 | 78 | def from_db_value(self, value, expression, connection, *args): 79 | if value is None: 80 | return value 81 | return PathValue(value) 82 | 83 | def get_prep_value(self, value): 84 | if value is None: 85 | return value 86 | return str(PathValue(value)) 87 | 88 | def to_python(self, value): 89 | if value is None: 90 | return value 91 | elif isinstance(value, PathValue): 92 | return value 93 | 94 | return PathValue(value) 95 | 96 | def get_db_prep_value(self, value, connection, prepared=False): 97 | if value is None: 98 | return value 99 | elif isinstance(value, PathValue): 100 | return str(value) 101 | elif isinstance(value, (list, str)): 102 | return str(PathValue(value)) 103 | 104 | raise ValueError("Unknown value type {}".format(type(value))) 105 | -------------------------------------------------------------------------------- /django_ltree/functions.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Transform, fields 2 | 3 | from .fields import PathField 4 | 5 | __all__ = ("NLevel",) 6 | 7 | 8 | @PathField.register_lookup 9 | class NLevel(Transform): 10 | lookup_name = "depth" 11 | function = "nlevel" 12 | 13 | @property 14 | def output_field(self): 15 | return fields.IntegerField() 16 | -------------------------------------------------------------------------------- /django_ltree/lookups.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Lookup 2 | 3 | from .fields import PathField 4 | 5 | 6 | class SimpleLookup(Lookup): 7 | lookup_operator = "=" # type: str 8 | 9 | def as_sql(self, compiler, connection): 10 | lhs, lhs_params = self.process_lhs(compiler, connection) 11 | rhs, rhs_params = self.process_rhs(compiler, connection) 12 | return "{} {} {}".format(lhs, self.lookup_operator, rhs), [*lhs_params, *rhs_params] 13 | 14 | 15 | @PathField.register_lookup 16 | class EqualLookup(Lookup): 17 | lookup_name = "exact" 18 | 19 | def as_sql(self, compiler, connection): 20 | lhs, lhs_params = self.process_lhs(compiler, connection) 21 | rhs, rhs_params = self.process_rhs(compiler, connection) 22 | return "{} = {}".format(lhs, rhs), [*lhs_params, *rhs_params] 23 | 24 | 25 | @PathField.register_lookup 26 | class AncestorLookup(SimpleLookup): 27 | lookup_name = "ancestors" 28 | lookup_operator = "@>" 29 | 30 | 31 | @PathField.register_lookup 32 | class DescendantLookup(SimpleLookup): 33 | lookup_name = "descendants" 34 | lookup_operator = "<@" 35 | 36 | 37 | @PathField.register_lookup 38 | class MatchLookup(SimpleLookup): 39 | lookup_name = "match" 40 | lookup_operator = "~" 41 | 42 | 43 | @PathField.register_lookup 44 | class ContainsLookup(SimpleLookup): 45 | lookup_name = "contains" 46 | lookup_operator = "?" 47 | -------------------------------------------------------------------------------- /django_ltree/managers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_ltree.paths import PathGenerator 4 | 5 | 6 | class TreeQuerySet(models.QuerySet): 7 | def roots(self): 8 | return self.filter(path__depth=1) 9 | 10 | def children(self, path): 11 | return self.filter(path__descendants=path, path__depth=len(path) + 1) 12 | 13 | 14 | class TreeManager(models.Manager): 15 | def get_queryset(self): 16 | return TreeQuerySet(model=self.model, using=self._db).order_by("path") 17 | 18 | def roots(self): 19 | return self.filter().roots() 20 | 21 | def children(self, path): 22 | return self.filter().children(path) 23 | 24 | def create_child(self, parent=None, **kwargs): 25 | paths_in_use = parent.children() if parent else self.roots() 26 | prefix = parent.path if parent else None 27 | path_generator = PathGenerator( 28 | prefix, 29 | skip=paths_in_use.values_list("path", flat=True), 30 | label_size=getattr(self.model, "label_size"), 31 | ) 32 | kwargs["path"] = next(path_generator) 33 | return self.create(**kwargs) 34 | -------------------------------------------------------------------------------- /django_ltree/migrations/0001_create_extension.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from django_ltree.operations import LtreeExtension 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [LtreeExtension()] 12 | -------------------------------------------------------------------------------- /django_ltree/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mariocesar/django-ltree/c776e3ba76f1a833c34cbc290978512cea353fd8/django_ltree/migrations/__init__.py -------------------------------------------------------------------------------- /django_ltree/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from .fields import PathField, PathValue 4 | from .managers import TreeManager 5 | 6 | 7 | class TreeModel(models.Model): 8 | path = PathField(unique=True) 9 | objects = TreeManager() 10 | 11 | class Meta: 12 | abstract = True 13 | ordering = ("path",) 14 | 15 | def label(self): 16 | return self.path[-1] 17 | 18 | def get_ancestors_paths(self): # type: () -> List[List[str]] 19 | return [PathValue(self.path[:n]) for n, p in enumerate(self.path) if n > 0] 20 | 21 | def ancestors(self): 22 | return type(self)._default_manager.filter(path__ancestors=self.path) 23 | 24 | def descendants(self): 25 | return type(self)._default_manager.filter(path__descendants=self.path) 26 | 27 | def parent(self): 28 | if len(self.path) > 1: 29 | return self.ancestors().exclude(id=self.id).last() 30 | 31 | def children(self): 32 | return self.descendants().filter(path__depth=len(self.path) + 1) 33 | 34 | def siblings(self): 35 | parent = self.path[:-1] 36 | return ( 37 | type(self) 38 | ._default_manager.filter(path__descendants=".".join(parent)) 39 | .filter(path__depth=len(self.path)) 40 | .exclude(path=self.path) 41 | ) 42 | 43 | def add_child(self, path, **kwargs): # type:(str) -> Any 44 | assert "path" not in kwargs 45 | kwargs["path"] = self.path[:] 46 | kwargs["path"].append(path) 47 | return type(self)._default_manager.create(**kwargs) 48 | -------------------------------------------------------------------------------- /django_ltree/operations.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.operations import CreateExtension 2 | 3 | 4 | class LtreeExtension(CreateExtension): 5 | def __init__(self): 6 | self.name = "ltree" 7 | -------------------------------------------------------------------------------- /django_ltree/paths.py: -------------------------------------------------------------------------------- 1 | import string 2 | from itertools import product 3 | 4 | from django_ltree.fields import PathValue 5 | 6 | 7 | class PathGenerator(object): 8 | _default_label_size = 6 # Postgres limits this to 256 9 | 10 | def __init__(self, prefix=None, skip=None, label_size=None): 11 | self.skip_paths = [] if skip is None else skip[:] 12 | self.path_prefix = prefix if prefix else [] 13 | self.product_iterator = product( 14 | string.digits + string.ascii_letters, 15 | repeat=label_size or self._default_label_size, 16 | ) 17 | 18 | def __iter__(self): 19 | return self 20 | 21 | def __next__(self): 22 | for val in self.product_iterator: 23 | label = "".join(val) 24 | path = PathValue(self.path_prefix + [label]) 25 | if path not in self.skip_paths: 26 | return path 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres14: 3 | image: postgres:14 4 | volumes: 5 | - "pgdata:/var/lib/postgresql/data" 6 | ports: 7 | - 5432:5432 8 | environment: 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: postgres 11 | POSTGRES_DB: taxonomy_db 12 | 13 | postgres16: 14 | image: postgres:16 15 | volumes: 16 | - "pgdata:/var/lib/postgresql/data" 17 | ports: 18 | - 5433:5432 19 | environment: 20 | POSTGRES_USER: postgres 21 | POSTGRES_PASSWORD: postgres 22 | POSTGRES_DB: taxonomy_db 23 | 24 | volumes: 25 | pgdata: 26 | pyenv: 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 77.0.3"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django_ltree" 7 | version = "0.6.0" 8 | description = "Django app to support ltree postgres extension" 9 | readme = { file = "README.md", content-type = "text/markdown" } 10 | license-files = ["LICENSE"] 11 | authors = [ 12 | { name = "Mario César Señoranis Ayala", email = "mariocesar@humanzilla.com" } 13 | ] 14 | requires-python = ">=3.10" 15 | dependencies = [ 16 | "django>=2.2" 17 | ] 18 | classifiers = [ 19 | "Development Status :: 4 - Beta", 20 | "Framework :: Django", 21 | "Framework :: Django :: 2.2", 22 | "Intended Audience :: Developers", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13" 30 | ] 31 | 32 | [project.urls] 33 | Source = "https://github.com/mariocesar/django_ltree" 34 | Tracker = "https://github.com/mariocesar/django_ltree/issues" 35 | 36 | [tool.setuptools.packages.find] 37 | include = ["django_ltree", "django_ltree.migrations"] 38 | 39 | [project.optional-dependencies] 40 | develop = ["twine", "tox"] 41 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | exclude = ["venv", ".venv"] 2 | line-length = 100 3 | target-version = "py311" 4 | 5 | [lint] 6 | select = [ 7 | "E", # Errors 8 | "F", # Pyflakes 9 | "I", # Isort 10 | "W", # Warnings about wrong format 11 | "T20", # Look for print() statements 12 | ] -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | branch = True 3 | parallel = True 4 | 5 | [coverage:paths] 6 | source = django_ltree 7 | 8 | [tool:pytest] 9 | testpaths = tests 10 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | from django.conf import settings 5 | 6 | 7 | def pytest_sessionstart(session): 8 | settings.configure( 9 | DEBUG=False, 10 | USE_TZ=True, 11 | DATABASES={ 12 | "default": { 13 | "ENGINE": "django.db.backends.postgresql", 14 | "NAME": "taxonomy_db", 15 | "HOST": os.environ.get("DJANGO_DATABASE_HOST", "database"), 16 | "USER": os.environ.get("DJANGO_DATABASE_USER", "postgres"), 17 | "PASSWORD": os.environ.get("DJANGO_DATABASE_PASSWORD", "postgres"), 18 | } 19 | }, 20 | ROOT_URLCONF="tests.urls", 21 | INSTALLED_APPS=[ 22 | "django.contrib.auth", 23 | "django.contrib.contenttypes", 24 | "django.contrib.messages", 25 | "django.contrib.sessions", 26 | "django.contrib.sites", 27 | "django_ltree", 28 | "taxonomy", 29 | ], 30 | SITE_ID=1, 31 | SILENCED_SYSTEM_CHECKS=["RemovedInDjango30Warning"], 32 | ) 33 | django.setup() 34 | -------------------------------------------------------------------------------- /tests/taxonomy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mariocesar/django-ltree/c776e3ba76f1a833c34cbc290978512cea353fd8/tests/taxonomy/__init__.py -------------------------------------------------------------------------------- /tests/taxonomy/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TaxonomyConfig(AppConfig): 5 | name = "taxonomy" 6 | -------------------------------------------------------------------------------- /tests/taxonomy/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11.24 on 2019-09-11 12:31 2 | from django.db import migrations, models 3 | 4 | import django_ltree.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Taxonomy", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 20 | ), 21 | ), 22 | ("path", django_ltree.fields.PathField(unique=True)), 23 | ("name", models.TextField()), 24 | ], 25 | options={ 26 | "ordering": ("path",), 27 | "abstract": False, 28 | }, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /tests/taxonomy/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mariocesar/django-ltree/c776e3ba76f1a833c34cbc290978512cea353fd8/tests/taxonomy/migrations/__init__.py -------------------------------------------------------------------------------- /tests/taxonomy/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_ltree.models import TreeModel 4 | 5 | 6 | class Taxonomy(TreeModel): 7 | label_size = 2 8 | 9 | name = models.TextField() 10 | 11 | def __str__(self): 12 | return "{}: {}".format(self.path, self.name) 13 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from taxonomy.models import Taxonomy 3 | 4 | TEST_DATA = [ 5 | {"name": "Bacteria"}, 6 | {"name": "Plantae"}, 7 | { 8 | "name": "Animalia", 9 | "sub": [ 10 | { 11 | "name": "Chordata", 12 | "sub": [ 13 | { 14 | "name": "Mammalia", 15 | "sub": [ 16 | { 17 | "name": "Carnivora", 18 | "sub": [ 19 | { 20 | "name": "Canidae", 21 | "sub": [ 22 | { 23 | "name": "Canis", 24 | "sub": [ 25 | {"name": "Canis lupus"}, 26 | {"name": "Canis rufus"}, 27 | ], 28 | }, 29 | { 30 | "name": "Urocyon", 31 | "sub": [{"name": "Urocyon cinereoargenteus"}], 32 | }, 33 | ], 34 | }, 35 | { 36 | "name": "Feliformia", 37 | "sub": [ 38 | { 39 | "name": "Felidae", 40 | "sub": [ 41 | { 42 | "name": "Felinae", 43 | "sub": [ 44 | { 45 | "name": "Lynx", 46 | "sub": [ 47 | {"name": "Lynx lynx"}, 48 | {"name": "Lynx rufus"}, 49 | ], 50 | }, 51 | { 52 | "name": "Puma", 53 | "sub": [{"name": "Puma concolor"}], 54 | }, 55 | ], 56 | } 57 | ], 58 | } 59 | ], 60 | }, 61 | ], 62 | }, 63 | { 64 | "name": "Pilosa", 65 | "sub": [ 66 | { 67 | "name": "Folivora", 68 | "sub": [ 69 | { 70 | "name": "Bradypodidae", 71 | "sub": [ 72 | { 73 | "name": "Bradypus", 74 | "sub": [{"name": "Bradypus tridactylus"}], 75 | } 76 | ], 77 | } 78 | ], 79 | } 80 | ], 81 | }, 82 | ], 83 | }, 84 | { 85 | "name": "Reptilia", 86 | "sub": [ 87 | { 88 | "name": "Squamata", 89 | "sub": [ 90 | { 91 | "name": "Iguania", 92 | "sub": [ 93 | { 94 | "name": "Agamidae", 95 | "sub": [ 96 | { 97 | "name": "Pogona", 98 | "sub": [ 99 | {"name": "Pogona barbata"}, 100 | {"name": "Pogona minor"}, 101 | {"name": "Pogona vitticeps"}, 102 | ], 103 | } 104 | ], 105 | } 106 | ], 107 | } 108 | ], 109 | } 110 | ], 111 | }, 112 | ], 113 | } 114 | ], 115 | }, 116 | ] 117 | 118 | 119 | def create_objects(objects, parent): 120 | for obj in objects: 121 | created = Taxonomy.objects.create_child(parent, name=obj["name"]) 122 | if "sub" in obj: 123 | create_objects(obj["sub"], created) 124 | 125 | 126 | def create_test_data(): 127 | create_objects(TEST_DATA, parent=None) 128 | 129 | 130 | def test_create(db): 131 | create_test_data() 132 | assert Taxonomy.objects.count() != 0 133 | 134 | 135 | def test_roots(db): 136 | create_test_data() 137 | roots = Taxonomy.objects.roots().values_list("name", flat=True) 138 | assert set(roots) == set(["Bacteria", "Plantae", "Animalia"]) 139 | 140 | 141 | @pytest.mark.parametrize( 142 | "name, expected", 143 | [ 144 | ("Animalia", ["Chordata"]), 145 | ("Mammalia", ["Carnivora", "Pilosa"]), 146 | ("Reptilia", ["Squamata"]), 147 | ("Pogona", ["Pogona barbata", "Pogona minor", "Pogona vitticeps"]), 148 | ], 149 | ) 150 | def test_children(db, name, expected): 151 | create_test_data() 152 | children = Taxonomy.objects.get(name=name).children().values_list("name", flat=True) 153 | assert set(children) == set(expected) 154 | 155 | 156 | def test_label(db): 157 | create_test_data() 158 | for item in Taxonomy.objects.all(): 159 | label = item.label() 160 | assert label.isalnum() 161 | assert str(item.path).endswith(label) 162 | 163 | 164 | @pytest.mark.parametrize( 165 | "name, expected", 166 | [ 167 | ( 168 | "Canis lupus", 169 | ["Animalia", "Chordata", "Mammalia", "Carnivora", "Canidae", "Canis", "Canis lupus"], 170 | ), 171 | ("Bacteria", ["Bacteria"]), 172 | ("Chordata", ["Animalia", "Chordata"]), 173 | ], 174 | ) 175 | def test_ancestors(db, name, expected): 176 | create_test_data() 177 | ancestors = Taxonomy.objects.get(name=name).ancestors().values_list("name", flat=True) 178 | assert list(ancestors) == expected 179 | 180 | 181 | @pytest.mark.parametrize( 182 | "name, expected", 183 | [ 184 | ( 185 | "Canidae", 186 | [ 187 | "Canidae", 188 | "Canis", 189 | "Canis lupus", 190 | "Canis rufus", 191 | "Urocyon", 192 | "Urocyon cinereoargenteus", 193 | ], 194 | ), 195 | ("Bradypus tridactylus", ["Bradypus tridactylus"]), 196 | ("Pogona", ["Pogona", "Pogona barbata", "Pogona minor", "Pogona vitticeps"]), 197 | ], 198 | ) 199 | def test_descendants(db, name, expected): 200 | create_test_data() 201 | descendants = Taxonomy.objects.get(name=name).descendants().values_list("name", flat=True) 202 | assert set(descendants) == set(expected) 203 | 204 | 205 | @pytest.mark.parametrize( 206 | "name, expected", [("Feliformia", "Carnivora"), ("Plantae", None), ("Pogona minor", "Pogona")] 207 | ) 208 | def test_parent(db, name, expected): 209 | create_test_data() 210 | parent = Taxonomy.objects.get(name=name).parent() 211 | assert getattr(parent, "name", None) == expected 212 | 213 | 214 | @pytest.mark.parametrize( 215 | "name, expected", 216 | [("Carnivora", ["Pilosa"]), ("Pogona vitticeps", ["Pogona minor", "Pogona barbata"])], 217 | ) 218 | def test_siblings(db, name, expected): 219 | create_test_data() 220 | siblings = Taxonomy.objects.get(name=name).siblings().values_list("name", flat=True) 221 | assert set(siblings) == set(expected) 222 | 223 | 224 | def test_slicing(db): 225 | create_test_data() 226 | qs = Taxonomy.objects.all() 227 | assert qs[:3].count() == 3 228 | -------------------------------------------------------------------------------- /tests/test_path_field.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.exceptions import ValidationError 3 | from taxonomy.models import Taxonomy 4 | 5 | from django_ltree.fields import PathValue, path_label_validator 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ["path", "valid"], 10 | [ 11 | ("00_00a", True), 12 | ("00$00", False), 13 | ("00000a.00000b", True), 14 | ("00000a+00000b", False), 15 | ], 16 | ) 17 | def test_path_field_validation(path, valid): 18 | """Validating that the path field is valid.""" 19 | taxonomy = Taxonomy() 20 | taxonomy.name = "test" 21 | taxonomy.path = PathValue(path) 22 | if valid: 23 | taxonomy.full_clean() 24 | else: 25 | with pytest.raises(ValidationError) as excinfo: 26 | taxonomy.full_clean() 27 | 28 | assert excinfo.value.message_dict == { 29 | "path": [ 30 | path_label_validator.message 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /tests/test_path_value.py: -------------------------------------------------------------------------------- 1 | from django_ltree.fields import PathValue 2 | 3 | 4 | def test_create(): 5 | assert str(PathValue([1, 2, 3, 4, 5])) == "1.2.3.4.5" 6 | assert str(PathValue((1, 3, 5, 7))) == "1.3.5.7" 7 | assert str(PathValue("hello.world")) == "hello.world" 8 | assert str(PathValue(5)) == "5" 9 | 10 | def generator(): 11 | yield "100" 12 | yield "bottles" 13 | yield "of" 14 | yield "beer" 15 | 16 | assert str(PathValue(generator())) == "100.bottles.of.beer" 17 | -------------------------------------------------------------------------------- /tests/test_register.py: -------------------------------------------------------------------------------- 1 | from django_ltree import functions, lookups 2 | from django_ltree.fields import PathField 3 | 4 | 5 | def test_registered_lookups(): 6 | registered_lookups = PathField.get_lookups() 7 | 8 | assert "ancestors" in registered_lookups, "Missing 'ancestors' in lookups" 9 | assert registered_lookups["ancestors"] is lookups.AncestorLookup 10 | 11 | assert "descendants" in registered_lookups, "Missing 'descendants' in lookups" 12 | assert registered_lookups["descendants"] is lookups.DescendantLookup 13 | 14 | assert "match" in registered_lookups, "Missing 'match' in lookups" 15 | assert registered_lookups["match"] is lookups.MatchLookup 16 | 17 | assert "depth" in registered_lookups, "Missing 'depth' in lookups" 18 | assert registered_lookups["depth"] is functions.NLevel 19 | 20 | assert "contains" in registered_lookups, "Missing 'contains' in lookups" 21 | assert registered_lookups["contains"] is lookups.ContainsLookup 22 | --------------------------------------------------------------------------------