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

Add easy way to annotate Route QS with "level" #131

Merged
merged 3 commits into from
Jan 21, 2018
Merged

Conversation

meshy
Copy link
Owner

@meshy meshy commented Jan 18, 2018

In order to create a navigation on a test project, I have had to add the following code:

# context_processors.py

from django.db.models.expressions import Func
from pages.models import Page


class CharCount(Func):
    template = "CHAR_LENGTH(%(field)s) - CHAR_LENGTH(REPLACE(%(field)s, '%(char)s', ''))"

    def __init__(self, field, *, char, **extra):
        if len(char) != 1:
            raise ValueError('CharCount can only count individual chars.')
        extra['char'] = char
        super().__init__(field, **extra)


def navigation_pages(request):
    nav_pages = (
        Page.objects
        .annotate(level=CharCount('url', char='/'))
        .filter(level=2)
    )
    return {'navigation_pages': nav_pages}

I think it would be nice to have this level-annotation functionality built into RouteManager as an optional addition. Something like Route.objects.with_levels().filter(level=2) strikes me as nicer.


Update. Okay, I've made this a PR now. It doesn't have exactly the same interface as above. See comment below for new syntax.

@meshy
Copy link
Owner Author

meshy commented Jan 18, 2018

This allows for Route.objects.with_level(2), or Route.objects.with_level() if no filtering is required.

@meshy
Copy link
Owner Author

meshy commented Jan 18, 2018

I might as well add a @property to make the level always accessible...

"""
Annotate the queryset with the level of each item.

The level reflects the number of forward slashes in the path.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The level is one-indexed; it reflects the number of forward slashes in the path.

expected = (
'SELECT "routes_route"."id", ' +
'CHAR_LENGTH("routes_route"."url") - ' +
'CHAR_LENGTH(REPLACE("routes_route"."url", \'/\', \'\')) AS "level" ' +
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps use a triple quoted string to avoid the need to escape any quotes.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good plan, thanks.

CharCount('url', char='no')

with self.assertRaisesMessage(ValueError, msg):
CharCount('url', char='')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps this is a place to use subTest.

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be cleaner to use a single character here. so we're violating only one assumption of the api.

result = Route.objects.with_level().order_by('level')
self.assertEqual(result[0].level, 1)
self.assertEqual(result[1].level, 2)
self.assertEqual(result[2].level, 3)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps:

self.assertSequenceEqual(result.values_list('level', flat=True), [1, 2, 3])

'CHAR_LENGTH(REPLACE("routes_route"."url", \'/\', \'\')) AS "level" ' +
'FROM "routes_route"'
)
self.assertEqual(str(qs.query), expected)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

str(qs.query) isn't perfect, since it does the interpolation of any params in python, not in sql. It might be worth using connection.queries instead.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the heads up -- I'll give that a shot.

@meshy
Copy link
Owner Author

meshy commented Jan 21, 2018

@Ian-Foote thanks for the review. I've rebased with your (slightly altered) changes, and added a Route.level property too.

How does that look?

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure the annotation is used instead of the property if it exists? Or does that not matter?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not that the annotation is used, exactly, it's that when an annotated object is instantiated, the attribute is set. Without this, and with the @property, a "can't set attribute" error is raised.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see - the annotation is used for sql-level operations like filtering, but the descriptor whenever we're in python.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly :)


def with_level(self, level=None):
"""
Annotate the queryset with the (1-indexed) level of each item.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see why you chose 1-indexed, but I'm not sure that's the best api. It seems more natural to me to care about how many parts the url has rather than the number of / separators.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I wasn't sure about it myself. I went for this simply because the implementation was marginally easier -- you'll probably not be the last person to think it's strange -- I'll look into changing it.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"""
Annotate the queryset with the (1-indexed) level of each item.

The level reflects the number of forward slashes in the path.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we guarantee elsewhere that a url always starts and ends with a /?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's a strange necessity of conman -- it makes other similar operations easy.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the whole, it shouldn't cause too many issues, I hope.

@meshy
Copy link
Owner Author

meshy commented Jan 21, 2018

Thanks :)

@meshy meshy merged commit 516f26a into master Jan 21, 2018
@meshy meshy deleted the level-annotation branch January 21, 2018 22:00
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants