├── 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 |
--------------------------------------------------------------------------------