Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,16 +1,40 @@

default:
BOLD := \033[1m
RESET := \033[0m
GREEN := \033[1;32m

default: help

install: ## Install all development dependencies in editable mode
pip install -e .[develop]
.PHONY: install


build: clean
test: ## Run tests
pytest tests/
.PHONY: test

lint: ## Run ruff check and fix
ruff check . --fix
.PHONY: lint

build: clean ## Build the package
python -m build -s -w

publish: build
twine upload dist/*
.PHONY: publish

clean:
rm -rf dist/
rm -rf build/
.PHONY: clean

backend:
docker-compose run --rm --service-ports backend bash

help:
@echo "$(BOLD)django-ltree Makefile"
@echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:"
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
.PHONY: help
9 changes: 7 additions & 2 deletions django_ltree/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"invalid",
)


class PathValue(UserList):
def __init__(self, value):
if isinstance(value, str):
Expand Down Expand Up @@ -66,6 +65,7 @@ class PathField(TextField):
def db_type(self, connection):
return "ltree"


def formfield(self, **kwargs):
kwargs["form_class"] = PathFormField
kwargs["widget"] = TextInput(attrs={"class": "vTextField"})
Expand Down Expand Up @@ -104,6 +104,11 @@ def get_db_prep_value(self, value, connection, prepared=False):
raise ValueError("Unknown value type {}".format(type(value)))


class LqueryField(PathField):
class LqueryField(TextField):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.editable = False

def db_type(self, connection):
return "lquery"
48 changes: 22 additions & 26 deletions django_ltree/lookups.py
Original file line number Diff line number Diff line change
@@ -1,46 +1,42 @@
from django.db.models import Lookup
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.lookups import PostgresOperatorLookup
from django.db.models import Value

from .fields import PathField


class SimpleLookup(Lookup):
lookup_operator = "=" # type: str

def as_sql(self, compiler, connection):
lhs, lhs_params = self.process_lhs(compiler, connection)
rhs, rhs_params = self.process_rhs(compiler, connection)
return "{} {} {}".format(lhs, self.lookup_operator, rhs), [*lhs_params, *rhs_params]
from .fields import LqueryField, PathField


@PathField.register_lookup
class EqualLookup(Lookup):
class EqualLookup(PostgresOperatorLookup):
postgres_operator = "="
lookup_name = "exact"

def as_sql(self, compiler, connection):
lhs, lhs_params = self.process_lhs(compiler, connection)
rhs, rhs_params = self.process_rhs(compiler, connection)
return "{} = {}".format(lhs, rhs), [*lhs_params, *rhs_params]


@PathField.register_lookup
class AncestorLookup(SimpleLookup):
class AncestorLookup(PostgresOperatorLookup):
lookup_name = "ancestors"
lookup_operator = "@>"
postgres_operator = "@>"


@PathField.register_lookup
class DescendantLookup(SimpleLookup):
class DescendantLookup(PostgresOperatorLookup):
lookup_name = "descendants"
lookup_operator = "<@"
postgres_operator = "<@"


@PathField.register_lookup
class MatchLookup(SimpleLookup):
class MatchLookup(PostgresOperatorLookup):
lookup_name = "match"
lookup_operator = "~"
postgres_operator = "~"


@PathField.register_lookup
class ContainsLookup(SimpleLookup):
class ContainsLookup(PostgresOperatorLookup):
lookup_name = "contains"
lookup_operator = "?"
postgres_operator = "?"

def __init__(self, lhs, rhs):
if not isinstance(rhs, (tuple, list)):
raise TypeError("Contains lookup requires a list or tuple of values")

rhs = Value(rhs, output_field=ArrayField(base_field=LqueryField()))
super().__init__(lhs, rhs)

10 changes: 9 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,12 @@ Tracker = "https://github.com/mariocesar/django_ltree/issues"
include = ["django_ltree", "django_ltree.migrations"]

[project.optional-dependencies]
develop = ["twine", "tox"]
develop = [
"pytest",
"pytest-django",
"pytest-cov",
"psycopg[binary]",
"twine",
"tox",
"ruff"
]
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def pytest_sessionstart(session):
"HOST": os.environ.get("DJANGO_DATABASE_HOST", "database"),
"USER": os.environ.get("DJANGO_DATABASE_USER", "postgres"),
"PASSWORD": os.environ.get("DJANGO_DATABASE_PASSWORD", "postgres"),
"PORT": os.environ.get("DJANGO_DATABASE_PORT", 5432),
}
},
ROOT_URLCONF="tests.urls",
Expand Down
113 changes: 113 additions & 0 deletions tests/test_lookups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import pytest
from taxonomy.models import Taxonomy

pytestmark = pytest.mark.django_db

def test_lookups_pattern_matching():
"""Test basic lquery pattern matching with existing Taxonomy model."""
# Create some test data using the existing Taxonomy model
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR Department")
Taxonomy.objects.create(path="tenant_a.departments.finance", name="Finance Department")
Taxonomy.objects.create(path="tenant_b.projects.alpha", name="Project Alpha")
Taxonomy.objects.create(path="shared.public.docs", name="Public Documentation")

# Test basic pattern matching
hr_matches = Taxonomy.objects.filter(path__match="tenant_a.departments.*")
assert hr_matches.count() == 2

# Test wildcard pattern
tenant_a_matches = Taxonomy.objects.filter(path__match="tenant_a.*")
assert tenant_a_matches.count() == 2

def test_lookups_contains():
"""Test the key feature: contains lookup with array of lquery patterns."""
# Create test data
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
Taxonomy.objects.create(path="tenant_a.projects.alpha", name="Alpha Project")
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
Taxonomy.objects.create(path="shared.public.docs", name="Docs")

# Test array of patterns with contains lookup
patterns = [
"tenant_a.departments.*", # HR department
"shared.public.*", # Public docs
]

matching = Taxonomy.objects.filter(
path__contains=patterns
)

# Should match HR and public docs (2 items)
assert matching.count() == 2

matched_names = set(item.name for item in matching)
assert "HR" in matched_names
assert "Docs" in matched_names

def test_lookups_contains_with_single_value():
"""Test the contains lookup with a single value."""
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
Taxonomy.objects.create(path="tenant_a.projects.alpha", name="Alpha Project")
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
Taxonomy.objects.create(path="shared.public.docs", name="Docs")

matching = Taxonomy.objects.filter(path__contains=["tenant_a.*"])
assert matching.count() == 2
matched_names = set(item.name for item in matching)
assert "HR" in matched_names
assert "Alpha Project" in matched_names

def test_lookups_contains_invalid_value():
"""Test the contains lookup with an invalid value."""
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
Taxonomy.objects.create(path="tenant_a.projects.alpha", name="Alpha Project")
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
Taxonomy.objects.create(path="shared.public.docs", name="Docs")

with pytest.raises(TypeError):
Taxonomy.objects.filter(path__contains="tenant_a.*")

def test_lookups_ancestors():
"""Test the ancestors lookup."""
Taxonomy.objects.create(path="tenant_a", name="Tenant A")
Taxonomy.objects.create(path="tenant_a.departments", name="Departments")
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
Taxonomy.objects.create(path="tenant_a.departments.alpha", name="Alpha Project")
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
Taxonomy.objects.create(path="shared.public.docs", name="Docs")

matching = Taxonomy.objects.filter(path__ancestors="tenant_a.departments")
assert matching.count() == 2
matched_names = set(item.name for item in matching)
assert "Tenant A" in matched_names
assert "Departments" in matched_names

def test_lookups_descendants():
"""Test the descendants lookup."""
Taxonomy.objects.create(path="tenant_a", name="Tenant A")
Taxonomy.objects.create(path="tenant_a.departments", name="Departments")
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
Taxonomy.objects.create(path="tenant_a.departments.alpha", name="Alpha Project")
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
Taxonomy.objects.create(path="shared.public.docs", name="Docs")

matching = Taxonomy.objects.filter(path__descendants="tenant_a.departments")

assert matching.count() == 3
matched_names = set(item.name for item in matching)
assert "HR" in matched_names
assert "Alpha Project" in matched_names
assert "Departments" in matched_names

def test_lookups_exact():
"""Test the exact lookup."""
Taxonomy.objects.create(path="tenant_a", name="Tenant A")
Taxonomy.objects.create(path="tenant_a.departments", name="Departments")
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
Taxonomy.objects.create(path="tenant_a.departments.alpha", name="Alpha Project")
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
Taxonomy.objects.create(path="shared.public.docs", name="Docs")

matching = Taxonomy.objects.filter(path__exact="tenant_a.departments.hr")
assert matching.count() == 1
assert "HR" == matching.first().name
86 changes: 4 additions & 82 deletions tests/test_lquery_field.py
Original file line number Diff line number Diff line change
@@ -1,89 +1,11 @@
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value
from taxonomy.models import Taxonomy
import pytest

from django_ltree.fields import LqueryField, PathField
from django_ltree.fields import LqueryField

pytestmark = pytest.mark.django_db


def test_lquery_field_db_type():
"""Test that LqueryField returns correct database type."""
field = LqueryField()
assert field.db_type(connection=None) == "lquery"


def test_lquery_field_inherits_from_pathfield():
"""Test that LqueryField inherits PathField functionality."""
field = LqueryField()
assert isinstance(field, PathField)

# Should inherit PathField's value processing
assert field.get_prep_value("simple.path") == "simple.path"
assert field.get_prep_value(["multi", "part", "path"]) == "multi.part.path"


def test_lquery_field_basic_validation():
"""Test that LqueryField accepts valid lquery patterns."""
from django.db import models

class TestModel(models.Model):
pattern = LqueryField()

class Meta:
app_label = 'tests'

# Valid lquery patterns should not raise ValidationError
valid_patterns = [
"simple.path",
"*.wildcard.path",
"path.*.with.wildcard",
"f63969a8-536f-4c80-a0a3-fafdb53cb7cf.*"
]

for pattern in valid_patterns:
instance = TestModel(pattern=pattern)
instance.full_clean() # Should not raise


def test_lquery_pattern_matching(db):
"""Test basic lquery pattern matching with existing Taxonomy model."""
# Create some test data using the existing Taxonomy model
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR Department")
Taxonomy.objects.create(path="tenant_a.departments.finance", name="Finance Department")
Taxonomy.objects.create(path="tenant_b.projects.alpha", name="Project Alpha")
Taxonomy.objects.create(path="shared.public.docs", name="Public Documentation")

# Test basic pattern matching
hr_matches = Taxonomy.objects.filter(path__match="tenant_a.departments.*")
assert hr_matches.count() == 2

# Test wildcard pattern
tenant_a_matches = Taxonomy.objects.filter(path__match="tenant_a.*")
assert tenant_a_matches.count() == 2


def test_lquery_array_contains_lookup(db):
"""Test the key feature: contains lookup with array of lquery patterns."""
# Create test data
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
Taxonomy.objects.create(path="tenant_a.projects.alpha", name="Alpha Project")
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
Taxonomy.objects.create(path="shared.public.docs", name="Docs")

# Test array of patterns with contains lookup
patterns = [
"tenant_a.departments.*", # HR department
"shared.public.*", # Public docs
]

output_field = ArrayField(base_field=LqueryField())

matching = Taxonomy.objects.filter(
path__contains=Value(patterns, output_field=output_field)
)

# Should match HR and public docs (2 items)
assert matching.count() == 2

matched_names = set(item.name for item in matching)
assert "HR" in matched_names
assert "Docs" in matched_names