Named Scopes for Django

There are really only a handful of features I miss about Ruby on Rails, having switched over to using only Django about a year ago. One of the biggest among these has been named scopes.

What a named scope does in Rails, in Django terms, is allow you to name a given query and use it as a base, or “scope,” for further queries. In other words, your narrowing down the results upon which additional queries that you run will be executed.

It’s pretty much the same idea behind modifying initial Manager QuerySets as outlined in the Django documentation, with one major difference: Scopes can be chained. Chaining is the feature I had long coveted, and I had been banging my head around for a solution for the last few months.

The other day while I was poking around the internals a bit though, a decent idea came to me. I realized it would be quite simple for me to extend the default Manager class with an attribute where I could store QuerySet methods I wanted to execute as part of a scope, and then overwrite get_query_set() with a hook that ran these methods before executing the rest of the query.

This turned out to be a nice strategy, and with the addition of some decorators the interface itself turned out to be extraordinarily simple.

Anyhow, here’s a walkthrough of how my alpha “scopable” module would work, with example code.

Let’s start with a simplistic blog post model:

class Post(models.Model):
    title           = models.CharField('title', max_length=200)
    author          = models.ForeignKey(User)
    body            = models.TextField('author')
    publish_at      = models.DateTimeField('published at', default=datetime.now)
    is_draft        = models.BooleanField('is draft', default=False)
    is_deleted      = models.BooleanField('deleted', default=False)
    objects         = PostManager()

That’s enough to provide for some good example scopes. Now, here’s what PostManager might look like, with explanation to follow:

from scopable import ScopableManager, scope

class PostManager(ScopableManager):
    @property
    @scope
    def active(self):
       return self.filter(is_deleted=False, user__is_active=True)

    @property
    @scope
    def published(self):
       return self.filter(is_draft=False)

    @property
    @scope
    def public(self):
       return self.active.published

So as you can see, the ScopableManager class is very easy to use; you just add the @scope decorator to any method that you want to turn into a scope. (Note that I added the @property decorator as a stylistic preference; it’s not required.)

To use the code, you just have to be sure to call a QuerySet method after it, e.g.:

Post.objects.active.all()
Post.objects.published.all()
Post.objects.active.published.filter(publish_at__lte=datetime.now())
Post.objects.public.all()

The only difference between a scoped and unscoped method is that execution of any QuerySet methods defined in a scoped method are deferred until a QuerySet method is called (e.g. Post.objects.public.all()), and scoped methods return an instance of your manager back, so they can be chained. As you can see from the public method, scoped methods can be nested as well.

Why is this awesome? Well the main advantage from my perspective is that it allows your business rules to be defined in one and only one discrete place.

So if, for example, I added a new business rule that blog posts shouldn’t be considered published unless they were not drafts and the publish date had already passed, I could just add that rule to the published method:

    @property
    @scope
    def published(self):
       return self.filter(publish_at__lte=datetime.now(), is_draft=False)

This means that any other queries that were built on published, such as public, are now updated with the new business logic we added to published.

The implementation of all of this is pretty straightforward. The main complication comes from implementing scope as a decorator and trying to elegantly capture all of the QuerySet methods used in a scoped Manager method.

This is still alpha code, and as of now it doesn’t handle custom Manager methods that are not scoped. I mostly just wanted to get this idea out there before I do any more work:

"""
scopable.py - A module for providing Rails-like named scopes to Django model Managers
Verson 0.1
"""

from functools import wraps
from django.db import models

class DummyManager(object):
    def __init__(self, actual_mgr, calls=[]):
        super(DummyManager, self).__init__()
        self.actual_mgr = actual_mgr
        self.calls = calls

    def __call__(self, *args, **kwargs):
        last_call = self.calls[-1]
        last_call["is_property"] = False
        last_call["args"] = args
        last_call["kwargs"] = kwargs
        clone = DummyManager(self.actual_mgr, self.calls)
        self.calls = []
        return clone

    def __getattribute__(self, name):
        if name in ["calls", "actual_mgr"]:
            return super(DummyManager, self).__getattribute__(name)

        # attr_type, args, and kwargs are set here by default in case the attr never gets called
        call = { "attr": name, "is_property": True, "args": [], "kwargs": {} }

        # if this is a default manager method
        if hasattr(models.Manager, name):
            call["definition"] = "default"

        # else if this is a custom manager method
        elif hasattr(self.actual_mgr.__class__, name):
            call["definition"] = "custom"

        # if it can't be found in those two classes, throw an error
        else:
            raise AttributeError("'%s' object has no attribute '%s'" % (self.actual_mgr.__class__.__name__, name))

        self.calls.append(call)
        clone = DummyManager(self.actual_mgr, self.calls)
        self.calls = []
        return clone

