Skip to content
This repository has been archived by the owner on Apr 16, 2023. It is now read-only.

Commit

Permalink
Merge pull request #131 from meshy/level-annotation
Browse files Browse the repository at this point in the history
Add easy way to annotate Route QS with "level"
  • Loading branch information
meshy committed Jan 21, 2018
2 parents 3a60756 + d3963e4 commit 516f26a
Show file tree
Hide file tree
Showing 7 changed files with 132 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Added

- Added `Route.level` property.
- Added `Route.objects.with_level()` to allow access to `level` in querysets.
- Added `Route.get_subclasses()`.
- Added `TemplateHandler`. A simpler handler that requires only a template.
This is the new default for `Route.handler_class`.
Expand Down
24 changes: 24 additions & 0 deletions conman/routes/expressions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from django.db.models.expressions import Func


class CharCount(Func):
"""
Count the occurrences of a char within a field.
Works by finding the difference in length between the whole string, and the
string with the char removed.
"""
template = "CHAR_LENGTH(%(field)s) - CHAR_LENGTH(REPLACE(%(field)s, '%(char)s', ''))"

def __init__(self, field, *, char, **extra):
"""
Add some validation to the invocation.
"Char" must always:
- be passed as a keyword argument
- be exactly one character.
"""
if len(char) != 1:
raise ValueError('CharCount must count exactly one char.')
super().__init__(field, char=char, **extra)
14 changes: 14 additions & 0 deletions conman/routes/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from polymorphic.managers import PolymorphicManager

from .exceptions import InvalidURL
from .expressions import CharCount
from .utils import split_path


Expand Down Expand Up @@ -72,3 +73,16 @@ def move_branch(self, old_url, new_url):
Value(new_url),
Substr('url', len(old_url) + 1), # 1 indexed
))

def with_level(self, level=None):
"""
Annotate the queryset with the (0-indexed) level of each item.
The level reflects the number of forward slashes in the path.
If "level" is passed in, the queryset will be filtered by the level.
"""
qs = self.annotate(level=CharCount('url', char='/') - 1)
if level is None:
return qs
return qs.filter(level=level)
10 changes: 10 additions & 0 deletions conman/routes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ def handle(self, request, path):
# Deal with the request
return handler.handle(request, path)

@property
def level(self):
"""Fetch the 'level' of this item in the URL tree."""
return self.url.count('/') - 1 # 0-indexed.

@level.setter
def level(self, new_value):
"""Silently fails to allow queryset annotation to work."""
pass

def move_to(self, new_url, *, move_children):
"""
Move this Route to a new url.
Expand Down
46 changes: 46 additions & 0 deletions tests/routes/test_expressions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext

from conman.routes.expressions import CharCount
from conman.routes.models import Route

from .factories import RouteFactory


class TestCharCount(TestCase):
"""Tests for CharCount."""
def test_query(self):
"""Match the exact value of the generated query."""
with CaptureQueriesContext(connection):
# The "only" here is handy to keep the query as short as possible.
list(Route.objects.only('id').annotate(level=CharCount('url', char='/')))
# Excuse the line wrapping here -- I wasn't sure of a nice way to do it.
# I decided it was better to just keep it simple.
expected = (
'SELECT "routes_route"."id", ' +
'CHAR_LENGTH("routes_route"."url") - ' +
'''CHAR_LENGTH(REPLACE("routes_route"."url", '/', '')) AS "level" ''' +
'FROM "routes_route"'
)
self.assertEqual(connection.queries[0]['sql'], expected)

def test_annotation(self):
"""Test the expression can be used for annotation."""
RouteFactory.create(url='/fifth/level/zero/indexed/path/')
route = Route.objects.annotate(level=CharCount('url', char='/')).get()
# The number of "/" in the path minus one for zero-indexing.
self.assertEqual(route.level, 5)

def test_calling_format(self):
"""Ensure the 'char' argument is always a keyword-arg."""
with self.assertRaises(TypeError):
CharCount('url', 'a')

def test_char_length(self):
"""Ensure 'char' length is always 1."""
msg = 'CharCount must count exactly one char.'
for not_a_char in ['', 'no']:
with self.subTest(char=not_a_char):
with self.assertRaisesMessage(ValueError, msg):
CharCount('url', char=not_a_char)
24 changes: 24 additions & 0 deletions tests/routes/test_managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,27 @@ def test_descendant_destination_occupied(self):
self.assertEqual(route.url, original_url)
child.refresh_from_db()
self.assertEqual(child.url, original_url + 'child/')


class RouteManagerWithPathTest(TestCase):
"""Test Route.objects.with_level."""
def test_no_level_passed(self):
"""No level passed, so items are annotated, but no filter is applied."""
RouteFactory.create(url='/')
RouteFactory.create(url='/branch/')
RouteFactory.create(url='/branch/leaf/')
result = Route.objects.with_level().values_list('level', 'url')
expected = (
(0, '/'),
(1, '/branch/'),
(2, '/branch/leaf/'),
)
self.assertCountEqual(result, expected)

def test_level_passed(self):
"""When passing a level, the filter is automatically applied."""
RouteFactory.create(url='/') # Not in QS.
branch = RouteFactory.create(url='/branch/')
RouteFactory.create(url='/branch/leaf/') # Not in QS.
result = Route.objects.with_level(1)
self.assertCountEqual(result, [branch])
12 changes: 12 additions & 0 deletions tests/routes/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ def test_url_form_widget(self):
widget = Route._meta.get_field('url').formfield().widget
self.assertIsInstance(widget, forms.TextInput)

def test_level(self):
"""Route.level is based on the url field, and is zero-indexed."""
urls = (
(0, '/'),
(1, '/branch/'),
(2, '/branch/leaf/'),
)
for level, url in urls:
with self.subTest(url=url):
route = RouteFactory.build(url=url)
self.assertEqual(route.level, level)


class RouteUniquenessTest(TestCase):
"""Check uniqueness conditions on Route are enforced in the DB."""
Expand Down

0 comments on commit 516f26a

Please sign in to comment.