├── test └── example │ ├── __init__.py │ ├── people │ ├── __init__.py │ ├── models.py │ ├── tests.py │ └── fixtures │ │ └── testing.json │ ├── urls.py │ ├── manage.py │ └── settings.py ├── .gitignore ├── setup.py ├── UNLICENSE ├── djqmethod.py └── README.md /test/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/example/people/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *.pyo 4 | .DS_Store 5 | build 6 | dist 7 | MANIFEST 8 | test/example/*.sqlite3 9 | -------------------------------------------------------------------------------- /test/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | # Uncomment the next two lines to enable the admin: 4 | # from django.contrib import admin 5 | # admin.autodiscover() 6 | 7 | urlpatterns = patterns('', 8 | # Example: 9 | # (r'^example/', include('example.foo.urls')), 10 | 11 | # Uncomment the admin/doc line below and add 'django.contrib.admindocs' 12 | # to INSTALLED_APPS to enable admin documentation: 13 | # (r'^admin/doc/', include('django.contrib.admindocs.urls')), 14 | 15 | # Uncomment the next line to enable the admin: 16 | # (r'^admin/', include(admin.site.urls)), 17 | ) 18 | -------------------------------------------------------------------------------- /test/example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from django.core.management import execute_manager 3 | import sys 4 | sys.path.insert(0, '../../') 5 | try: 6 | import settings # Assumed to be in the same directory. 7 | except ImportError: 8 | import sys 9 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) 10 | sys.exit(1) 11 | 12 | if __name__ == "__main__": 13 | execute_manager(settings) 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import re 6 | 7 | from distutils.core import setup 8 | 9 | 10 | rel_file = lambda *args: os.path.join(os.path.dirname(os.path.abspath(__file__)), *args) 11 | 12 | def read_from(filename): 13 | fp = open(filename) 14 | try: 15 | return fp.read() 16 | finally: 17 | fp.close() 18 | 19 | def get_version(): 20 | data = read_from(rel_file('djqmethod.py')) 21 | return re.search(r"__version__ = '([^']+)'", data).group(1) 22 | 23 | 24 | setup( 25 | name = 'django-qmethod', 26 | version = get_version(), 27 | author = "Zachary Voase", 28 | author_email = 'z@zacharyvoase.com', 29 | url = 'http://github.com/zacharyvoase/django-qmethod', 30 | description = "Define methods on QuerySets without custom manager and QuerySet subclasses.", 31 | py_modules = ['djqmethod'], 32 | ) 33 | -------------------------------------------------------------------------------- /test/example/people/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.contrib.sites.models import Site 4 | from django.contrib.sites.managers import CurrentSiteManager 5 | from django.db import models 6 | from djqmethod import Manager, querymethod 7 | 8 | 9 | class SiteManager(CurrentSiteManager, Manager): 10 | pass 11 | 12 | 13 | class Group(models.Model): 14 | pass 15 | 16 | 17 | class Person(models.Model): 18 | 19 | group = models.ForeignKey(Group, related_name='people') 20 | age = models.PositiveIntegerField() 21 | site = models.ForeignKey(Site, related_name='people', null=True) 22 | 23 | objects = Manager() 24 | on_site = SiteManager() 25 | 26 | @querymethod 27 | def minors(query): 28 | return query.filter(age__lt=18) 29 | 30 | @querymethod 31 | def adults(query): 32 | return query.filter(age__gte=18) 33 | 34 | @querymethod 35 | def get_for_age(query, age): 36 | return query.get_or_create(age=age) 37 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /djqmethod.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __version__ = '0.0.3' 4 | 5 | from functools import partial 6 | 7 | from django.db import models 8 | 9 | 10 | def attr_error(obj, attr): 11 | return AttributeError("%r object has no attribute %r" % (str(type(obj)), attr)) 12 | 13 | 14 | class QueryMethod(object): 15 | 16 | # Make querymethod objects a little cheaper. 17 | __slots__ = ('function',) 18 | 19 | def __init__(self, function): 20 | self.function = function 21 | 22 | def for_query_set(self, qset): 23 | return partial(self.function, qset) 24 | 25 | querymethod = QueryMethod 26 | 27 | 28 | class QMethodLookupMixin(object): 29 | """Delegate missing attributes to querymethods on ``self.model``.""" 30 | 31 | def __getattr__(self, attr): 32 | # Using `object.__getattribute__` avoids infinite loops if the 'model' 33 | # attribute does not exist. 34 | qmethod = getattr(object.__getattribute__(self, 'model'), attr, None) 35 | if isinstance(qmethod, QueryMethod): 36 | return qmethod.for_query_set(self) 37 | raise attr_error(self, attr) 38 | 39 | 40 | class QMethodQuerySet(models.query.QuerySet, QMethodLookupMixin): 41 | pass 42 | 43 | 44 | class Manager(models.Manager, QMethodLookupMixin): 45 | 46 | # If this is the default manager for a model, use this manager class for 47 | # relations (i.e. `group.people`, see README for details). 48 | use_for_related_fields = True 49 | 50 | def get_query_set(self, *args, **kwargs): 51 | return QMethodQuerySet(model=self.model, using=self._db) 52 | -------------------------------------------------------------------------------- /test/example/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for example project. 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.append(os.path.dirname(os.path.abspath(__file__))) 7 | sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) 8 | 9 | 10 | DEBUG = True 11 | TEMPLATE_DEBUG = DEBUG 12 | 13 | ADMINS = ( 14 | ('Zachary Voase', 'z@zacharyvoase.com'), 15 | ) 16 | 17 | MANAGERS = ADMINS 18 | 19 | DATABASES = { 20 | 'default': { 21 | 'ENGINE': 'django.db.backends.sqlite3', 22 | 'NAME': 'dev.sqlite3', 23 | 'USER': '', 24 | 'PASSWORD': '', 25 | 'HOST': '', 26 | 'PORT': '', 27 | } 28 | } 29 | 30 | # Local time zone for this installation. Choices can be found here: 31 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 32 | # although not all choices may be available on all operating systems. 33 | # If running in a Windows environment this must be set to the same as your 34 | # system time zone. 35 | TIME_ZONE = 'Europe/London' 36 | 37 | # Language code for this installation. All choices can be found here: 38 | # http://www.i18nguy.com/unicode/language-identifiers.html 39 | LANGUAGE_CODE = 'en-gb' 40 | 41 | SITE_ID = 1 42 | 43 | # If you set this to False, Django will make some optimizations so as not 44 | # to load the internationalization machinery. 45 | USE_I18N = True 46 | 47 | # Absolute path to the directory that holds media. 48 | # Example: "/home/media/media.lawrence.com/" 49 | MEDIA_ROOT = '' 50 | 51 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 52 | # trailing slash if there is a path component (optional in other cases). 53 | # Examples: "http://media.lawrence.com", "http://example.com/media/" 54 | MEDIA_URL = '' 55 | 56 | # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a 57 | # trailing slash. 58 | # Examples: "http://foo.com/media/", "/media/". 59 | ADMIN_MEDIA_PREFIX = '/media/' 60 | 61 | # Make this unique, and don't share it with anybody. 62 | SECRET_KEY = '8@+k3lm3=s+ml6_*(cnpbg1w=6k9xpk5f=irs+&j4_6i=62fy^' 63 | 64 | # List of callables that know how to import templates from various sources. 65 | TEMPLATE_LOADERS = ( 66 | 'django.template.loaders.filesystem.load_template_source', 67 | 'django.template.loaders.app_directories.load_template_source', 68 | # 'django.template.loaders.eggs.load_template_source', 69 | ) 70 | 71 | MIDDLEWARE_CLASSES = ( 72 | 'django.middleware.common.CommonMiddleware', 73 | ) 74 | 75 | ROOT_URLCONF = 'example.urls' 76 | 77 | TEMPLATE_DIRS = ( 78 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 79 | # Always use forward slashes, even on Windows. 80 | # Don't forget to use absolute paths, not relative paths. 81 | ) 82 | 83 | INSTALLED_APPS = ( 84 | 'django.contrib.sites', 85 | 'people', 86 | ) 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `django-qmethod` 2 | 3 | `django-qmethod` is a library for easily defining operations on collections of 4 | Django models (that is, QuerySets and Managers). 5 | 6 | One day, I hope something like this is included in Django core. 7 | 8 | 9 | ## Usage 10 | 11 | Basic usage is as follows: 12 | 13 | ```python 14 | import cPickle as pickle 15 | from django.db import models 16 | from djqmethod import Manager, querymethod 17 | 18 | class Group(models.Model): 19 | pass 20 | 21 | class Person(models.Model): 22 | GENDERS = dict(m='Male', f='Female', u='Unspecified').items() 23 | 24 | group = models.ForeignKey(Group, related_name='people') 25 | gender = models.CharField(max_length=1, choices=GENDERS) 26 | age = models.PositiveIntegerField() 27 | 28 | # Note: you need to create an explicit manager here. 29 | objects = Manager() 30 | 31 | @querymethod 32 | def minors(query): 33 | return query.filter(age__lt=18) 34 | 35 | @querymethod 36 | def adults(query): 37 | return query.filter(age__gte=18) 38 | 39 | # The `minors()` and `adults()` methods will be available on the manager: 40 | assert isinstance(Person.objects.minors(), models.query.QuerySet) 41 | 42 | # They'll be available on subsequent querysets: 43 | assert isinstance(Person.objects.filter(gender='m').minors(), 44 | models.query.QuerySet) 45 | 46 | # They'll also be available on relations, if you use the djqmethod.Manager as 47 | # the default manager for the related model. 48 | group = Group.objects.all()[0] 49 | assert isinstance(group.people.minors(), models.query.QuerySet) 50 | 51 | # The QuerySets produced are totally pickle-safe: 52 | assert isinstance(pickle.loads(pickle.dumps(Person.objects.minors())), 53 | models.query.QuerySet) 54 | ``` 55 | 56 | A test project is located in `test/example/`; consult this for a more 57 | comprehensive example. 58 | 59 | 60 | ## Installation 61 | 62 | pip install django-qmethod 63 | 64 | 65 | ## (Un)license 66 | 67 | This is free and unencumbered software released into the public domain. 68 | 69 | Anyone is free to copy, modify, publish, use, compile, sell, or distribute this 70 | software, either in source code form or as a compiled binary, for any purpose, 71 | commercial or non-commercial, and by any means. 72 | 73 | In jurisdictions that recognize copyright laws, the author or authors of this 74 | software dedicate any and all copyright interest in the software to the public 75 | domain. We make this dedication for the benefit of the public at large and to 76 | the detriment of our heirs and successors. We intend this dedication to be an 77 | overt act of relinquishment in perpetuity of all present and future rights to 78 | this software under copyright law. 79 | 80 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 81 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 82 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE 83 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 84 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 85 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 86 | 87 | For more information, please refer to 88 | -------------------------------------------------------------------------------- /test/example/people/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import cPickle as pickle 4 | 5 | from django.db import IntegrityError, models 6 | from django.test import TestCase 7 | 8 | from people.models import Group, Person 9 | 10 | 11 | class SimpleTest(TestCase): 12 | 13 | fixtures = ['testing'] 14 | 15 | def test_manager(self): 16 | self.failUnless(isinstance( 17 | Person.objects.minors(), 18 | models.query.QuerySet)) 19 | 20 | self.failUnlessEqual( 21 | pks(Person.objects.minors()), 22 | pks(Person.objects.filter(age__lt=18))) 23 | 24 | self.failUnless(isinstance( 25 | Person.objects.adults(), 26 | models.query.QuerySet)) 27 | 28 | self.failUnlessEqual( 29 | pks(Person.objects.adults()), 30 | pks(Person.objects.filter(age__gte=18))) 31 | 32 | def test_qset(self): 33 | self.failUnless(isinstance( 34 | Person.objects.all().minors(), 35 | models.query.QuerySet)) 36 | 37 | self.failUnlessEqual( 38 | pks(Person.objects.all().minors()), 39 | pks(Person.objects.filter(age__lt=18))) 40 | 41 | self.failUnless(isinstance( 42 | Person.objects.all().adults(), 43 | models.query.QuerySet)) 44 | 45 | self.failUnlessEqual( 46 | pks(Person.objects.all().adults()), 47 | pks(Person.objects.filter(age__gte=18))) 48 | 49 | 50 | class RelationTest(TestCase): 51 | 52 | fixtures = ['testing'] 53 | 54 | def test_querying(self): 55 | for group in Group.objects.all(): 56 | self.failUnless(isinstance( 57 | group.people.all(), 58 | models.query.QuerySet)) 59 | 60 | self.failUnless(isinstance( 61 | group.people.minors(), 62 | models.query.QuerySet)) 63 | 64 | self.failUnlessEqual( 65 | pks(group.people.minors()), 66 | pks(group.people.filter(age__lt=18))) 67 | 68 | self.failUnless(isinstance( 69 | group.people.adults(), 70 | models.query.QuerySet)) 71 | 72 | self.failUnlessEqual( 73 | pks(group.people.adults()), 74 | pks(group.people.filter(age__gte=18))) 75 | 76 | def test_creation(self): 77 | group = Group.objects.get(pk=1) 78 | person = group.people.create(age=32) 79 | assert person.group_id == group.pk 80 | 81 | def test_qmethods_get_the_original_object(self): 82 | group = Group.objects.get(pk=1) 83 | person, created = group.people.get_for_age(72) 84 | assert created 85 | assert person.age == 72 86 | assert person.group_id == group.pk 87 | 88 | # group_id cannot be NULL. 89 | with self.assertRaises(IntegrityError) as cm: 90 | Person.objects.get_for_age(22) 91 | assert "group_id" in cm.exception.message 92 | assert "NULL" in cm.exception.message 93 | 94 | 95 | class PickleTest(TestCase): 96 | 97 | fixtures = ['testing'] 98 | 99 | def assert_pickles(self, qset): 100 | self.failUnlessEqual(pks(qset), 101 | pks(pickle.loads(pickle.dumps(qset)))) 102 | 103 | def test(self): 104 | self.assert_pickles(Person.objects.minors()) 105 | self.assert_pickles(Person.objects.all().minors()) 106 | self.assert_pickles(Person.objects.minors().all()) 107 | self.assert_pickles(Group.objects.all()) 108 | self.assert_pickles(Group.objects.all()[0].people.all()) 109 | self.assert_pickles(Group.objects.all()[0].people.minors()) 110 | 111 | 112 | def pks(qset): 113 | """Return the list of primary keys for the results of a QuerySet.""" 114 | 115 | return sorted(tuple(qset.values_list('pk', flat=True))) 116 | -------------------------------------------------------------------------------- /test/example/people/fixtures/testing.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "fields": {}, 4 | "model": "people.group", 5 | "pk": 1 6 | }, 7 | { 8 | "fields": {}, 9 | "model": "people.group", 10 | "pk": 2 11 | }, 12 | { 13 | "fields": {}, 14 | "model": "people.group", 15 | "pk": 3 16 | }, 17 | { 18 | "fields": { 19 | "age": 17, 20 | "group": 1 21 | }, 22 | "model": "people.person", 23 | "pk": 1 24 | }, 25 | { 26 | "fields": { 27 | "age": 17, 28 | "group": 1 29 | }, 30 | "model": "people.person", 31 | "pk": 2 32 | }, 33 | { 34 | "fields": { 35 | "age": 17, 36 | "group": 1 37 | }, 38 | "model": "people.person", 39 | "pk": 3 40 | }, 41 | { 42 | "fields": { 43 | "age": 18, 44 | "group": 1 45 | }, 46 | "model": "people.person", 47 | "pk": 4 48 | }, 49 | { 50 | "fields": { 51 | "age": 18, 52 | "group": 1 53 | }, 54 | "model": "people.person", 55 | "pk": 5 56 | }, 57 | { 58 | "fields": { 59 | "age": 18, 60 | "group": 1 61 | }, 62 | "model": "people.person", 63 | "pk": 6 64 | }, 65 | { 66 | "fields": { 67 | "age": 19, 68 | "group": 1 69 | }, 70 | "model": "people.person", 71 | "pk": 7 72 | }, 73 | { 74 | "fields": { 75 | "age": 19, 76 | "group": 1 77 | }, 78 | "model": "people.person", 79 | "pk": 8 80 | }, 81 | { 82 | "fields": { 83 | "age": 19, 84 | "group": 1 85 | }, 86 | "model": "people.person", 87 | "pk": 9 88 | }, 89 | { 90 | "fields": { 91 | "age": 17, 92 | "group": 2 93 | }, 94 | "model": "people.person", 95 | "pk": 10 96 | }, 97 | { 98 | "fields": { 99 | "age": 17, 100 | "group": 2 101 | }, 102 | "model": "people.person", 103 | "pk": 11 104 | }, 105 | { 106 | "fields": { 107 | "age": 17, 108 | "group": 2 109 | }, 110 | "model": "people.person", 111 | "pk": 12 112 | }, 113 | { 114 | "fields": { 115 | "age": 18, 116 | "group": 2 117 | }, 118 | "model": "people.person", 119 | "pk": 13 120 | }, 121 | { 122 | "fields": { 123 | "age": 18, 124 | "group": 2 125 | }, 126 | "model": "people.person", 127 | "pk": 14 128 | }, 129 | { 130 | "fields": { 131 | "age": 18, 132 | "group": 2 133 | }, 134 | "model": "people.person", 135 | "pk": 15 136 | }, 137 | { 138 | "fields": { 139 | "age": 19, 140 | "group": 2 141 | }, 142 | "model": "people.person", 143 | "pk": 16 144 | }, 145 | { 146 | "fields": { 147 | "age": 19, 148 | "group": 2 149 | }, 150 | "model": "people.person", 151 | "pk": 17 152 | }, 153 | { 154 | "fields": { 155 | "age": 19, 156 | "group": 2 157 | }, 158 | "model": "people.person", 159 | "pk": 18 160 | }, 161 | { 162 | "fields": { 163 | "age": 17, 164 | "group": 3 165 | }, 166 | "model": "people.person", 167 | "pk": 19 168 | }, 169 | { 170 | "fields": { 171 | "age": 17, 172 | "group": 3 173 | }, 174 | "model": "people.person", 175 | "pk": 20 176 | }, 177 | { 178 | "fields": { 179 | "age": 17, 180 | "group": 3 181 | }, 182 | "model": "people.person", 183 | "pk": 21 184 | }, 185 | { 186 | "fields": { 187 | "age": 18, 188 | "group": 3 189 | }, 190 | "model": "people.person", 191 | "pk": 22 192 | }, 193 | { 194 | "fields": { 195 | "age": 18, 196 | "group": 3 197 | }, 198 | "model": "people.person", 199 | "pk": 23 200 | }, 201 | { 202 | "fields": { 203 | "age": 18, 204 | "group": 3 205 | }, 206 | "model": "people.person", 207 | "pk": 24 208 | }, 209 | { 210 | "fields": { 211 | "age": 19, 212 | "group": 3 213 | }, 214 | "model": "people.person", 215 | "pk": 25 216 | }, 217 | { 218 | "fields": { 219 | "age": 19, 220 | "group": 3 221 | }, 222 | "model": "people.person", 223 | "pk": 26 224 | }, 225 | { 226 | "fields": { 227 | "age": 19, 228 | "group": 3 229 | }, 230 | "model": "people.person", 231 | "pk": 27 232 | } 233 | ] 234 | --------------------------------------------------------------------------------