├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── django_orm_sugar.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | - "3.5" 6 | - "3.8" 7 | # TODO: fix doctest freezing in Python 3.5 8 | 9 | # command to install dependencies 10 | install: 11 | - pip install -q Django==$DJANGO_VERSION 12 | - python setup.py -q install 13 | 14 | # setup different django versions 15 | env: 16 | - DJANGO_VERSION=1.8 17 | - DJANGO_VERSION=3.2.8 18 | - DJANGO_VERSION=1.10 19 | - DJANGO_VERSION=1.10.1 20 | - DJANGO_VERSION=1.10.2 21 | 22 | # command to run tests 23 | script: 24 | python django_orm_sugar.py 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Alexey Zankevich 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django ORM Sugar [![Build Status](https://travis-ci.org/Nepherhotep/django-orm-sugar.svg?branch=master)](https://travis-ci.org/Nepherhotep/django-orm-sugar) 2 | Sugar library to simplify Django querying 3 | 4 | ## Installation 5 | 6 | ``` 7 | pip install django-orm-sugar 8 | ``` 9 | 10 | ## Overview 11 | 12 | The updated Q object replaces calls like 13 | ```python 14 | SomeModel.objects.filter(user__profile__common_bucket__seq_count__gte=7) 15 | ``` 16 | 17 | With more pythonic syntax 18 | ```python 19 | from django_orm_sugar import Q 20 | 21 | SomeModel.objects.filter(Q.user.profile.common_bucket.seq_count >= 7) 22 | ``` 23 | 24 | It gets easy to follow DRY principles when working with long query paths 25 | ```python 26 | from django_orm_sugar import Q 27 | 28 | # saving reference for QFactory 29 | seq_count = Q.user.profile.common_bucket.seq_count 30 | 31 | # using it multiple times - in filter and order_by calls 32 | SomeModel.objects.filter(seq_count >= 7).order_by(seq_count.get_path()) 33 | ``` 34 | 35 | It is still possible to create Q objects in the old style way: 36 | ```python 37 | q = Q(user__profile__common_bucket__seq_count=1) 38 | ``` 39 | 40 | ## Queries 41 | ### Comparison operators 42 | General comparison operators generate related Q objects: 43 | ```python 44 | >>> Q.user.username == 'Bender Rodriguez' 45 | Q(user__username='Bender Rodriguez') 46 | 47 | >>> Q.user.age > 7 48 | Q(user__age__gt=7) 49 | 50 | >>> Q.user.age >= 7 51 | Q(user__age__gte=7) 52 | 53 | >>> Q.user.age < 7 54 | Q(user__age__lt=7) 55 | 56 | >>> Q.user.age <= 7 57 | Q(user__age__lte=7) 58 | ``` 59 | 60 | ### Filter by null (or not-null) fields 61 | ```python 62 | >>> Q.user.favorite_movie.is_null() 63 | Q(user__favorite_movie__isnull=True) 64 | 65 | >>> Q.user.favorite_movie.is_null(False) 66 | Q(user__favorite_movie__isnull=False) 67 | ``` 68 | 69 | ### Filter by fields matching a given list 70 | ```python 71 | >>> Q.user.id.in_list([1, 2, 3]) 72 | Q(user__id__in=[1, 2, 3]) 73 | ``` 74 | 75 | ### Common Django filter shortcuts 76 | ```python 77 | >>> Q.user.username.iexact('Bender Rodriguez') 78 | Q(user__username__iexact='Bender Rodriguez') 79 | 80 | >>> Q.user.username.exact('Bender Rodriguez') 81 | Q(user__username__exact='Bender Rodriguez') 82 | 83 | >>> Q.user.username.contains('Rodriguez') 84 | Q(user__username__contains='Rodriguez') 85 | 86 | >>> Q.user.username.icontains('Rodriguez') 87 | Q(user__username__icontains='Rodriguez') 88 | ``` 89 | 90 | ### Index and slices queries 91 | Used by PostgreSQL ArrayField or JSONField 92 | ```python 93 | >>> Q.data.owner.other_pets[0].name='Fishy' 94 | Q(data__owner__other_pets__0__name='Fishy') 95 | 96 | >>> Q.tags[0] == 'thoughts' 97 | Q(tags__0='thoughts') 98 | 99 | >>> Q.tags[0:2].contains(['thoughts']) 100 | Q(tags__0_2__contains=['thoughts']) 101 | ``` 102 | 103 | ### Passing multiple arguments 104 | It's possible to pass multiple arguments, they will be converted to tuple 105 | in final expression. 106 | ```python 107 | >>> Q.user.create_datetime.range(d1, d2) 108 | Q(user__create_datetime__range=(d1, d2)) 109 | ``` 110 | However, passing them as tuple is also allowed as a single argument will 111 | be passed to lookup expression without any modifications. 112 | 113 | ### Get query path as string with underscores 114 | It's useful for order_by, select_related and other calls, 115 | which expect query path as string 116 | ```python 117 | >>> Q.user.username.get_path() 118 | 'user__username' 119 | ``` 120 | 121 | ## Extending 122 | 123 | It's possible to register custom helpers. Let's say it's required to 124 | create **in_exc_range()** helper, which will perform exclusive range 125 | filtering. 126 | 127 | 128 | ```python 129 | from django_orm_sugar import register_helper 130 | 131 | # write helper function and register it using special decorator 132 | @register_helper('in_exc_range') 133 | def exclusive_in_range_helper(query_path, min_value, max_value): 134 | """ 135 | Unlike existing in_range method, filtering will be performed 136 | excluding initial values. In other words - we will use "<" and ">" 137 | comparison instead of "<=" and ">=" 138 | """ 139 | q_gt = Q(**{'{}__gt'.format(query_path): min_value}) 140 | q_lt = Q(**{'{}__lt'.format(query_path): max_value}) 141 | return q_gt & q_lt 142 | ``` 143 | 144 | The actual function name doesn't matter, only passed name to decorator 145 | does. 146 | Now a newly registered helper can be used: 147 | ```python 148 | >>> Q.user.registration_date.in_exc_range(from_date, to_date) 149 | Q(registration_date__gt=from_date) & Q(registration_date__lt=to_date) 150 | ``` 151 | -------------------------------------------------------------------------------- /django_orm_sugar.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from django.db.models import Q as QNode 3 | 4 | __author__ = 'Alexey Zankevich' 5 | 6 | 7 | class QFactory(object): 8 | """ 9 | Usage: 10 | 11 | >>> Q.username.get_path() 12 | 'username' 13 | 14 | >>> Q.user.username.get_path() 15 | 'user__username' 16 | 17 | Typical usage: 18 | >>> Q.user.username == 'Bender Rodriguez' 19 | 20 | 21 | The old-style usage is still available: 22 | >>> Q(user__username='Bender') 23 | 24 | 25 | Different usage cases: 26 | >>> Q.user.username.iexact('Bender Rodriguez') 27 | 28 | 29 | >>> Q.user.username.exact('Bender Rodriguez') 30 | 31 | 32 | >>> Q.user.username.contains('Rodriguez') 33 | 34 | 35 | >>> Q.user.username.icontains('Rodriguez') 36 | 37 | 38 | Passing items as a single list argument 39 | >>> Q.user.tags[0:1].overlap(['item1', 'item2']) 40 | 41 | 42 | Passing items as multiple arguments 43 | >>> Q.user.created.range('start date', 'end date') 44 | 45 | 46 | >>> Q.data.contained_by({'breed': 'collie'}) 47 | 48 | 49 | """ 50 | 51 | _helpers = {} 52 | 53 | def __init__(self, name='', parent=None): 54 | self._name = name 55 | self._parent = parent 56 | 57 | def __getattr__(self, item): 58 | """ 59 | :return: QFactory() 60 | """ 61 | # Made module doctestable, otherwise doctest runner failed into infinite loop 62 | if item == '__wrapped__': 63 | raise AttributeError('No attribute __wrapped__') 64 | else: 65 | return QFactory(name=item, parent=self) 66 | 67 | def __getitem__(self, item): 68 | """ 69 | >>> Q.user.tags[0].name == 'My Tag' 70 | 71 | 72 | >>> Q.user.tags[0:1].name == "My Tag" 73 | 74 | 75 | """ 76 | if isinstance(item, slice): 77 | return QFactory('{}_{}'.format(item.start, item.stop), self) 78 | else: 79 | return QFactory(str(item), self) 80 | 81 | def __eq__(self, value): 82 | """ 83 | >>> Q.username == 'Bender Rodriguez' 84 | 85 | 86 | >>> Q.user.username == 'Bender Rodriguez' 87 | 88 | """ 89 | return QNode(**{self.get_path(): value}) 90 | 91 | def __ne__(self, value): 92 | """ 93 | >>> QFactory().user.username != 'Bender Rodriguez' 94 | 95 | """ 96 | return ~QNode(**{self.get_path(): value}) 97 | 98 | def __gt__(self, value): 99 | """ 100 | >>> QFactory().user.age > 7 101 | 102 | """ 103 | return QNode(**{'{}__gt'.format(self.get_path()): value}) 104 | 105 | def __ge__(self, value): 106 | """ 107 | >>> QFactory().user.age >= 7 108 | 109 | """ 110 | return QNode(**{'{}__gte'.format(self.get_path()): value}) 111 | 112 | def __lt__(self, value): 113 | """ 114 | >>> QFactory().user.age < 7 115 | 116 | """ 117 | return QNode(**{'{}__lt'.format(self.get_path()): value}) 118 | 119 | def __le__(self, value): 120 | """ 121 | >>> QFactory().user.age <= 7 122 | 123 | """ 124 | return QNode(**{'{}__lte'.format(self.get_path()): value}) 125 | 126 | def __call__(self, *args, **kwargs): 127 | """ 128 | Lookup custom helpers, otherwise keep old-style Q usage 129 | 130 | >>> Q(user__age__lte=7) 131 | 132 | 133 | >>> Q.article.tags.overlap('holiday', 'x-mas') 134 | 135 | 136 | >>> tags = Q.article.tags 137 | >>> tags.overlap('holiday', 'x-mas') 138 | 139 | 140 | """ 141 | if self._parent: 142 | helper = self._helpers.get(self._name) 143 | if helper: 144 | return helper(self._parent.get_path(), *args, **kwargs) 145 | else: 146 | # create Q object based on full path 147 | if len(args) == 1: 148 | value = args[0] 149 | else: 150 | value = args 151 | return QNode(**{self.get_path(): value}) 152 | else: 153 | # just create usual Q object 154 | return QNode(**kwargs) 155 | 156 | def is_null(self, value=True): 157 | """ 158 | Filter by null (or not-null) fields 159 | 160 | >>> QFactory().user.favorite_movie.is_null() 161 | 162 | 163 | """ 164 | return QNode(**{'{}__isnull'.format(self.get_path()): value}) 165 | 166 | def is_not_null(self): 167 | """ 168 | Filter by not null (or not-null) fields 169 | 170 | >>> QFactory().user.favorite_movie.is_not_null() 171 | 172 | """ 173 | return self.is_null(False) 174 | 175 | def in_list(self, lst): 176 | """ 177 | Filter by fields matching a given list 178 | 179 | >>> QFactory().user.id.in_list([1, 2, 3]) 180 | 181 | """ 182 | return QNode(**{'{}__in'.format(self.get_path()): lst}) 183 | 184 | def in_range(self, min_value, max_value): 185 | """ 186 | >>> QFactory().user.id.in_range(7, 10) 187 | 188 | """ 189 | return (self <= min_value) & (self >= max_value) 190 | 191 | def get_path(self): 192 | """ 193 | Get Django-compatible query path 194 | 195 | >>> QFactory().user.username.get_path() 196 | 'user__username' 197 | 198 | """ 199 | if self._parent is not None: 200 | parent_param = self._parent.get_path() 201 | if parent_param: 202 | return '__'.join([parent_param, self._name]) 203 | return self._name 204 | 205 | @classmethod 206 | def register_helper(cls, name, function): 207 | cls._helpers[name] = function 208 | 209 | 210 | def register_helper(helper_name): 211 | """ 212 | Register a custom helper. 213 | Decorated function should take at least one param - queried path, which 214 | will be passed automatically by QFactory. 215 | 216 | Example: 217 | 218 | >>> import datetime 219 | >>> @register_helper('is_today') 220 | ... def is_today_helper(path): 221 | ... return QNode(**{path: datetime.date.today()}) 222 | 223 | >>> q = Q.user.last_login_date.is_today() 224 | >>> isinstance(q, QNode) 225 | True 226 | 227 | """ 228 | def decorator(func): 229 | QFactory.register_helper(helper_name, func) 230 | return func 231 | return decorator 232 | 233 | 234 | # creating shortcut 235 | Q = QFactory() 236 | 237 | # make old-style references for backward compatibility, will be removed in next stable release 238 | S = Q 239 | SugarQueryHelper = QFactory 240 | 241 | 242 | if __name__ == "__main__": 243 | import doctest 244 | test_results = doctest.testmod() 245 | print(test_results) 246 | sys.exit(test_results[0]) 247 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file=README.md 3 | universal=1 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | __author__ = 'Alexey Zankevich' 4 | 5 | 6 | setup( 7 | name="django-orm-sugar", 8 | version="0.9.0", 9 | py_modules=['django_orm_sugar'], 10 | author="Alexey Zankevich", 11 | author_email="alex.zankevich@gmail.com", 12 | description="Django ORM sugar library to simplify querying", 13 | keywords=['Django', 'ORM', 'util', 'sugar'], 14 | license="MIT", 15 | platforms=['Platform Independent'], 16 | url="https://github.com/Nepherhotep/django-orm-sugar", 17 | install_requires=['django'], 18 | classifiers=["Framework :: Django :: 1.8", 19 | "Framework :: Django :: 1.9", 20 | "Framework :: Django :: 1.10", 21 | "Development Status :: 5 - Production/Stable", 22 | 23 | "Programming Language :: Python :: 2.7", 24 | "Programming Language :: Python :: 3.4", 25 | "Programming Language :: Python :: 3.5"] 26 | ) 27 | 28 | 29 | 30 | --------------------------------------------------------------------------------