Skip to content

Commit dd7ba9d

Browse files
authored
Merge pull request #33 from lunika/feat/lquery
feat: Add lquery type
2 parents 5f7570b + ec74971 commit dd7ba9d

File tree

7 files changed

+182
-113
lines changed

7 files changed

+182
-113
lines changed

Makefile

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,40 @@
11

2-
default:
2+
BOLD := \033[1m
3+
RESET := \033[0m
4+
GREEN := \033[1;32m
5+
6+
default: help
7+
8+
install: ## Install all development dependencies in editable mode
39
pip install -e .[develop]
10+
.PHONY: install
11+
412

5-
build: clean
13+
test: ## Run tests
14+
pytest tests/
15+
.PHONY: test
16+
17+
lint: ## Run ruff check and fix
18+
ruff check . --fix
19+
.PHONY: lint
20+
21+
build: clean ## Build the package
622
python -m build -s -w
723

824
publish: build
925
twine upload dist/*
26+
.PHONY: publish
1027

1128
clean:
1229
rm -rf dist/
1330
rm -rf build/
31+
.PHONY: clean
1432

1533
backend:
1634
docker-compose run --rm --service-ports backend bash
35+
36+
help:
37+
@echo "$(BOLD)django-ltree Makefile"
38+
@echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:"
39+
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
40+
.PHONY: help

django_ltree/fields.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
"invalid",
1313
)
1414

15-
1615
class PathValue(UserList):
1716
def __init__(self, value):
1817
if isinstance(value, str):
@@ -66,6 +65,7 @@ class PathField(TextField):
6665
def db_type(self, connection):
6766
return "ltree"
6867

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

106106

107-
class LqueryField(PathField):
107+
class LqueryField(TextField):
108+
109+
def __init__(self, *args, **kwargs):
110+
super().__init__(*args, **kwargs)
111+
self.editable = False
112+
108113
def db_type(self, connection):
109114
return "lquery"

django_ltree/lookups.py

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,42 @@
1-
from django.db.models import Lookup
1+
from django.contrib.postgres.fields import ArrayField
2+
from django.contrib.postgres.lookups import PostgresOperatorLookup
3+
from django.db.models import Value
24

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]
5+
from .fields import LqueryField, PathField
136

147

158
@PathField.register_lookup
16-
class EqualLookup(Lookup):
9+
class EqualLookup(PostgresOperatorLookup):
10+
postgres_operator = "="
1711
lookup_name = "exact"
1812

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-
2513
@PathField.register_lookup
26-
class AncestorLookup(SimpleLookup):
14+
class AncestorLookup(PostgresOperatorLookup):
2715
lookup_name = "ancestors"
28-
lookup_operator = "@>"
16+
postgres_operator = "@>"
2917

3018

3119
@PathField.register_lookup
32-
class DescendantLookup(SimpleLookup):
20+
class DescendantLookup(PostgresOperatorLookup):
3321
lookup_name = "descendants"
34-
lookup_operator = "<@"
22+
postgres_operator = "<@"
3523

3624

3725
@PathField.register_lookup
38-
class MatchLookup(SimpleLookup):
26+
class MatchLookup(PostgresOperatorLookup):
3927
lookup_name = "match"
40-
lookup_operator = "~"
28+
postgres_operator = "~"
4129

4230

4331
@PathField.register_lookup
44-
class ContainsLookup(SimpleLookup):
32+
class ContainsLookup(PostgresOperatorLookup):
4533
lookup_name = "contains"
46-
lookup_operator = "?"
34+
postgres_operator = "?"
35+
36+
def __init__(self, lhs, rhs):
37+
if not isinstance(rhs, (tuple, list)):
38+
raise TypeError("Contains lookup requires a list or tuple of values")
39+
40+
rhs = Value(rhs, output_field=ArrayField(base_field=LqueryField()))
41+
super().__init__(lhs, rhs)
42+

pyproject.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,12 @@ Tracker = "https://github.com/mariocesar/django_ltree/issues"
3737
include = ["django_ltree", "django_ltree.migrations"]
3838

3939
[project.optional-dependencies]
40-
develop = ["twine", "tox"]
40+
develop = [
41+
"pytest",
42+
"pytest-django",
43+
"pytest-cov",
44+
"psycopg[binary]",
45+
"twine",
46+
"tox",
47+
"ruff"
48+
]

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ def pytest_sessionstart(session):
1515
"HOST": os.environ.get("DJANGO_DATABASE_HOST", "database"),
1616
"USER": os.environ.get("DJANGO_DATABASE_USER", "postgres"),
1717
"PASSWORD": os.environ.get("DJANGO_DATABASE_PASSWORD", "postgres"),
18+
"PORT": os.environ.get("DJANGO_DATABASE_PORT", 5432),
1819
}
1920
},
2021
ROOT_URLCONF="tests.urls",

tests/test_lookups.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import pytest
2+
from taxonomy.models import Taxonomy
3+
4+
pytestmark = pytest.mark.django_db
5+
6+
def test_lookups_pattern_matching():
7+
"""Test basic lquery pattern matching with existing Taxonomy model."""
8+
# Create some test data using the existing Taxonomy model
9+
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR Department")
10+
Taxonomy.objects.create(path="tenant_a.departments.finance", name="Finance Department")
11+
Taxonomy.objects.create(path="tenant_b.projects.alpha", name="Project Alpha")
12+
Taxonomy.objects.create(path="shared.public.docs", name="Public Documentation")
13+
14+
# Test basic pattern matching
15+
hr_matches = Taxonomy.objects.filter(path__match="tenant_a.departments.*")
16+
assert hr_matches.count() == 2
17+
18+
# Test wildcard pattern
19+
tenant_a_matches = Taxonomy.objects.filter(path__match="tenant_a.*")
20+
assert tenant_a_matches.count() == 2
21+
22+
def test_lookups_contains():
23+
"""Test the key feature: contains lookup with array of lquery patterns."""
24+
# Create test data
25+
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
26+
Taxonomy.objects.create(path="tenant_a.projects.alpha", name="Alpha Project")
27+
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
28+
Taxonomy.objects.create(path="shared.public.docs", name="Docs")
29+
30+
# Test array of patterns with contains lookup
31+
patterns = [
32+
"tenant_a.departments.*", # HR department
33+
"shared.public.*", # Public docs
34+
]
35+
36+
matching = Taxonomy.objects.filter(
37+
path__contains=patterns
38+
)
39+
40+
# Should match HR and public docs (2 items)
41+
assert matching.count() == 2
42+
43+
matched_names = set(item.name for item in matching)
44+
assert "HR" in matched_names
45+
assert "Docs" in matched_names
46+
47+
def test_lookups_contains_with_single_value():
48+
"""Test the contains lookup with a single value."""
49+
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
50+
Taxonomy.objects.create(path="tenant_a.projects.alpha", name="Alpha Project")
51+
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
52+
Taxonomy.objects.create(path="shared.public.docs", name="Docs")
53+
54+
matching = Taxonomy.objects.filter(path__contains=["tenant_a.*"])
55+
assert matching.count() == 2
56+
matched_names = set(item.name for item in matching)
57+
assert "HR" in matched_names
58+
assert "Alpha Project" in matched_names
59+
60+
def test_lookups_contains_invalid_value():
61+
"""Test the contains lookup with an invalid value."""
62+
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
63+
Taxonomy.objects.create(path="tenant_a.projects.alpha", name="Alpha Project")
64+
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
65+
Taxonomy.objects.create(path="shared.public.docs", name="Docs")
66+
67+
with pytest.raises(TypeError):
68+
Taxonomy.objects.filter(path__contains="tenant_a.*")
69+
70+
def test_lookups_ancestors():
71+
"""Test the ancestors lookup."""
72+
Taxonomy.objects.create(path="tenant_a", name="Tenant A")
73+
Taxonomy.objects.create(path="tenant_a.departments", name="Departments")
74+
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
75+
Taxonomy.objects.create(path="tenant_a.departments.alpha", name="Alpha Project")
76+
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
77+
Taxonomy.objects.create(path="shared.public.docs", name="Docs")
78+
79+
matching = Taxonomy.objects.filter(path__ancestors="tenant_a.departments")
80+
assert matching.count() == 2
81+
matched_names = set(item.name for item in matching)
82+
assert "Tenant A" in matched_names
83+
assert "Departments" in matched_names
84+
85+
def test_lookups_descendants():
86+
"""Test the descendants lookup."""
87+
Taxonomy.objects.create(path="tenant_a", name="Tenant A")
88+
Taxonomy.objects.create(path="tenant_a.departments", name="Departments")
89+
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
90+
Taxonomy.objects.create(path="tenant_a.departments.alpha", name="Alpha Project")
91+
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
92+
Taxonomy.objects.create(path="shared.public.docs", name="Docs")
93+
94+
matching = Taxonomy.objects.filter(path__descendants="tenant_a.departments")
95+
96+
assert matching.count() == 3
97+
matched_names = set(item.name for item in matching)
98+
assert "HR" in matched_names
99+
assert "Alpha Project" in matched_names
100+
assert "Departments" in matched_names
101+
102+
def test_lookups_exact():
103+
"""Test the exact lookup."""
104+
Taxonomy.objects.create(path="tenant_a", name="Tenant A")
105+
Taxonomy.objects.create(path="tenant_a.departments", name="Departments")
106+
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
107+
Taxonomy.objects.create(path="tenant_a.departments.alpha", name="Alpha Project")
108+
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
109+
Taxonomy.objects.create(path="shared.public.docs", name="Docs")
110+
111+
matching = Taxonomy.objects.filter(path__exact="tenant_a.departments.hr")
112+
assert matching.count() == 1
113+
assert "HR" == matching.first().name

tests/test_lquery_field.py

Lines changed: 4 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,11 @@
1-
from django.contrib.postgres.fields import ArrayField
2-
from django.db.models import Value
3-
from taxonomy.models import Taxonomy
1+
import pytest
42

5-
from django_ltree.fields import LqueryField, PathField
3+
from django_ltree.fields import LqueryField
4+
5+
pytestmark = pytest.mark.django_db
66

77

88
def test_lquery_field_db_type():
99
"""Test that LqueryField returns correct database type."""
1010
field = LqueryField()
1111
assert field.db_type(connection=None) == "lquery"
12-
13-
14-
def test_lquery_field_inherits_from_pathfield():
15-
"""Test that LqueryField inherits PathField functionality."""
16-
field = LqueryField()
17-
assert isinstance(field, PathField)
18-
19-
# Should inherit PathField's value processing
20-
assert field.get_prep_value("simple.path") == "simple.path"
21-
assert field.get_prep_value(["multi", "part", "path"]) == "multi.part.path"
22-
23-
24-
def test_lquery_field_basic_validation():
25-
"""Test that LqueryField accepts valid lquery patterns."""
26-
from django.db import models
27-
28-
class TestModel(models.Model):
29-
pattern = LqueryField()
30-
31-
class Meta:
32-
app_label = 'tests'
33-
34-
# Valid lquery patterns should not raise ValidationError
35-
valid_patterns = [
36-
"simple.path",
37-
"*.wildcard.path",
38-
"path.*.with.wildcard",
39-
"f63969a8-536f-4c80-a0a3-fafdb53cb7cf.*"
40-
]
41-
42-
for pattern in valid_patterns:
43-
instance = TestModel(pattern=pattern)
44-
instance.full_clean() # Should not raise
45-
46-
47-
def test_lquery_pattern_matching(db):
48-
"""Test basic lquery pattern matching with existing Taxonomy model."""
49-
# Create some test data using the existing Taxonomy model
50-
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR Department")
51-
Taxonomy.objects.create(path="tenant_a.departments.finance", name="Finance Department")
52-
Taxonomy.objects.create(path="tenant_b.projects.alpha", name="Project Alpha")
53-
Taxonomy.objects.create(path="shared.public.docs", name="Public Documentation")
54-
55-
# Test basic pattern matching
56-
hr_matches = Taxonomy.objects.filter(path__match="tenant_a.departments.*")
57-
assert hr_matches.count() == 2
58-
59-
# Test wildcard pattern
60-
tenant_a_matches = Taxonomy.objects.filter(path__match="tenant_a.*")
61-
assert tenant_a_matches.count() == 2
62-
63-
64-
def test_lquery_array_contains_lookup(db):
65-
"""Test the key feature: contains lookup with array of lquery patterns."""
66-
# Create test data
67-
Taxonomy.objects.create(path="tenant_a.departments.hr", name="HR")
68-
Taxonomy.objects.create(path="tenant_a.projects.alpha", name="Alpha Project")
69-
Taxonomy.objects.create(path="tenant_b.departments.eng", name="Engineering")
70-
Taxonomy.objects.create(path="shared.public.docs", name="Docs")
71-
72-
# Test array of patterns with contains lookup
73-
patterns = [
74-
"tenant_a.departments.*", # HR department
75-
"shared.public.*", # Public docs
76-
]
77-
78-
output_field = ArrayField(base_field=LqueryField())
79-
80-
matching = Taxonomy.objects.filter(
81-
path__contains=Value(patterns, output_field=output_field)
82-
)
83-
84-
# Should match HR and public docs (2 items)
85-
assert matching.count() == 2
86-
87-
matched_names = set(item.name for item in matching)
88-
assert "HR" in matched_names
89-
assert "Docs" in matched_names

0 commit comments

Comments
 (0)