├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── django_group_by ├── __init__.py ├── group.py ├── iterable.py ├── mixin.py └── queryset.py ├── requirements └── dev.txt ├── runtests.py ├── setup.py ├── test_app ├── __init__.py ├── factories.py ├── models.py ├── query.py ├── settings.py ├── tests.py └── urls.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = django_group_by 4 | 5 | [report] 6 | exclude_lines = 7 | pragma: no cover 8 | raise AssertionError 9 | raise NotImplementedError 10 | 11 | [html] 12 | directory = coverage 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python bytecodes 2 | *.pyc 3 | 4 | # IDE files 5 | /.idea 6 | 7 | # Testing environment/results 8 | /.coverage 9 | /.tox 10 | /coverage 11 | /coverage.xml 12 | 13 | # Packaging/distribution 14 | /build 15 | /dist 16 | /*.egg-info 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Branch definition: only build master and PRs 2 | branches: 3 | only: 4 | master 5 | 6 | # Build definition; language, deps, scripts 7 | language: python 8 | python: 9 | - 2.7 10 | - 3.4 11 | - 3.5 12 | - 3.6 13 | install: 14 | - pip install -r requirements/dev.txt 15 | script: 16 | - if [[ $TRAVIS_PYTHON_VERSION == 2.7 ]]; then tox -e py27-dj18,py27-dj19,py27-dj110; fi 17 | - if [[ $TRAVIS_PYTHON_VERSION == 3.4 ]]; then tox -e py34-dj18,py34-dj19,py34-dj110; fi 18 | - if [[ $TRAVIS_PYTHON_VERSION == 3.5 ]]; then tox -e py35-dj18,py35-dj19,py35-dj110; fi 19 | - if [[ $TRAVIS_PYTHON_VERSION == 3.6 ]]; then tox -e py36-dj18,py36-dj19,py36-dj110; fi 20 | after_success: 21 | - pip install codecov 22 | - codecov 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Claudio Melendrez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include DESCRIPTION.rst 2 | 3 | # Include the test suite (FIXME: does not work yet) 4 | recursive-include tests * 5 | 6 | # If using Python 2.6 or less, then have to include package data, even though 7 | # it's already declared in setup.py 8 | # include data/*.dat -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Django GroupBy 3 | ============== 4 | 5 | .. image:: https://img.shields.io/github/license/kako-nawao/django-group-by.svg 6 | :target: http://www.opensource.org/licenses/MIT 7 | 8 | .. image:: https://img.shields.io/pypi/pyversions/django-group-by.svg 9 | :target: https://pypi.python.org/pypi/django-group-by 10 | .. image:: https://img.shields.io/pypi/v/django-group-by.svg 11 | :target: https://pypi.python.org/pypi/django-group-by 12 | 13 | .. image:: https://img.shields.io/travis/kako-nawao/django-group-by.svg 14 | :target: https://travis-ci.org/kako-nawao/django-group-by 15 | .. image:: https://img.shields.io/codecov/c/github/kako-nawao/django-group-by.svg 16 | :target: https://codecov.io/gh/kako-nawao/django-group-by 17 | 18 | This package provides a mixin for Django QuerySets that adds a method ``group_by`` that 19 | behaves mostly like the ``values`` method, but with one difference: its iterator does not 20 | return dictionaries, but a *model-like object with model instances for related values*. 21 | 22 | Installation 23 | ============ 24 | 25 | Install from PyPI:: 26 | 27 | pip install django-group-by 28 | 29 | Compatibility 30 | ~~~~~~~~~~~~~ 31 | 32 | This package is compatible with Django 1.8, 1.9 and 1.10, and Python versions 2.7, 3.4, 3.5 and 3.6. 33 | Probably others, but those 12 combinations are the ones against which we build (by Travis CI). 34 | 35 | 36 | Usage 37 | ===== 38 | 39 | Create a QuerySet subclass with the ``GroupByMixin`` to use in a model's manager:: 40 | 41 | # models.py 42 | 43 | from django.db import models 44 | from django.db.models.query import QuerySet 45 | from django_group_by import GroupByMixin 46 | 47 | class BookQuerySet(QuerySet, GroupByMixin): 48 | pass 49 | 50 | class Book(Model): 51 | objects = BookQuerySet.as_manager() 52 | 53 | title = models.CharField(max_length=100) 54 | author = models.ForeignKey('another_app.Author') 55 | publication_date = models.DateField() 56 | ... 57 | 58 | Then use it just like ``values``, and you'll get a similar query set:: 59 | 60 | >>> some_rows = Book.objects.group_by('title', 'author', 'author__nationality').distinct() 61 | >>> some_rows.count() 62 | 4 63 | 64 | The difference is that every row is not a dictionary but an AggregatedGroup instance, with only the grouped fields:: 65 | 66 | >>> row = some_rows[0] 67 | >>> row 68 | 69 | >>> row.title 70 | The Colour of Magic 71 | >>> row.publication_date 72 | *** AttributeError: 'AggregatedGroup' object has no attribute 'publication_date' 73 | 74 | But of course the main advantage is that you also get related model instances, as far as you want:: 75 | 76 | >>> row.author 77 | 78 | >>> row.author_nationality 79 | 80 | 81 | 82 | Related Model ID Only 83 | ~~~~~~~~~~~~~~~~~~~~~ 84 | 85 | The previous example shows a difference in behaviour from Django's ``values``: we're grouping by the foreign key 86 | *author*, but instead of getting only the ID we get the full instance. Why? Because it's more useful, and I 87 | think that getting *{'author': 5}* as a result is just weird. 88 | 89 | If you just want the ID you can specify it:: 90 | 91 | >>> some_rows = Book.objects.group_by('title', 'author_id', 'author__nationality_id').distinct() 92 | 93 | -------------------------------------------------------------------------------- /django_group_by/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains the package exports. 3 | """ 4 | from .mixin import GroupByMixin 5 | -------------------------------------------------------------------------------- /django_group_by/group.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import cached_property 2 | 3 | 4 | class AggregatedGroup(object): 5 | """ 6 | Generic object that constructs related objects like a Django model 7 | from queryset values() data. 8 | """ 9 | def __init__(self, model, row_values): 10 | self._model = model 11 | self._row_values = row_values 12 | self._set_values() 13 | 14 | def __repr__(self): 15 | return u'<{} for {}>'.format(self.__class__.__name__, 16 | self._model.__name__) 17 | 18 | @cached_property 19 | def _data(self): 20 | """ 21 | Cached data built from instance raw _values as a dictionary. 22 | """ 23 | d = {} 24 | 25 | # Iterate all keys and values 26 | for k, v in self._row_values.items(): 27 | # Split related model fields 28 | attrs = k.rsplit('__', 1) 29 | 30 | # Set value depending case 31 | if len(attrs) == 2: 32 | # Related model field, store nested 33 | fk, fn = attrs 34 | if fk not in d: 35 | d[fk] = {} 36 | d[fk][fn] = v 37 | 38 | else: 39 | # Own model field, store directly 40 | d[k] = v 41 | 42 | # Return (+cache) data 43 | return d 44 | 45 | def _set_values(self): 46 | """ 47 | Populate instance with given. 48 | """ 49 | # Iterate all keys and values in data 50 | for k, v in self._data.items(): 51 | # If it's a dict, process it (it's probably instance data) 52 | if isinstance(v, dict): 53 | try: 54 | # Get related model from field (follow path) 55 | rel_model = self._model 56 | for attr in k.split('__'): 57 | rel_model = getattr(rel_model, attr).field.related_model 58 | 59 | except AttributeError: 60 | # Not a model, maybe it is a dict field (?) 61 | pass 62 | 63 | else: 64 | # Model, first shorten field name 65 | k = k.replace('__', '_') 66 | 67 | # Now init instance if required (not if we got ID None) 68 | if 'id' in v and v['id'] is None: 69 | # This means we grouped by ID, if it's none then FK is None 70 | v = None 71 | 72 | else: 73 | # Either we have ID or we didn't group by ID, use instance 74 | v = rel_model(**v) 75 | 76 | # Set value 77 | setattr(self, k, v) 78 | -------------------------------------------------------------------------------- /django_group_by/iterable.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains the implementations for Django 1.9 and above, for which we 3 | need a customized ValuesIterable. 4 | """ 5 | from django.db.models.query import ValuesIterable 6 | 7 | from .group import AggregatedGroup 8 | 9 | 10 | class GroupByIterable(ValuesIterable): 11 | """ 12 | Modified ValuesIterable that yields AggregatedGroup instances instead 13 | of dictionaries, which resemble the queryset's model in that all foreign 14 | related field values become actual model instances. 15 | """ 16 | def __iter__(self): 17 | # Same as in django 18 | queryset = self.queryset 19 | query = queryset.query 20 | compiler = query.get_compiler(queryset.db) 21 | field_names = list(query.values_select) 22 | extra_names = list(query.extra_select) 23 | annotation_names = list(query.annotation_select) 24 | names = extra_names + field_names + annotation_names 25 | 26 | # Iterate results and yield AggregatedGroup instances 27 | for row in compiler.results_iter(): 28 | data = dict(zip(names, row)) 29 | obj = AggregatedGroup(queryset.model, data) 30 | yield obj 31 | 32 | 33 | class GroupByIterableMixinBase(object): 34 | """ 35 | Implementation of the group_by method using GroupByIterable. 36 | """ 37 | def group_by(self, *fields): 38 | """ 39 | Clone the queryset using GroupByQuerySet. 40 | 41 | :param fields: 42 | :return: 43 | """ 44 | fields = self._expand_group_by_fields(self.model, fields) 45 | clone = self._values(*fields) 46 | clone._iterable_class = GroupByIterable 47 | return clone 48 | -------------------------------------------------------------------------------- /django_group_by/mixin.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains the final mixin implementation, for whatever version 3 | of Django is present. 4 | """ 5 | from django.db.models import ForeignKey, ManyToManyField 6 | 7 | try: 8 | # Django 1.9+ 9 | from .iterable import GroupByIterableMixinBase as GroupByMixinBase 10 | 11 | except ImportError: 12 | # Django 1.8- 13 | from .queryset import GroupByQuerySetMixinBase as GroupByMixinBase 14 | 15 | 16 | class GroupByMixin(GroupByMixinBase): 17 | """ 18 | QuerySet mixin that adds a group_by() method, similar to values() but 19 | which returns AggregatedGroup instances when iterated instead of 20 | dictionaries. 21 | """ 22 | @classmethod 23 | def _expand_group_by_fields(cls, model, fields): 24 | """ 25 | Expand FK fields into all related object's fields to avoid future 26 | lookups. 27 | 28 | :param fields: fields to "group by" 29 | :return: expanded fields 30 | """ 31 | # Containers for resulting fields and related model fields 32 | res = [] 33 | related = {} 34 | 35 | # Add own fields and populate related fields 36 | for field_name in fields: 37 | if '__' in field_name: 38 | # Related model field: append to related model's fields 39 | fk_field_name, related_field = field_name.split('__', 1) 40 | if fk_field_name not in related: 41 | related[fk_field_name] = [related_field] 42 | else: 43 | related[fk_field_name].append(related_field) 44 | 45 | else: 46 | # Simple field, get the field instance 47 | model_field = model._meta.get_field(field_name) 48 | 49 | if isinstance(model_field, (ForeignKey, ManyToManyField)): 50 | # It's a related field, get model 51 | related_model = model_field.related_model 52 | 53 | # Append all its fields with the correct prefix 54 | res.extend('{}__{}'.format(field_name, f.column) 55 | for f in related_model._meta.fields) 56 | 57 | else: 58 | # It's a common field, just append it 59 | res.append(field_name) 60 | 61 | # Resolve all related fields 62 | for fk_field_name, field_names in related.items(): 63 | # Get field 64 | fk = model._meta.get_field(fk_field_name) 65 | 66 | # Get all fields for that related model 67 | related_fields = cls._expand_group_by_fields(fk.related_model, 68 | field_names) 69 | 70 | # Append them with the correct prefix 71 | res.extend('{}__{}'.format(fk_field_name, f) for f in related_fields) 72 | 73 | # Return all fields 74 | return res 75 | -------------------------------------------------------------------------------- /django_group_by/queryset.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains the implementations for Django 1.8 and below, for which we 3 | need a customized ValuesQuerySet. 4 | """ 5 | from django.db.models.query import ValuesQuerySet 6 | 7 | from .group import AggregatedGroup 8 | 9 | 10 | class GroupByQuerySet(ValuesQuerySet): 11 | """ 12 | Modified ValuesQuerySet that yields AggregatedGroup instances instead 13 | of dictionaries, which resemble the queryset's model in that all foreign 14 | related field values become actual model instances. 15 | """ 16 | def iterator(self): 17 | # Same as in django 18 | extra_names = list(self.query.extra_select) 19 | field_names = self.field_names 20 | annotation_names = list(self.query.annotation_select) 21 | names = extra_names + field_names + annotation_names 22 | 23 | # Iterate results and yield AggregatedGroup instances 24 | for row in self.query.get_compiler(self.db).results_iter(): 25 | data = dict(zip(names, row)) 26 | obj = AggregatedGroup(self.model, data) 27 | yield obj 28 | 29 | 30 | class GroupByQuerySetMixinBase(object): 31 | """ 32 | Implementation of the group_by method using GroupByQuerySet. 33 | """ 34 | def group_by(self, *fields): 35 | """ 36 | Clone the queryset using GroupByQuerySet. 37 | 38 | :param fields: 39 | :return: 40 | """ 41 | fields = self._expand_group_by_fields(self.model, fields) 42 | return self._clone(klass=GroupByQuerySet, setup=True, _fields=fields) 43 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | # Development requirements, needed to run tests 2 | 3 | coverage 4 | tox 5 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import django 4 | 5 | from django.conf import settings 6 | from django.test.utils import get_runner 7 | 8 | 9 | def run_tests(): 10 | os.environ['DJANGO_SETTINGS_MODULE'] = 'test_app.settings' 11 | django.setup() 12 | TestRunner = get_runner(settings) 13 | test_runner = TestRunner() 14 | failures = test_runner.run_tests(["test_app"]) 15 | sys.exit(bool(failures)) 16 | 17 | if __name__ == '__main__': 18 | run_tests() 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """A setuptools based setup module. 2 | See: 3 | https://packaging.python.org/en/latest/distributing.html 4 | https://github.com/pypa/sampleproject 5 | """ 6 | # Always prefer setuptools over distutils 7 | from setuptools import setup, find_packages 8 | # To use a consistent encoding 9 | from codecs import open 10 | from os import path 11 | 12 | 13 | here = path.abspath(path.dirname(__file__)) 14 | 15 | # Get the long description from the relevant file 16 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 17 | long_description = f.read() 18 | 19 | setup( 20 | name='django_group_by', 21 | 22 | # https://packaging.python.org/en/latest/single_source_version.html 23 | version='0.3.1', 24 | 25 | description='Group by arbitrary model fields', 26 | long_description=long_description, 27 | url='https://github.com/kako-nawao/django-group-by', 28 | author='Claudio Omar Melendrez Baeza', 29 | author_email='claudio.melendrez@gmail.com', 30 | license='MIT', 31 | 32 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 33 | classifiers=[ 34 | 'Development Status :: 4 - Beta', 35 | 'Environment :: Plugins', 36 | 'Framework :: Django :: 1.8', 37 | 'Framework :: Django :: 1.9', 38 | 'Framework :: Django :: 1.10', 39 | 'Intended Audience :: Developers', 40 | 'License :: OSI Approved :: MIT License', 41 | 'Operating System :: OS Independent', 42 | 'Programming Language :: Python :: 2.7', 43 | 'Programming Language :: Python :: 3.4', 44 | 'Programming Language :: Python :: 3.5', 45 | 'Programming Language :: Python :: 3.6', 46 | 'Topic :: Software Development', 47 | ], 48 | 49 | keywords='django grouping values models', 50 | packages=find_packages(exclude=['contrib', 'docs', 'test_app']), 51 | 52 | # https://packaging.python.org/en/latest/requirements.html 53 | install_requires=[], 54 | extras_require={}, 55 | package_data={}, 56 | 57 | # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files # noqa 58 | data_files=[], 59 | 60 | # To provide executable scripts, use entry points in preference to the 61 | # "scripts" keyword. Entry points provide cross-platform support and allow 62 | # pip to create the appropriate form of executable for the target platform. 63 | entry_points={}, 64 | 65 | # Django test suite 66 | test_suite='runtests.run_tests', 67 | ) 68 | -------------------------------------------------------------------------------- /test_app/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This package contains the test django app. 3 | """ 4 | -------------------------------------------------------------------------------- /test_app/factories.py: -------------------------------------------------------------------------------- 1 | 2 | from factory import SubFactory, DjangoModelFactory, Faker, LazyAttribute 3 | 4 | from .models import Author, Book, Genre, Nation 5 | 6 | 7 | class GenreFactory(DjangoModelFactory): 8 | 9 | class Meta(object): 10 | model = Genre 11 | django_get_or_create = ('name',) 12 | 13 | name = Faker('random_element', elements=('Adventure', 'Fantasy', 'Science-Fiction', 'Comedy',)) 14 | 15 | 16 | class NationalityFactory(DjangoModelFactory): 17 | 18 | class Meta(object): 19 | model = Nation 20 | django_get_or_create = ('name',) 21 | 22 | name = Faker('country') 23 | demonym = LazyAttribute(lambda obj: '{}an'.format(obj.name)) 24 | 25 | 26 | class AuthorFactory(DjangoModelFactory): 27 | 28 | class Meta(object): 29 | model = Author 30 | django_get_or_create = ('name', 'nationality',) 31 | 32 | name = Faker('name') 33 | nationality = SubFactory(NationalityFactory) 34 | 35 | 36 | class BookFactory(DjangoModelFactory): 37 | 38 | class Meta(object): 39 | model = Book 40 | 41 | title = Faker('sentence') 42 | publication_date = Faker('date_time') 43 | author = SubFactory(AuthorFactory) 44 | -------------------------------------------------------------------------------- /test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from .query import BookQuerySet 3 | 4 | 5 | class Book(models.Model): 6 | 7 | objects = BookQuerySet.as_manager() 8 | 9 | title = models.CharField(max_length=50) 10 | publication_date = models.DateTimeField() 11 | author = models.ForeignKey('Author') 12 | genres = models.ManyToManyField('Genre') 13 | 14 | 15 | class Author(models.Model): 16 | name = models.CharField(max_length=50) 17 | nationality = models.ForeignKey('Nation', null=True) 18 | 19 | 20 | class Genre(models.Model): 21 | name = models.CharField(max_length=50) 22 | 23 | 24 | class Nation(models.Model): 25 | name = models.CharField(max_length=50) 26 | demonym = models.CharField(max_length=50) 27 | -------------------------------------------------------------------------------- /test_app/query.py: -------------------------------------------------------------------------------- 1 | 2 | from django_group_by import GroupByMixin 3 | from django.db.models.query import QuerySet 4 | 5 | 6 | class BookQuerySet(QuerySet, GroupByMixin): 7 | pass 8 | -------------------------------------------------------------------------------- /test_app/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for djproject project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.8. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.8/ref/settings/ 11 | """ 12 | 13 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 14 | import os 15 | 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'c2c#(xrm2w69%88_+(582ud+m$^1jq!l%0x-o=)m%nzgpj7jy7' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = ( 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'test_app' 41 | ) 42 | 43 | MIDDLEWARE_CLASSES = ( 44 | 'django.contrib.sessions.middleware.SessionMiddleware', 45 | 'django.middleware.common.CommonMiddleware', 46 | 'django.middleware.csrf.CsrfViewMiddleware', 47 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 48 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 49 | 'django.contrib.messages.middleware.MessageMiddleware', 50 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 51 | 'django.middleware.security.SecurityMiddleware', 52 | ) 53 | 54 | ROOT_URLCONF = 'djproject.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [], 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | 'django.contrib.auth.context_processors.auth', 66 | 'django.contrib.messages.context_processors.messages', 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = 'djproject.wsgi.application' 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 77 | 78 | DATABASES = { 79 | 'default': { 80 | 'ENGINE': 'django.db.backends.sqlite3', 81 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 82 | } 83 | } 84 | 85 | 86 | # Internationalization 87 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 88 | 89 | LANGUAGE_CODE = 'en-us' 90 | 91 | TIME_ZONE = 'UTC' 92 | 93 | USE_I18N = True 94 | 95 | USE_L10N = True 96 | 97 | USE_TZ = True 98 | 99 | 100 | # Static files (CSS, JavaScript, Images) 101 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 102 | 103 | STATIC_URL = '/static/' 104 | -------------------------------------------------------------------------------- /test_app/tests.py: -------------------------------------------------------------------------------- 1 | 2 | try: 3 | from unittest.mock import patch, MagicMock 4 | except ImportError: 5 | from mock import patch, MagicMock 6 | 7 | from django.test import TestCase 8 | from django_group_by import GroupByMixin 9 | from django_group_by.group import AggregatedGroup 10 | 11 | from .models import Book, Author, Genre, Nation 12 | from .factories import AuthorFactory, BookFactory, GenreFactory 13 | 14 | 15 | class AggregatedGroupTest(TestCase): 16 | 17 | @patch.object(AggregatedGroup, '_set_values', MagicMock()) 18 | def test_data(self): 19 | # Simplest case, no nesting 20 | agg = AggregatedGroup(None, {'name': 'Peter', 'age': 56}) 21 | self.assertEqual(agg._data, {'name': 'Peter', 'age': 56}) 22 | 23 | # First level nesting 24 | agg = AggregatedGroup(None, {'name': 'Peter', 'friend__age': 56}) 25 | self.assertEqual(agg._data, {'name': 'Peter', 'friend': {'age': 56}}) 26 | 27 | # Deep nesting 28 | agg = AggregatedGroup(None, {'name': 'Peter', 29 | 'birth__city__name': 'Akropolis', 30 | 'birth__city__foundation__year': 1}) 31 | self.assertEqual(agg._data, { 32 | 'name': 'Peter', 33 | 'birth__city': {'name': 'Akropolis'}, 34 | 'birth__city__foundation': {'year': 1} 35 | }) 36 | 37 | def test_init(self): 38 | # Provide only title, has only that attr 39 | values = {'title': 'The Colour of Magic'} 40 | agg = AggregatedGroup(Book, values) 41 | self.assertEqual(agg.title, 'The Colour of Magic') 42 | with self.assertRaises(AttributeError): 43 | agg.publication_date 44 | with self.assertRaises(AttributeError): 45 | agg.author 46 | with self.assertRaises(AttributeError): 47 | agg.genres 48 | 49 | # FK None (all fields None, including ID), should not init model 50 | values.update({'author__id': None, 'author__name': None}) 51 | agg = AggregatedGroup(Book, values) 52 | self.assertEqual(agg.author, None) 53 | 54 | # Change to FK values, without ID 55 | values.pop('author__id') 56 | values.update({'author__name': 'Terry Pratchett', 'genres__name': 'Fantasy'}) 57 | agg = AggregatedGroup(Book, values) 58 | self.assertEqual(type(agg.author), Author) 59 | self.assertEqual(agg.author.name, 'Terry Pratchett') 60 | self.assertEqual(type(agg.genres), Genre) 61 | self.assertEqual(agg.genres.name, 'Fantasy') 62 | 63 | # Deep relations, make sure it's followed properly 64 | values.update({'author__nationality__name': 'Great Britain', 65 | 'author__nationality__demonym': 'British'}) 66 | agg = AggregatedGroup(Book, values) 67 | self.assertEqual(type(agg.author_nationality), Nation) 68 | self.assertEqual(agg.author_nationality.name, 'Great Britain') 69 | self.assertEqual(agg.author_nationality.demonym, 'British') 70 | 71 | 72 | class QuerySetTest(TestCase): 73 | 74 | def test_expand_group_by_field(self): 75 | # Own fields, not modified 76 | fields = GroupByMixin._expand_group_by_fields(Book, ['title', 'publication_date']) 77 | self.assertEqual(set(fields), {'title', 'publication_date'}) 78 | 79 | # Related model field, same 80 | fields = GroupByMixin._expand_group_by_fields(Book, ['author__id', 'genres__name']) 81 | self.assertEqual(set(fields), {'author__id', 'genres__name'}) 82 | 83 | # Related model, must expand 84 | fields = GroupByMixin._expand_group_by_fields(Book, ['author', 'genres']) 85 | self.assertEqual(set(fields), {'author__id', 'author__name', 'author__nationality_id', 86 | 'genres__id', 'genres__name'}) 87 | 88 | # Related model two levels deep, must expand all 89 | fields = GroupByMixin._expand_group_by_fields(Book, ['author', 'author__nationality', 'genres']) 90 | self.assertEqual(set(fields), {'author__id', 'author__name', 'author__nationality_id', 91 | 'author__nationality__id', 'author__nationality__name', 92 | 'author__nationality__demonym', 'genres__id', 'genres__name'}) 93 | 94 | def test_group_by(self): 95 | # Create two books by same author 96 | author1 = AuthorFactory.create(name='Terry Pratchett', nationality__name='Great Britain') 97 | book1 = BookFactory.create(author=author1, title='The Colour of Magic') 98 | book2 = BookFactory.create(author=author1, title='The Light Fantastic') 99 | 100 | # Create another book with same title, but different author 101 | author2 = AuthorFactory.create(nationality=None) 102 | BookFactory.create(author=author2, title='The Colour of Magic') 103 | 104 | # Add genres to books 1-2 105 | fantasy = GenreFactory.create(name='Fantasy') 106 | comedy = GenreFactory.create(name='Comedy') 107 | book1.genres.add(fantasy, comedy) 108 | book2.genres.add(fantasy, comedy) 109 | 110 | # Group by author, should return two with only author set 111 | res = Book.objects.group_by('author').order_by('author').distinct() 112 | self.assertEqual(res.count(), 2) 113 | for group in res: 114 | self.assertTrue(type(group.author), Author) 115 | with self.assertRaises(AttributeError): 116 | group.title 117 | with self.assertRaises(AttributeError): 118 | group.publication_date 119 | with self.assertRaises(AttributeError): 120 | group.genre 121 | 122 | # Check that they're authors 1 and 2 123 | tp, oth = res.all() 124 | self.assertEqual(tp.author, author1) 125 | self.assertEqual(oth.author, author2) 126 | 127 | # Group by title, still two with only title set 128 | res = Book.objects.group_by('title').distinct() 129 | self.assertEqual(res.count(), 2) 130 | for group in res: 131 | with self.assertRaises(AttributeError): 132 | group.author 133 | self.assertTrue(group.title.startswith('The')) 134 | with self.assertRaises(AttributeError): 135 | group.publication_date 136 | with self.assertRaises(AttributeError): 137 | group.genre 138 | 139 | # Group by nationality, only attr author_nationality is included 140 | # Note: invert order because None goes first 141 | res = Book.objects.group_by('author__nationality').order_by('-author__nationality').distinct() 142 | self.assertEqual(res.count(), 2) 143 | tp, oth = res.all() 144 | self.assertEqual(tp.author_nationality, author1.nationality) 145 | self.assertEqual(tp.author_nationality.name, 'Great Britain') 146 | with self.assertRaises(AttributeError): 147 | tp.title 148 | with self.assertRaises(AttributeError): 149 | tp.author 150 | self.assertEqual(oth.author_nationality, None) 151 | with self.assertRaises(AttributeError): 152 | oth.title 153 | with self.assertRaises(AttributeError): 154 | oth.author 155 | 156 | # Group by title+genre, should expand to 5 groups 157 | res = Book.objects.group_by('title', 'genres').order_by('genres__name', 'title').distinct() 158 | self.assertEqual(res.count(), 5) 159 | b1, b2, b3, b4, b5 = res.all() 160 | self.assertEqual(b1.genres, None) 161 | self.assertEqual(b1.title, 'The Colour of Magic') 162 | self.assertEqual(b2.genres, comedy) 163 | self.assertEqual(b2.title, 'The Colour of Magic') 164 | self.assertEqual(b3.genres, comedy) 165 | self.assertEqual(b3.title, 'The Light Fantastic') 166 | self.assertEqual(b4.genres, fantasy) 167 | self.assertEqual(b4.title, 'The Colour of Magic') 168 | self.assertEqual(b5.genres, fantasy) 169 | self.assertEqual(b5.title, 'The Light Fantastic') 170 | -------------------------------------------------------------------------------- /test_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib import admin 3 | 4 | urlpatterns = [ 5 | # Examples: 6 | # url(r'^$', 'djproject.views.home', name='home'), 7 | # url(r'^blog/', include('blog.urls')), 8 | 9 | url(r'^admin/', include(admin.site.urls)), 10 | ] 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{27,34,35,36}-dj{18,19,110} 3 | 4 | [testenv] 5 | deps = 6 | dj18: django==1.8 7 | dj19: django==1.9 8 | dj110: django==1.10 9 | py27: mock 10 | factory_boy 11 | coverage 12 | commands = 13 | coverage run -a --rcfile={toxinidir}/.coveragerc setup.py test 14 | --------------------------------------------------------------------------------