diff --git a/Makefile b/Makefile index e54df63..7aaf0b2 100644 --- a/Makefile +++ b/Makefile @@ -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 \ No newline at end of file diff --git a/django_ltree/fields.py b/django_ltree/fields.py index b967b8a..43641f4 100644 --- a/django_ltree/fields.py +++ b/django_ltree/fields.py @@ -12,7 +12,6 @@ "invalid", ) - class PathValue(UserList): def __init__(self, value): if isinstance(value, str): @@ -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"}) @@ -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" diff --git a/django_ltree/lookups.py b/django_ltree/lookups.py index b363546..9dd570f 100644 --- a/django_ltree/lookups.py +++ b/django_ltree/lookups.py @@ -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) + diff --git a/pyproject.toml b/pyproject.toml index e80b239..3cdc100 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" +] diff --git a/tests/conftest.py b/tests/conftest.py index 6fe8691..51a687a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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", diff --git a/tests/test_lookups.py b/tests/test_lookups.py new file mode 100644 index 0000000..a03a482 --- /dev/null +++ b/tests/test_lookups.py @@ -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 diff --git a/tests/test_lquery_field.py b/tests/test_lquery_field.py index 9202972..2071569 100644 --- a/tests/test_lquery_field.py +++ b/tests/test_lquery_field.py @@ -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