def scope(fn):
    @wraps(fn)
    def scopified(*args, **kwargs):
        args_list = list(args)
        actual_mgr = args_list.pop(0)
        dummy = DummyManager(actual_mgr, [])
        args_list.insert(0, dummy)
        deferred_calls = get_deferred_calls(actual_mgr, fn, args_list, kwargs)
        a = actual_mgr.__class__(deferred_calls=deferred_calls)
        a.contribute_to_class(actual_mgr.model, actual_mgr.model.__name__)
        return a
    return scopified

def get_deferred_calls(mgr, fn, args, kwargs):
    deferred_calls = []
    result = fn(*args, **kwargs)
    if result.calls:
        for call in result.calls:
            if call["definition"] == "default":
                deferred_calls.append(call)
            elif call["definition"] == "custom":
                next_result = getattr(mgr, call["attr"])
                if not call["is_property"]:
                    next_result = next_result(*call["args"], **call["kwargs"])
                deferred_calls = deferred_calls + next_result.deferred_calls
            else:
                raise AssertionError("Something went wrong.")
    return deferred_calls

class ScopableManager(models.Manager):

    def __init__(self, deferred_calls=[]):
        super(ScopableManager, self).__init__()
        self.scopes = []
        self.deferred_calls = deferred_calls

    def get_query_set(self):
        qs = super(ScopableManager, self).get_query_set()
        if self.deferred_calls:
            for deferred_call in self.deferred_calls:
                next_qs = getattr(qs, deferred_call["attr"])
                if not deferred_call["is_property"]:
                    next_qs = next_qs(*deferred_call["args"], **deferred_call["kwargs"])
                qs = next_qs
        return qs

Please let me know in the comments if you have feedback, suggestions, bugs, grave warnings, etc.

5 comments

  1. Alex Gaynor

    This is ultimately very similar to something I wrote about a few months ago: http://lazypython.blogspot.com/2009/01/building-magic-manager.html . In the end I find the best solution is just to subclass QuerySet and proxy the methods onto my Manager by hand however.

  2. Carl Meyer

    Interesting work, but seems like it’s partially based on a misunderstanding of how Django querysets work. Django querysets are always lazy (nothing is executed until the final queryset is sliced or iterated), so all the work to “defer” calls is pointless. In that sense this gives you no advantage over plain manager methods.

    It does give you the ability to chain manager methods, which is nice. But as Alex said, the simpler solution to that is to move the methods onto a custom QuerySet class which your custom Manager returns, and then also proxy the methods onto your Manager (or use his “magic manager” to keep things DRY). Though I can’t help but wonder if the fact that you have to do this at all is a design flaw in Django; seems to me like there isn’t much good reason for Manager and QuerySet to be separate classes in the first place. I wonder what it would look like to unite them…

  3. Jim Dalton

    @Alex -Thanks for sharing that; I like what you’ve done there. I did not understand your last paragraph though (in your blog post). In what sense are you repeating yourself? It seems like your solution allows you to define your manager methods in a custom manager class and then they become part of the Queryset. Where do you have to define them again?

    Also, can you nest manager methods using your approach?

    @Carl – Yeah, the deferring aspect of this is really non-essential. For me, my only real goals were to be able to chain and nest custom manager methods. I actually agree with you; Alex’s method appears to be simpler and if it works cleanly it’s probably better than mine. (My solution, by the way, is a lot “simpler” than it looks. Most of the code amounts to an elegant hack to facilitate it via the @scope decorator.)

    And by the way, I agree with you about Django. My instinct is that custom manager methods should be chainable just like the default QuerySet methods. Not only is this not yet a feature of Django, but I couldn’t even find much chatter about it, so I’m not holding my breath.

    Well, I’ll have to give Alex’s idea a try to see if it does the trick for me. Thanks to both of you for the feedback.

  4. Alex Gaynor

    Yep, they can be nested since the QuerySet clones it’s current class when you chain methods. I don’t believe mine will work with propeties like yours does though.

  5. Empty

    Nice work. While working on Django-SQLAlchemy I also wondered why QuerySet is separate from the Manager and I believe at that time I figured out the rationale for that, but it escapes me at the moment. I like having a single manager with all the business logic scoping rules in one place.

Post a comment

Contact Info
will not be published

include http://

You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>