├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── Makefile ├── README.md ├── docs ├── example.md ├── index.md ├── reference.md └── requirements.in ├── mkdocs.yml ├── model_values ├── __init__.py └── py.typed ├── pyproject.toml └── tests ├── __init__.py ├── conftest.py ├── models.py └── test_all.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ['3.10', '3.11', '3.12', '3.13'] 16 | django-version: ['<5', '<5.2', ''] 17 | exclude: 18 | - python-version: '3.13' 19 | django-version: '<5' 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - run: pip install --pre 'django${{ matrix.django-version }}' 26 | - run: pip install pytest-cov pytest-django 27 | - run: make check 28 | - run: coverage xml 29 | - uses: codecov/codecov-action@v5 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | 33 | lint: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: actions/setup-python@v5 38 | with: 39 | python-version: 3.x 40 | - run: pip install ruff mypy 41 | - run: make lint 42 | 43 | docs: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: actions/setup-python@v5 48 | with: 49 | python-version: 3.x 50 | - run: pip install -r docs/requirements.in 51 | - run: make html 52 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | permissions: write-all 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: 3.x 17 | - run: pip install build -r docs/requirements.in 18 | - run: python -m build 19 | - run: python -m mkdocs gh-deploy --force 20 | - uses: pypa/gh-action-pypi-publish@release/v1 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | site/ 3 | .coverage 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 5 | 6 | ## Unreleased 7 | ### Changed 8 | * Python >=3.10 required 9 | 10 | ## [1.6](https://pypi.org/project/django-model-values/1.6/) - 2023-11-04 11 | ### Changed 12 | * Python >=3.8 required 13 | * Django >=4.2 required 14 | 15 | ## [1.5](https://pypi.org/project/django-model-values/1.5/) - 2022-08-03 16 | ### Changed 17 | * Django >=3.2 required 18 | 19 | ## [1.4](https://pypi.org/project/django-model-values/1.4/) - 2021-12-04 20 | * Python >=3.7 required 21 | * Django 4 support 22 | 23 | ## [1.3](https://pypi.org/project/django-model-values/1.3/) - 2021-04-03 24 | * Django 3.2 support 25 | 26 | ## [1.2](https://pypi.org/project/django-model-values/1.2/) - 2020-08-04 27 | * Python >=3.6 required 28 | * Django >=2.2 required 29 | 30 | ## [1.1](https://pypi.org/project/django-model-values/1.1/) - 2019-12-01 31 | * Django 3 support 32 | 33 | ## [1.0](https://pypi.org/project/django-model-values/1.0/) - 2019-04-01 34 | * Update related methods moved with deprecation warnings 35 | * Extensible change detection and updates 36 | * Django 2.2 functions 37 | 38 | ## [0.6](https://pypi.org/project/django-model-values/0.6/) - 2018-08-01 39 | * Transform functions 40 | * Named tuples 41 | * Window functions 42 | * Distance lookups 43 | * Django 2.1 functions 44 | * `EnumField` 45 | * Annotated `items` 46 | * Expressions in column selection 47 | 48 | ## [0.5](https://pypi.org/project/django-model-values/0.5/) - 2017-11-28 49 | * `F` expressions operators `any` and `all` 50 | * Spatial lookups and functions 51 | * Django 2.0 support 52 | 53 | ## [0.4](https://pypi.org/project/django-model-values/0.4/) - 2016-04-29 54 | * `upsert` method 55 | * Django 1.9 database functions 56 | * `bulk_update` supports additional fields 57 | 58 | ## [0.3](https://pypi.org/project/django-model-values/0.3/) - 2015-12-01 59 | * Lookup methods and operators 60 | * `F` expressions and aggregation methods 61 | * Database functions 62 | * Conditional expressions for updates and annotations 63 | * Bulk updates and change detection 64 | 65 | ## [0.2](https://pypi.org/project/django-model-values/0.2/) - 2015-09-26 66 | * Change detection 67 | * Groupby functionality 68 | * Named tuples 69 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022 Aric Coady 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | check: 2 | python -m pytest -s --cov 3 | 4 | lint: 5 | ruff check . 6 | ruff format --check . 7 | mypy -p model_values 8 | 9 | html: 10 | python -m mkdocs build 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![image](https://img.shields.io/pypi/v/django-model-values.svg)](https://pypi.org/project/django-model-values/) 2 | ![image](https://img.shields.io/pypi/pyversions/django-model-values.svg) 3 | ![image](https://img.shields.io/pypi/djversions/django-model-values.svg) 4 | [![image](https://pepy.tech/badge/django-model-values)](https://pepy.tech/project/django-model-values) 5 | ![image](https://img.shields.io/pypi/status/django-model-values.svg) 6 | [![build](https://github.com/coady/django-model-values/actions/workflows/build.yml/badge.svg)](https://github.com/coady/django-model-values/actions/workflows/build.yml) 7 | [![image](https://codecov.io/gh/coady/django-model-values/branch/main/graph/badge.svg)](https://codecov.io/gh/coady/django-model-values/) 8 | [![CodeQL](https://github.com/coady/django-model-values/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/coady/django-model-values/actions/workflows/github-code-scanning/codeql) 9 | [![image](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 10 | [![image](https://mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) 11 | 12 | [Django](https://docs.djangoproject.com) model utilities for encouraging direct data access instead of unnecessary object overhead. Implemented through compatible method and operator extensions to `QuerySets` and `Managers`. 13 | 14 | The goal is to provide elegant syntactic support for best practices in using Django's ORM. Specifically avoiding the inefficiencies and race conditions associated with always using objects. 15 | 16 | ## Usage 17 | Typical model usage is verbose, inefficient, and incorrect. 18 | 19 | ```python 20 | book = Book.objects.get(pk=pk) 21 | book.rating = 5.0 22 | book.save() 23 | ``` 24 | 25 | The correct method is generally supported, but arguably less readable. 26 | 27 | ```python 28 | Book.objects.filter(pk=pk).update(rating=5.0) 29 | ``` 30 | 31 | `model_values` encourages the better approach with operator support. 32 | 33 | ```python 34 | Book.objects[pk]['rating'] = 5.0 35 | ``` 36 | 37 | Similarly for queries: 38 | 39 | ```python 40 | (book.rating for book in books) 41 | books.values_list('rating', flat=True) 42 | books['rating'] 43 | ``` 44 | 45 | Column-oriented syntax is common in panel data layers, and the greater expressiveness cascades. `QuerySets` also support aggregation and conditionals. 46 | 47 | ```python 48 | books.values_list('rating', flat=True).filter(rating__gt=0) 49 | books['rating'] > 0 50 | 51 | books.aggregate(models.Avg('rating'))['rating__avg'] 52 | books['rating'].mean() 53 | ``` 54 | 55 | `Managers` provide a variety of efficient primary key based utilities. To enable, instantiate the `Manager` in your models. As with any custom `Manager`, it doesn't have to be named `objects`, but it is designed to be a 100% compatible replacement. 56 | 57 | ```python 58 | from model_values import Manager 59 | 60 | class Book(models.Model): 61 | ... 62 | objects = Manager() 63 | ``` 64 | 65 | `F` expressions are also enhanced, and can be used directly without model changes. 66 | 67 | ```python 68 | from model_values import F 69 | 70 | .filter(rating__gt=0, last_modified__range=(start, end)) 71 | .filter(F.rating > 0, F.last_modified.range(start, end)) 72 | ``` 73 | 74 | ## Installation 75 | ```console 76 | % pip install django-model-values 77 | ``` 78 | 79 | ## Tests 80 | 100% branch coverage. 81 | 82 | ```console 83 | % pytest [--cov] 84 | ``` 85 | -------------------------------------------------------------------------------- /docs/example.md: -------------------------------------------------------------------------------- 1 | An example `Model` used in the tests. 2 | 3 | ```python 4 | from django.db import models 5 | from model_values import F, Manager, classproperty 6 | 7 | 8 | class Book(models.Model): 9 | title = models.TextField() 10 | author = models.CharField(max_length=50) 11 | quantity = models.IntegerField() 12 | last_modified = models.DateTimeField(auto_now=True) 13 | 14 | objects = Manager() 15 | ``` 16 | 17 | ## Table logic 18 | 19 | Django recommends model methods for row-level functionality, and [custom managers](https://docs.djangoproject.com/en/stable/topics/db/managers/#custom-managers) for table-level functionality. That's fine if the custom managers are reused across models, but often they're just custom filters, and specific to a model. As evidenced by [django-model-utils'](https://pypi.org/project/django-model-utils/) `QueryManager`. 20 | 21 | There's a simpler way to achieve the same end: a model `classmethod`. In some cases a profileration of classmethods is an anti-pattern, but in this case functions won't suffice. It's Django that attached the `Manager` instance to a class. 22 | 23 | Additionally a `classproperty` wrapper is provided, to mimic a custom `Manager` or `Queryset` without calling it first. 24 | 25 | ```python 26 | @classproperty 27 | def in_stock(cls): 28 | return cls.objects.filter(F.quantity > 0) 29 | ``` 30 | 31 | ## Row logic 32 | 33 | Some of the below methods may be added to a model mixin in the future. It's a delicate balance, as the goal is to *not* encourage object usage. However, sometimes having an object already is inevitable, so it's still worth considering best practices given that situation. 34 | 35 | Providing wrappers for any manager method that's `pk`-based may be worthwhile, particularly a filter to match only the object. 36 | 37 | ```python 38 | @property 39 | def object(self): 40 | return type(self).objects[self.pk] 41 | ``` 42 | 43 | From there one can easily imagine other useful extensions. 44 | 45 | ```python 46 | def changed(self, **kwargs): 47 | return self.object.changed(**kwargs) 48 | 49 | def update(self, **kwargs): 50 | for name in kwargs: 51 | setattr(self, name, kwargs[name]) 52 | return self.object.update(**kwargs) 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | Provides [Django](https://docs.djangoproject.com) model utilities for encouraging direct data access instead of unnecessary object overhead. Implemented through compatible method and operator extensions[^1] to [QuerySets](reference.md#model_values.QuerySet) and [Managers](reference.md#model_values.Manager). 2 | 3 | The primary motivation is the experiential observation that the active record pattern - specifically `Model.save` - is the root of all evil. The secondary goal is to provide a more intuitive data layer, similar to PyData projects such as [pandas](http://pandas.pydata.org). 4 | 5 | Usage: instantiate the [custom manager](https://docs.djangoproject.com/en/stable/topics/db/managers/#custom-managers) in your models. 6 | 7 | ## Updates 8 | 9 | *The Bad*: 10 | ```python 11 | book = Book.objects.get(pk=pk) 12 | book.rating = 5.0 13 | book.save() 14 | ``` 15 | 16 | This example is ubiquitous and even encouraged in many django circles. It's also an epic fail: 17 | 18 | * Runs an unnecessary select query, as no fields need to be read. 19 | * Updates all fields instead of just the one needed. 20 | * Therefore also suffers from race conditions. 21 | * And is relatively verbose, without addressing errors yet. 22 | 23 | The solution is relatively well-known, and endorsed by [django's own docs](https://docs.djangoproject.com/en/stable/ref/models/querysets/#update), but remains under-utilized. 24 | 25 | *The Ugly*: 26 | ```python 27 | Book.objects.filter(pk=pk).update(rating=5.0) 28 | ``` 29 | 30 | So why not provide syntactic support for the better approach? The [Manager](reference.md#model_values.Manager) supports filtering by primary key, since that's so common. The [QuerySet](reference.md#model_values.QuerySet) supports column updates. 31 | 32 | *The Good*: 33 | ```python 34 | Book.objects[pk]['rating'] = 5.0 35 | ``` 36 | 37 | But one might posit... 38 | 39 | * "Isn't the encapsulation `save` provides worth it in principle?" 40 | * "Doesn't the new `update_fields` option fix this in practice?" 41 | * "What if the object is cached or has custom logic in the `save` method?" 42 | 43 | No, no, and good luck with that.[^2] Consider a more realistic example which addresses these concerns. 44 | 45 | *The Bad*: 46 | ```python 47 | try: 48 | book = Book.objects.get(pk=pk) 49 | except Book.DoesNotExist: 50 | changed = False 51 | else: 52 | changed = book.publisher != publisher 53 | if changed: 54 | book.publisher = publisher 55 | book.pubdate = today 56 | book.save(update_fields=['publisher', 'pubdate']) 57 | ``` 58 | 59 | This solves the most severe problem, though with more verbosity and still an unnecessary read.[^3] Note handling `pubdate` in the `save` implementation would only spare the caller one line of code. But the real problem is how to handle custom logic when `update_fields` *isn't* 60 | specified. There's no one obvious correct behavior, which is why projects like [django-model-utils](https://pypi.org/project/django-model-utils/) have to track the changes on the object itself.[^4] 61 | 62 | A better approach would be an `update_publisher` method which does all and only what is required. So what would such an implementation be? A straight-forward update won't work, yet only a minor tweak is needed. 63 | 64 | *The Ugly*: 65 | ```python 66 | changed = Book.objects.filter(pk=pk).exclude(publisher=publisher) \ 67 | .update(publisher=publisher, pubdate=today) 68 | ``` 69 | Now the update is only executed if necessary. And this can be generalized with a little inspiration from `{get,update}_or_create`. 70 | 71 | *The Good*: 72 | ```python 73 | changed = Book.objects[pk].change({'pubdate': today}, publisher=publisher) 74 | ``` 75 | 76 | ## Selects 77 | 78 | Direct column access has some of the clunkiest syntax: `values_list(..., flat=True)`. [QuerySets](reference.md#model_values.QuerySet) override `__getitem__`, as well as comparison operators for simple filters. Both are common syntax in panel data layers. 79 | 80 | *The Bad*: 81 | ```python 82 | {book.pk: book.name for book in qs} 83 | 84 | (book.name for book in qs.filter(name__isnull=False)) 85 | 86 | if qs.filter(author=author): 87 | ``` 88 | 89 | *The Ugly*: 90 | ```python 91 | dict(qs.values_list('pk', 'name')) 92 | 93 | qs.exclude(name=None).values_list('name', flat=True) 94 | 95 | if qs.filter(author=author).exists(): 96 | ``` 97 | 98 | *The Good*: 99 | ```python 100 | dict(qs['pk', 'name']) 101 | 102 | qs['name'] != None 103 | 104 | if author in qs['author']: 105 | ``` 106 | 107 | ## Aggregation 108 | 109 | Once accustomed to working with data values, a richer set of aggregations becomes possible. Again the method names mirror projects like [pandas](http://pandas.pydata.org) whenever applicable. 110 | 111 | *The Bad*: 112 | ```python 113 | collections.Counter(book.author for book in qs) 114 | 115 | sum(book.rating for book in qs) / len(qs) 116 | 117 | counts = collections.Counter() 118 | for book in qs: 119 | counts[book.author] += book.quantity 120 | ``` 121 | 122 | *The Ugly*: 123 | ```python 124 | dict(qs.values_list('author').annotate(model.Count('author'))) 125 | 126 | qs.aggregate(models.Avg('rating'))['rating__avg'] 127 | 128 | dict(qs.values_list('author').annotate(models.Sum('quantity'))) 129 | ``` 130 | 131 | *The Good*: 132 | ```python 133 | dict(qs['author'].value_counts()) 134 | 135 | qs['rating'].mean() 136 | 137 | dict(qs['quantity'].groupby('author').sum()) 138 | ``` 139 | 140 | ## Expressions 141 | 142 | `F` expressions are similarly extended to easily create `Q`, `Func`, and `OrderBy` objects. Note they can be used directly even without a custom manager. 143 | 144 | *The Bad*: 145 | ```python 146 | (book for book in qs if book.author.startswith('A') or book.author.startswith('B')) 147 | 148 | (book.title[:10] for book in qs) 149 | 150 | for book in qs: 151 | book.rating += 1 152 | book.save() 153 | ``` 154 | 155 | *The Ugly*: 156 | ```python 157 | qs.filter(Q(author__startswith='A') | Q(author__startswith='B')) 158 | 159 | qs.values_list(functions.Substr('title', 1, 10), flat=True) 160 | 161 | qs.update(rating=models.F('rating') + 1) 162 | ``` 163 | 164 | *The Good*: 165 | ```python 166 | qs[F.any(map(F.author.startswith, 'AB'))] 167 | 168 | qs[F.title[:10]] 169 | 170 | qs['rating'] += 1 171 | ``` 172 | 173 | ## Conditionals 174 | 175 | Annotations and updates with `Case` and `When` expressions. See also [bulk_changed and bulk_change](reference.md#model_values.Manager) for efficient bulk operations on primary keys. 176 | 177 | *The Bad*: 178 | ```python 179 | collections.Counter('low' if book.quantity < 10 else 'high' for book in qs).items() 180 | 181 | for author, quantity in items: 182 | for book in qs.filter(author=author): 183 | book.quantity = quantity 184 | book.save() 185 | ``` 186 | *The Ugly*: 187 | ```python 188 | qs.values_list(models.Case( 189 | models.When(quantity__lt=10, then=models.Value('low')), 190 | models.When(quantity__gte=10, then=models.Value('high')), 191 | output_field=models.CharField(), 192 | )).annotate(count=models.Count('*')) 193 | 194 | cases = (models.When(author=author, then=models.Value(quantity)) for author, quantity in items) 195 | qs.update(quantity=models.Case(*cases, default='quantity')) 196 | ``` 197 | 198 | *The Good*: 199 | ```python 200 | qs[{F.quantity < 10: 'low', F.quantity >= 10: 'high'}].value_counts() 201 | 202 | qs['quantity'] = {F.author == author: quantity for author, quantity in items} 203 | ``` 204 | 205 | [^1]: The only incompatible changes are edge cases which aren't documented behavior, such as queryset comparison. 206 | 207 | [^2]: In the *vast* majority of instances of that idiom, the object is immediately discarded and no custom logic is necessary. Furthermore the dogma of a model knowing how to serialize itself doesn't inherently imply a single all-purpose instance method. Specialized classmethods or manager methods would be just as encapsulated. 208 | 209 | [^3]: Premature optimization? While debatable with respect to general object overhead, nothing good can come from running superfluous database queries. 210 | 211 | [^4]: Supporting `update_fields` with custom logic also results in complex conditionals, ironic given that OO methodology ostensibly favors separate methods over large switch statements. 212 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | !!! note 2 | Spatial lookups require [gis](https://docs.djangoproject.com/en/stable/ref/contrib/gis/) to be enabled. 3 | ::: model_values.Lookup 4 | 5 | !!! note 6 | Since attributes are used for constructing [F](#model_values.F) objects, there may be collisions between field names and methods. For example, `name` is a reserved attribute, but the usual constructor can still be used: `F('name')`. 7 | !!! note 8 | See source for available spatial functions if [gis](https://docs.djangoproject.com/en/stable/ref/contrib/gis/) is configured. 9 | ::: model_values.F 10 | 11 | !!! note 12 | See source for available aggregate spatial functions if [gis](https://docs.djangoproject.com/en/stable/ref/contrib/gis/) is configured. 13 | ::: model_values.QuerySet 14 | 15 | ::: model_values.Manager 16 | 17 | ::: model_values.Case 18 | 19 | ::: model_values.classproperty 20 | 21 | ::: model_values.EnumField 22 | -------------------------------------------------------------------------------- /docs/requirements.in: -------------------------------------------------------------------------------- 1 | django 2 | mkdocs-material 3 | mkdocstrings[python] 4 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: django-model-values 2 | site_url: https://coady.github.io/django-model-values/ 3 | site_description: Taking the O out of ORM. 4 | theme: material 5 | 6 | repo_name: coady/django-model-values 7 | repo_url: https://github.com/coady/django-model-values 8 | edit_uri: "" 9 | 10 | nav: 11 | - Introduction: index.md 12 | - Reference: reference.md 13 | - Example: example.md 14 | 15 | plugins: 16 | - search 17 | - mkdocstrings: 18 | handlers: 19 | python: 20 | options: 21 | show_root_heading: true 22 | 23 | markdown_extensions: 24 | - admonition 25 | - footnotes 26 | -------------------------------------------------------------------------------- /model_values/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import collections 3 | import functools 4 | import itertools 5 | import math 6 | import operator 7 | import types 8 | from collections.abc import Callable, Iterable, Mapping 9 | from django.db import IntegrityError, models, transaction 10 | from django.db.models import functions 11 | 12 | try: # pragma: no cover 13 | import django.contrib.gis.db.models as gis 14 | except Exception: 15 | gis = None 16 | 17 | 18 | def update_wrapper(wrapper, name): 19 | wrapper.__name__ = wrapper.__doc__ = name 20 | return wrapper 21 | 22 | 23 | def eq(lookup): 24 | return update_wrapper(lambda self, value: self.__eq__(value, '__' + lookup), lookup) 25 | 26 | 27 | class Lookup: 28 | """Mixin for field lookups.""" 29 | 30 | __ne__ = eq('ne') 31 | __lt__ = eq('lt') 32 | __le__ = eq('lte') 33 | __gt__ = eq('gt') 34 | __ge__ = eq('gte') 35 | iexact = eq('iexact') 36 | icontains = eq('icontains') 37 | startswith = eq('startswith') 38 | istartswith = eq('istartswith') 39 | endswith = eq('endswith') 40 | iendswith = eq('iendswith') 41 | regex = eq('regex') 42 | iregex = eq('iregex') 43 | isin = eq('in') 44 | # spatial lookups 45 | contained = eq('contained') 46 | coveredby = eq('coveredby') 47 | covers = eq('covers') 48 | crosses = eq('crosses') 49 | disjoint = eq('disjoint') 50 | equals = eq('equals') # __eq__ is taken 51 | intersects = eq('intersects') # __and__ is ambiguous 52 | touches = eq('touches') 53 | __lshift__ = left = eq('left') 54 | __rshift__ = right = eq('right') 55 | above = eq('strictly_above') 56 | below = eq('strictly_below') 57 | 58 | def range(self, *values): 59 | """range""" 60 | return self.__eq__(values, '__range') 61 | 62 | def relate(self, *values): 63 | """relate""" 64 | return self.__eq__(values, '__relate') 65 | 66 | @property 67 | def is_valid(self): 68 | """Whether field `isvalid`.""" 69 | return self.__eq__(True, '__isvalid') 70 | 71 | def contains(self, value, properly=False, bb=False): 72 | """Return whether field `contains` the value. Options apply only to geom fields. 73 | 74 | Args: 75 | properly: `contains_properly` 76 | bb: bounding box, `bbcontains` 77 | """ 78 | properly = '_properly' * bool(properly) 79 | bb = 'bb' * bool(bb) 80 | return self.__eq__(value, f'__{bb}contains{properly}') 81 | 82 | def overlaps(self, geom, position='', bb=False): 83 | """Return whether field `overlaps` with geometry . 84 | 85 | Args: 86 | position: `overlaps_{left, right, above, below}` 87 | bb: bounding box, `bboverlaps` 88 | """ 89 | bb = 'bb' * bool(bb) 90 | return self.__eq__(geom, f'__{bb}overlaps_{position}'.rstrip('_')) 91 | 92 | def within(self, geom, distance=None): 93 | """Return whether field is `within` geometry. 94 | 95 | Args: 96 | distance: `dwithin` 97 | """ 98 | if distance is None: 99 | return self.__eq__(geom, '__within') 100 | return self.__eq__((geom, distance), '__dwithin') 101 | 102 | 103 | class method(functools.partial): 104 | def __init__(self, func, *args): 105 | self.__doc__ = func.__doc__ or func.__name__ 106 | 107 | def __get__(self, instance, owner): 108 | return self if instance is None else types.MethodType(self, instance) 109 | 110 | 111 | def transform(lookup, func, value): 112 | field, expr = func.source_expressions 113 | expr = expr if isinstance(expr, models.F) else expr.value 114 | return field.__eq__((expr, value), '__' + lookup) 115 | 116 | 117 | class MetaF(type): 118 | def __getattr__(cls, name: str) -> F: 119 | if name in ('name', '__slots__', '__wrapped__'): 120 | raise AttributeError(f"'{name}' is a reserved attribute") 121 | return cls(name) 122 | 123 | def any(cls, exprs: Iterable[models.Q]) -> models.Q: 124 | """Return ``Q`` OR object.""" 125 | return functools.reduce(operator.or_, exprs) 126 | 127 | def all(cls, exprs: Iterable[models.Q]) -> models.Q: 128 | """Return ``Q`` AND object.""" 129 | return functools.reduce(operator.and_, exprs) 130 | 131 | 132 | class F(models.F, Lookup, metaclass=MetaF): 133 | """Create ``F``, ``Q``, and ``Func`` objects with expressions. 134 | 135 | ``F`` creation supported as attributes: 136 | ``F.user`` == ``F('user')``, 137 | ``F.user.created`` == ``F('user__created')``. 138 | 139 | ``Q`` lookups supported as methods or operators: 140 | ``F.text.iexact(...)`` == ``Q(text__iexact=...)``, 141 | ``F.user.created >= ...`` == ``Q(user__created__gte=...)``. 142 | 143 | ``Func`` objects also supported as methods: 144 | ``F.user.created.min()`` == ``Min('user__created')``. 145 | 146 | Some ``Func`` objects can also be transformed into lookups, 147 | if [registered](https://docs.djangoproject.com/en/stable/ref/models/database-functions/#length): 148 | ``F.text.length()`` == ``Length(F('text'))``, 149 | ``F.text.length > 0`` == ``Q(text__length__gt=0)``. 150 | """ 151 | 152 | lookups = dict( 153 | length=functions.Length, 154 | lower=functions.Lower, 155 | upper=functions.Upper, 156 | chr=functions.Chr, 157 | ord=functions.Ord, 158 | acos=functions.ACos, 159 | asin=functions.ASin, 160 | atan=functions.ATan, 161 | atan2=functions.ATan2, 162 | cos=functions.Cos, 163 | cot=functions.Cot, 164 | degrees=functions.Degrees, 165 | exp=functions.Exp, 166 | radians=functions.Radians, 167 | sin=functions.Sin, 168 | sqrt=functions.Sqrt, 169 | tan=functions.Tan, 170 | sign=functions.Sign, 171 | md5=functions.MD5, 172 | ) 173 | coalesce = method(functions.Coalesce) 174 | concat = method(functions.Concat) # __add__ is taken 175 | min = method(models.Min) 176 | max = method(models.Max) 177 | sum = method(models.Sum) 178 | mean = method(models.Avg) 179 | var = method(models.Variance) 180 | std = method(models.StdDev) 181 | greatest = method(functions.Greatest) 182 | least = method(functions.Least) 183 | now = staticmethod(functions.Now) 184 | cast = method(functions.Cast) 185 | extract = method(functions.Extract) 186 | trunc = method(functions.Trunc) 187 | cume_dist = method(functions.CumeDist) 188 | dense_rank = method(functions.DenseRank) 189 | first_value = method(functions.FirstValue) 190 | lag = method(functions.Lag) 191 | last_value = method(functions.LastValue) 192 | lead = method(functions.Lead) 193 | nth_value = method(functions.NthValue) 194 | ntile = staticmethod(functions.Ntile) 195 | percent_rank = method(functions.PercentRank) 196 | rank = method(functions.Rank) 197 | row_number = method(functions.RowNumber) 198 | strip = method(functions.Trim) 199 | lstrip = method(functions.LTrim) 200 | rstrip = method(functions.RTrim) 201 | repeat = method(functions.Repeat) 202 | nullif = method(functions.NullIf) 203 | __reversed__ = method(functions.Reverse) 204 | __abs__ = method(functions.Abs) 205 | __ceil__ = method(functions.Ceil) 206 | __floor__ = method(functions.Floor) 207 | __mod__ = method(functions.Mod) 208 | pi = functions.Pi() 209 | __pow__ = method(functions.Power) 210 | __round__ = method(functions.Round) 211 | sha1 = method(functions.SHA1) 212 | sha224 = method(functions.SHA224) 213 | sha256 = method(functions.SHA256) 214 | sha384 = method(functions.SHA384) 215 | sha512 = method(functions.SHA512) 216 | collate = method(functions.Collate) 217 | json = staticmethod(functions.JSONObject) 218 | random = staticmethod(functions.Random) 219 | if gis: # pragma: no cover 220 | area = property(gis.functions.Area) 221 | geojson = method(gis.functions.AsGeoJSON) 222 | gml = method(gis.functions.AsGML) 223 | kml = method(gis.functions.AsKML) 224 | svg = method(gis.functions.AsSVG) 225 | bounding_circle = method(gis.functions.BoundingCircle) 226 | centroid = property(gis.functions.Centroid) 227 | difference = method(gis.functions.Difference) 228 | envelope = property(gis.functions.Envelope) 229 | geohash = method(gis.functions.GeoHash) # __hash__ requires an int 230 | intersection = method(gis.functions.Intersection) 231 | make_valid = method(gis.functions.MakeValid) 232 | mem_size = property(gis.functions.MemSize) 233 | num_geometries = property(gis.functions.NumGeometries) 234 | num_points = property(gis.functions.NumPoints) 235 | perimeter = property(gis.functions.Perimeter) 236 | point_on_surface = property(gis.functions.PointOnSurface) 237 | reverse = method(gis.functions.Reverse) 238 | scale = method(gis.functions.Scale) 239 | snap_to_grid = method(gis.functions.SnapToGrid) 240 | symmetric_difference = method(gis.functions.SymDifference) 241 | transform = method(gis.functions.Transform) 242 | translate = method(gis.functions.Translate) 243 | union = method(gis.functions.Union) 244 | azimuth = method(gis.functions.Azimuth) 245 | line_locate_point = method(gis.functions.LineLocatePoint) 246 | force_polygon_cw = method(gis.functions.ForcePolygonCW) 247 | 248 | @method 249 | class distance(gis.functions.Distance): 250 | """Return ``Distance`` with support for lookups: <, <=, >, >=, within.""" 251 | 252 | __lt__ = method(transform, 'distance_lt') 253 | __le__ = method(transform, 'distance_lte') 254 | __gt__ = method(transform, 'distance_gt') 255 | __ge__ = method(transform, 'distance_gte') 256 | within = method(transform, 'dwithin') 257 | 258 | def __getattr__(self, name: str) -> F: 259 | """Return new [F][model_values.F] object with chained attribute.""" 260 | return type(self)(f'{self.name}__{name}') 261 | 262 | def __eq__(self, value, lookup: str = '') -> models.Q: 263 | """Return ``Q`` object with lookup.""" 264 | if not lookup and type(value) is models.F: 265 | return self.name == value.name 266 | return models.Q(**{self.name + lookup: value}) 267 | 268 | def __ne__(self, value) -> models.Q: 269 | """Allow __ne=None lookup without custom queryset.""" 270 | if value is None: 271 | return self.__eq__(False, '__isnull') 272 | return self.__eq__(value, '__ne') 273 | 274 | __hash__ = models.F.__hash__ 275 | 276 | def __call__(self, *args, **extra) -> models.Func: 277 | name, _, func = self.name.rpartition('__') 278 | return self.lookups[func](name, *args, **extra) 279 | 280 | def __iter__(self): 281 | raise TypeError("'F' object is not iterable") 282 | 283 | def __getitem__(self, slc: slice) -> models.Func: 284 | """Return field ``Substr`` or ``Right``.""" 285 | assert (slc.stop or 0) >= 0 and slc.step is None 286 | start = slc.start or 0 287 | if start < 0: 288 | assert slc.stop is None 289 | return functions.Right(self, -start) 290 | size = slc.stop and max(slc.stop - start, 0) 291 | return functions.Substr(self, start + 1, size) 292 | 293 | def __rmod__(self, value): 294 | return functions.Mod(value, self) 295 | 296 | def __rpow__(self, value): 297 | return functions.Power(value, self) 298 | 299 | @method 300 | def count(self='*', **extra): 301 | """Return ``Count`` with optional field.""" 302 | return models.Count(getattr(self, 'name', self), **extra) 303 | 304 | def find(self, sub, **extra) -> models.Expression: 305 | """Return ``StrIndex`` with ``str.find`` semantics.""" 306 | return functions.StrIndex(self, Value(sub), **extra) - 1 307 | 308 | def replace(self, old, new='', **extra) -> models.Func: 309 | """Return ``Replace`` with wrapped values.""" 310 | return functions.Replace(self, Value(old), Value(new), **extra) 311 | 312 | def ljust(self, width: int, fill=' ', **extra) -> models.Func: 313 | """Return ``LPad`` with wrapped values.""" 314 | return functions.LPad(self, width, Value(fill), **extra) 315 | 316 | def rjust(self, width: int, fill=' ', **extra) -> models.Func: 317 | """Return ``RPad`` with wrapped values.""" 318 | return functions.RPad(self, width, Value(fill), **extra) 319 | 320 | def log(self, base=math.e, **extra) -> models.Func: 321 | """Return ``Log``, by default ``Ln``.""" 322 | return functions.Log(self, base, **extra) 323 | 324 | 325 | def reduce(func): 326 | return update_wrapper(lambda self, **extra: self.reduce(func, **extra), func.__name__) 327 | 328 | 329 | def field(func): 330 | return update_wrapper(lambda self, value: func(models.F(*self._fields), value), func.__name__) 331 | 332 | 333 | class QuerySet(models.QuerySet, Lookup): 334 | min = reduce(models.Min) 335 | max = reduce(models.Max) 336 | sum = reduce(models.Sum) 337 | mean = reduce(models.Avg) 338 | var = reduce(models.Variance) 339 | std = reduce(models.StdDev) 340 | __add__ = field(operator.add) 341 | __sub__ = field(operator.sub) 342 | __mul__ = field(operator.mul) 343 | __truediv__ = field(operator.truediv) 344 | __mod__ = field(operator.mod) 345 | __pow__ = field(operator.pow) 346 | if gis: # pragma: no cover 347 | collect = reduce(gis.Collect) 348 | extent = reduce(gis.Extent) 349 | extent3d = reduce(gis.Extent3D) 350 | make_line = reduce(gis.MakeLine) 351 | union = reduce(gis.Union) 352 | 353 | @property 354 | def _flat(self): 355 | return issubclass(self._iterable_class, models.query.FlatValuesListIterable) 356 | 357 | @property 358 | def _named(self): 359 | return issubclass(self._iterable_class, models.query.NamedValuesListIterable) 360 | 361 | def __getitem__(self, key): 362 | """Allow column access by field names, expressions, or ``F`` objects. 363 | 364 | ``qs[field]`` returns flat ``values_list`` 365 | 366 | ``qs[field, ...]`` returns tupled ``values_list`` 367 | 368 | ``qs[Q_obj]`` provisionally returns filtered [QuerySet][model_values.QuerySet] 369 | """ 370 | if isinstance(key, tuple): 371 | return self.values_list(*map(extract, key), named=True) 372 | key = extract(key) 373 | if isinstance(key, (str, models.Expression)): 374 | return self.values_list(key, flat=True) 375 | if isinstance(key, models.Q): 376 | return self.filter(key) 377 | return super().__getitem__(key) 378 | 379 | def __setitem__(self, key, value): 380 | """Update a single column.""" 381 | self.update(**{key: value}) 382 | 383 | def __eq__(self, value, lookup: str = '') -> QuerySet: 384 | """Return [QuerySet][model_values.QuerySet] filtered by comparison to given value.""" 385 | (field,) = self._fields 386 | return self.filter(**{field + lookup: value}) 387 | 388 | def __contains__(self, value): 389 | """Return whether value is present using ``exists``.""" 390 | if self._result_cache is None and self._flat: 391 | return (self == value).exists() 392 | return value in iter(self) 393 | 394 | def __iter__(self): 395 | """Iteration extended to support [groupby][model_values.QuerySet.groupby].""" 396 | if not hasattr(self, '_groupby'): 397 | return super().__iter__() 398 | size = len(self._groupby) 399 | rows = self[self._groupby + self._fields].order_by(*self._groupby).iterator() 400 | groups = itertools.groupby(rows, key=operator.itemgetter(*range(size))) 401 | getter = operator.itemgetter(size if self._flat else slice(size, None)) 402 | if self._named: 403 | Row = collections.namedtuple('Row', self._fields) 404 | getter = lambda tup: Row(*tup[size:]) # noqa: E731 405 | return ((key, map(getter, values)) for key, values in groups) 406 | 407 | def items(self, *fields, **annotations) -> QuerySet: 408 | """Return annotated ``values_list``.""" 409 | return self.annotate(**annotations)[fields + tuple(annotations)] 410 | 411 | def groupby(self, *fields, **annotations) -> QuerySet: 412 | """Return a grouped [QuerySet][model_values.QuerySet]. 413 | 414 | The queryset is iterable in the same manner as ``itertools.groupby``. 415 | Additionally the [reduce][model_values.QuerySet.reduce] functions will return annotated querysets. 416 | """ 417 | qs = self.annotate(**annotations) 418 | qs._groupby = fields + tuple(annotations) 419 | return qs 420 | 421 | def annotate(self, *args, **kwargs) -> QuerySet: 422 | """Annotate extended to also handle mapping values, as a [Case][model_values.Case] expression. 423 | 424 | Args: 425 | **kwargs: ``field={Q_obj: value, ...}, ...`` 426 | 427 | As a provisional feature, an optional ``default`` key may be specified. 428 | """ 429 | for field, value in kwargs.items(): 430 | if Case.isa(value): 431 | kwargs[field] = Case.defaultdict(value) 432 | return super().annotate(*args, **kwargs) 433 | 434 | def alias(self, *args, **kwargs) -> QuerySet: 435 | """Alias extended to also handle mapping values, as a [Case][model_values.Case] expression. 436 | 437 | Args: 438 | **kwargs: ``field={Q_obj: value, ...}, ...`` 439 | """ 440 | for field, value in kwargs.items(): 441 | if Case.isa(value): 442 | kwargs[field] = Case.defaultdict(value) 443 | return super().alias(*args, **kwargs) 444 | 445 | def value_counts(self, alias: str = 'count') -> QuerySet: 446 | """Return annotated value counts.""" 447 | return self.items(*self._fields, **{alias: F.count()}) 448 | 449 | def sort_values(self, reverse=False) -> QuerySet: 450 | """Return [QuerySet][model_values.QuerySet] ordered by selected values.""" 451 | qs = self.order_by(*self._fields) 452 | return qs.reverse() if reverse else qs 453 | 454 | def reduce(self, *funcs, **extra): 455 | """Return aggregated values, or an annotated [QuerySet][model_values.QuerySet]. 456 | 457 | Args: 458 | *funcs: aggregation function classes 459 | """ 460 | funcs = [func(field, **extra) for field, func in zip(self._fields, itertools.cycle(funcs))] 461 | if hasattr(self, '_groupby'): 462 | return self[self._groupby].annotate(*funcs) 463 | names = [func.default_alias for func in funcs] 464 | row = self.aggregate(*funcs) 465 | if self._named: 466 | return collections.namedtuple('Row', names)(**row) 467 | return row[names[0]] if self._flat else tuple(map(row.__getitem__, names)) 468 | 469 | def update(self, **kwargs) -> int: 470 | """Update extended to also handle mapping values, as a [Case][model_values.Case] expression. 471 | 472 | Args: 473 | **kwargs: ``field={Q_obj: value, ...}, ...`` 474 | """ 475 | for field, value in kwargs.items(): 476 | if Case.isa(value): 477 | kwargs[field] = Case(value, default=F(field)) 478 | return super().update(**kwargs) 479 | 480 | def change(self, defaults: Mapping = {}, **kwargs) -> int: 481 | """Update and return number of rows that actually changed. 482 | 483 | For triggering on-change logic without fetching first. 484 | 485 | ``if qs.change(status=...):`` status actually changed 486 | 487 | ``qs.change({'last_modified': now}, status=...)`` last_modified only updated if status updated 488 | 489 | Args: 490 | defaults: optional mapping which will be updated conditionally, as with ``update_or_create``. 491 | """ 492 | return self.exclude(**kwargs).update(**dict(defaults, **kwargs)) 493 | 494 | def changed(self, **kwargs) -> dict: 495 | """Return first mapping of fields and values which differ in the db. 496 | 497 | Also efficient enough to be used in boolean contexts, instead of ``exists``. 498 | """ 499 | row = self.exclude(**kwargs).values(*kwargs).first() or {} 500 | return {field: value for field, value in row.items() if value != kwargs[field]} 501 | 502 | def exists(self, count: int = 1) -> bool: 503 | """Return whether there are at least the specified number of rows.""" 504 | if count == 1: 505 | return super().exists() 506 | return (self[:count].count() if self._result_cache is None else len(self)) >= count 507 | 508 | 509 | @models.Field.register_lookup 510 | class NotEqual(models.Lookup): 511 | """Missing != operator.""" 512 | 513 | lookup_name = 'ne' 514 | 515 | def as_sql(self, *args): 516 | lhs, lhs_params = self.process_lhs(*args) 517 | rhs, rhs_params = self.process_rhs(*args) 518 | return f'{lhs} <> {rhs}', (lhs_params + rhs_params) 519 | 520 | 521 | class Query(models.sql.Query): 522 | """Allow __ne=None lookup.""" 523 | 524 | def build_lookup(self, lookups, lhs, rhs): 525 | if rhs is None and lookups[-1:] == ['ne']: 526 | rhs, lookups[-1] = False, 'isnull' 527 | return super().build_lookup(lookups, lhs, rhs) 528 | 529 | 530 | class Manager(models.Manager): 531 | def get_queryset(self): 532 | return QuerySet(self.model, Query(self.model), self._db, self._hints) 533 | 534 | def __getitem__(self, pk) -> QuerySet: 535 | """Return [QuerySet][model_values.QuerySet] which matches primary key. 536 | 537 | To encourage direct db access, instead of always using get and save. 538 | """ 539 | return self.filter(pk=pk) 540 | 541 | def __delitem__(self, pk): 542 | """Delete row with primary key.""" 543 | self[pk].delete() 544 | 545 | def __contains__(self, pk): 546 | """Return whether primary key is present using ``exists``.""" 547 | return self[pk].exists() 548 | 549 | def upsert(self, defaults: Mapping = {}, **kwargs) -> int | models.Model: 550 | """Update or insert returning number of rows or created object. 551 | 552 | Faster and safer than ``update_or_create``. 553 | Supports combined expression updates by assuming the identity element on insert: ``F(...) + 1``. 554 | 555 | Args: 556 | defaults: optional mapping which will be updated, as with ``update_or_create``. 557 | """ 558 | update = getattr(self.filter(**kwargs), 'update' if defaults else 'count') 559 | for field, value in defaults.items(): 560 | expr = isinstance(value, models.expressions.CombinedExpression) 561 | kwargs[field] = value.rhs.value if expr else value 562 | try: 563 | with transaction.atomic(): 564 | return update(**defaults) or self.create(**kwargs) 565 | except IntegrityError: 566 | return update(**defaults) 567 | 568 | def bulk_changed(self, field, data: Mapping, key: str = 'pk') -> dict: 569 | """Return mapping of values which differ in the db. 570 | 571 | Args: 572 | field: value column 573 | data: ``{pk: value, ...}`` 574 | key: unique key column 575 | """ 576 | rows = self.filter(F(key).isin(data))[key, field].iterator() 577 | return {pk: value for pk, value in rows if value != data[pk]} 578 | 579 | def bulk_change( 580 | self, field, data: Mapping, key: str = 'pk', conditional=False, **kwargs 581 | ) -> int: 582 | """Update changed rows with a minimal number of queries, by inverting the data to use ``pk__in``. 583 | 584 | Args: 585 | field: value column 586 | data: ``{pk: value, ...}`` 587 | key: unique key column 588 | conditional: execute select query and single conditional update; 589 | may be more efficient if the percentage of changed rows is relatively small 590 | **kwargs: additional fields to be updated 591 | """ 592 | if conditional: 593 | data = {pk: data[pk] for pk in self.bulk_changed(field, data, key)} 594 | updates = collections.defaultdict(list) 595 | for pk in data: 596 | updates[data[pk]].append(pk) 597 | if conditional: 598 | kwargs[field] = {F(key).isin(tuple(updates[value])): value for value in updates} 599 | return self.filter(F(key).isin(data)).update(**kwargs) 600 | count = 0 601 | for value in updates: 602 | kwargs[field] = value 603 | count += self.filter((F(field) != value) & F(key).isin(updates[value])).update(**kwargs) 604 | return count 605 | 606 | 607 | class classproperty(property): 608 | """A property bound to a class.""" 609 | 610 | def __get__(self, instance, owner): 611 | return self.fget(owner) 612 | 613 | 614 | def Value(value): 615 | return value if isinstance(value, models.F) else models.Value(value) 616 | 617 | 618 | def extract(field): 619 | if isinstance(field, models.F): 620 | return field.name 621 | return Case.defaultdict(field) if Case.isa(field) else field 622 | 623 | 624 | class Case(models.Case): 625 | """``Case`` expression from mapping of when conditionals. 626 | 627 | Args: 628 | conds: ``{Q_obj: value, ...}`` 629 | default: optional default value or ``F`` object 630 | """ 631 | 632 | types = { 633 | str: models.CharField, 634 | int: models.IntegerField, 635 | float: models.FloatField, 636 | bool: models.BooleanField, 637 | } 638 | 639 | def __new__(cls, conds: Mapping, default=None, **extra): 640 | cases = (models.When(cond, Value(conds[cond])) for cond in conds) 641 | return models.Case(*cases, default=Value(default), **extra) 642 | 643 | @classmethod 644 | def defaultdict(cls, conds): 645 | conds = dict(conds) 646 | return cls(conds, default=conds.pop('default', None)) 647 | 648 | @classmethod 649 | def isa(cls, value): 650 | return isinstance(value, Mapping) and any(isinstance(key, models.Q) for key in value) 651 | 652 | 653 | def EnumField(enum, display: Callable | None = None, **options) -> models.Field: 654 | """Return a ``CharField`` or ``IntegerField`` with choices from given enum. 655 | 656 | By default, enum names and values are used as db values and display labels respectively, 657 | returning a ``CharField`` with computed ``max_length``. 658 | 659 | Args: 660 | display: optional callable to transform enum names to display labels, 661 | thereby using enum values as db values and also supporting integers. 662 | """ 663 | choices = tuple((choice.name, choice.value) for choice in enum) 664 | if display is not None: 665 | choices = tuple((choice.value, display(choice.name)) for choice in enum) 666 | try: 667 | max_length = max(map(len, dict(choices))) 668 | except TypeError: 669 | return models.IntegerField(choices=choices, **options) 670 | return models.CharField(max_length=max_length, choices=choices, **options) 671 | -------------------------------------------------------------------------------- /model_values/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coady/django-model-values/5500f1fea1aa519c972edb43b7e8fdddc8765ad5/model_values/py.typed -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-model-values" 3 | version = "1.6" 4 | description = "Taking the O out of ORM." 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | license = {file = "LICENSE.txt"} 8 | authors = [{name = "Aric Coady", email = "aric.coady@gmail.com"}] 9 | keywords = ["values_list", "pandas", "column-oriented", "data", "mapper", "pattern", "orm"] 10 | classifiers = [ 11 | "Development Status :: 6 - Mature", 12 | "Framework :: Django :: 4", 13 | "Framework :: Django :: 4.2", 14 | "Framework :: Django :: 5", 15 | "Framework :: Django :: 5.1", 16 | "Framework :: Django :: 5.2", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: Apache Software License", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | "Topic :: Database :: Database Engines/Servers", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | "Typing :: Typed", 28 | ] 29 | dependencies = ["django>=4.2"] 30 | 31 | [project.urls] 32 | Homepage = "https://github.com/coady/django-model-values" 33 | Documentation = "https://coady.github.io/django-model-values" 34 | Changelog = "https://github.com/coady/django-model-values/blob/main/CHANGELOG.md" 35 | Issues = "https://github.com/coady/django-model-values/issues" 36 | 37 | [tool.ruff] 38 | line-length = 100 39 | 40 | [tool.ruff.format] 41 | quote-style = "preserve" 42 | 43 | [[tool.mypy.overrides]] 44 | module = ["django.*"] 45 | ignore_missing_imports = true 46 | 47 | [tool.coverage.run] 48 | source = ["model_values"] 49 | branch = true 50 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coady/django-model-values/5500f1fea1aa519c972edb43b7e8fdddc8765ad5/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | settings.configure( 4 | INSTALLED_APPS=['tests'], DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3'}} 5 | ) 6 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from model_values import F, Manager, classproperty 3 | 4 | 5 | class Book(models.Model): 6 | title = models.TextField() 7 | author = models.CharField(max_length=50) 8 | quantity = models.IntegerField() 9 | last_modified = models.DateTimeField(auto_now=True) 10 | 11 | objects = Manager() 12 | 13 | @classproperty 14 | def in_stock(cls): 15 | return cls.objects.filter(F.quantity > 0) 16 | 17 | @property 18 | def object(self): 19 | return type(self).objects[self.pk] 20 | 21 | def changed(self, **kwargs): 22 | return self.object.changed(**kwargs) 23 | 24 | def update(self, **kwargs): 25 | for name in kwargs: 26 | setattr(self, name, kwargs[name]) 27 | return self.object.update(**kwargs) 28 | -------------------------------------------------------------------------------- /tests/test_all.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import math 3 | from django.db import models 4 | from django.db.models import functions 5 | from django.utils import timezone 6 | import pytest 7 | from .models import Book 8 | from model_values import Case, EnumField, F, gis, transform 9 | 10 | pytestmark = pytest.mark.django_db 11 | 12 | 13 | @pytest.fixture 14 | def books(): 15 | for quantity in (10, 10): 16 | Book.objects.create(author='A', quantity=quantity) 17 | for quantity in (2, 1, 2): 18 | Book.objects.create(author='B', quantity=quantity) 19 | return Book.objects.all() 20 | 21 | 22 | def test_queryset(books): 23 | assert books.filter(id__ne=None).exists(5) 24 | assert books and books.exists() and not books.exists(6) 25 | assert set(books['author']) == set(books[F.author]) == {'A', 'B'} 26 | assert dict(books[F.id, 'author']) == {1: 'A', 2: 'A', 3: 'B', 4: 'B', 5: 'B'} 27 | assert set(books[F.author.lower()]) == {'a', 'b'} 28 | assert dict(books['id', F.author.lower()]) == {1: 'a', 2: 'a', 3: 'b', 4: 'b', 5: 'b'} 29 | 30 | assert len(books['quantity'] < 2) == 1 31 | assert len(books['quantity'] <= 2) == 3 32 | assert len(books['quantity'] > 2) == 2 33 | assert len(books['quantity'] >= 2) == 4 34 | assert len(books['quantity'] == 2) == 2 35 | assert len(books['quantity'] != 2) == 3 36 | 37 | quant = books['quantity'] 38 | assert 10 in quant and quant._result_cache is None 39 | assert quant and 10 in quant 40 | assert books[0] in books.all() 41 | assert ('A', 10) in books['author', 'quantity'] 42 | assert list(quant.sort_values()) == [1, 2, 2, 10, 10] 43 | assert list(quant.sort_values(reverse=True)) == [10, 10, 2, 2, 1] 44 | 45 | now = timezone.now() 46 | assert books.filter(author='B').change({'last_modified': now}, quantity=2) == 1 47 | assert len(books['last_modified'] == now) == 1 48 | books['quantity'] = {F.author == 'B': 3} 49 | assert set(books['quantity']) == {3, 10} 50 | assert Book.objects.upsert({'quantity': 0}, pk=1) == 1 51 | assert Book.objects.upsert(pk=0) == 0 # simulates race condition 52 | book = Book.objects.upsert({'quantity': F.quantity + 1}, pk=0) 53 | assert book.pk == 0 and book.quantity == 1 54 | with pytest.raises(TypeError): 55 | books['quantity'] = {} 56 | 57 | 58 | def test_manager(books): 59 | assert 1 in Book.objects 60 | assert Book.objects[1]['id'].first() == 1 61 | assert Book.objects.bulk_changed('quantity', {3: 2, 4: 2, 5: 2}) == {4: 1} 62 | assert Book.objects.bulk_changed('quantity', {'A': 5}, key='author') == {'A': 10} 63 | now = timezone.now() 64 | assert Book.objects.bulk_change('quantity', {3: 2, 4: 2}, last_modified=now) == 1 65 | timestamps = dict(books.filter(quantity=2)['id', 'last_modified']) 66 | assert len(timestamps) == 3 and timestamps[3] < timestamps[5] < timestamps[4] == now 67 | assert Book.objects.bulk_change('quantity', {3: 2, 4: 3}, key='id', conditional=True) == 1 68 | assert set(books.filter(quantity=2)['id']) == {3, 5} 69 | assert Book.objects[1].changed(quantity=5) == {'quantity': 10} 70 | del Book.objects[1] 71 | assert 1 not in Book.objects 72 | 73 | 74 | def test_aggregation(books): 75 | assert set(books['author'].annotate(models.Max('quantity'))) == {'A', 'B'} 76 | assert dict(books['author'].value_counts()) == {'A': 2, 'B': 3} 77 | 78 | assert books['author', 'quantity'].reduce(models.Max, models.Min) == ('B', 1) 79 | assert books['author', 'quantity'].min() == ('A', 1) 80 | assert books['quantity'].min() == 1 81 | assert books['quantity'].max() == 10 82 | assert books['quantity'].sum() == 25 83 | assert books['quantity'].mean() == 5.0 84 | 85 | groups = books['quantity'].groupby('author') 86 | assert {key: sorted(values) for key, values in groups} == {'A': [10, 10], 'B': [1, 2, 2]} 87 | assert dict(groups.min()) == {'A': 10, 'B': 1} 88 | assert dict(groups.max()) == {'A': 10, 'B': 2} 89 | assert dict(groups.sum()) == {'A': 20, 'B': 5} 90 | assert dict(groups.mean()) == {'A': 10, 'B': 5.0 / 3} 91 | assert isinstance(groups.var(), models.QuerySet) 92 | assert isinstance(groups.std(), models.QuerySet) 93 | key, values = next(iter(books.values('title', 'last_modified').groupby('author', 'quantity'))) 94 | assert key == ('A', 10) and next(values)[0] == '' 95 | 96 | groups = books['quantity'].groupby(author=F.author.lower()) 97 | assert dict(groups.sum()) == {'a': 20, 'b': 5} 98 | counts = books[F.author.lower()].value_counts() 99 | assert dict(counts) == {'a': 2, 'b': 3} 100 | assert dict(counts[F('count') > 2]) == {'b': 3} 101 | case = {F.quantity <= 1: 'low', F.quantity >= 10: 'high'} 102 | assert dict(books[case].value_counts()) == {'low': 1, None: 2, 'high': 2} 103 | case['default'] = 'medium' 104 | assert set(books.items(amount=case)) == {('low',), ('medium',), ('high',)} 105 | with pytest.raises(Exception): 106 | Case({models.Q(): None}).output_field 107 | 108 | expr = books.values_list(F.quantity * -1) 109 | assert type(expr.sum()) is tuple 110 | key, values = next(iter(expr.groupby('author'))) 111 | assert set(map(type, values)) == {tuple} 112 | 113 | 114 | def test_functions(books): 115 | book = Book.objects[1] 116 | assert book['quantity'].first() == 10 117 | book['quantity'] += 1 118 | assert book['quantity'].first() == 11 119 | book['quantity'] -= 1 120 | assert book['quantity'].first() == 10 121 | book['quantity'] *= 2 122 | assert book['quantity'].first() == 20 123 | book['quantity'] /= 2 124 | assert book['quantity'].first() == 10 125 | book['quantity'] %= 7 126 | assert book['quantity'].first() == 3 127 | book['quantity'] **= 2 128 | assert book['quantity'].first() == 9 129 | 130 | assert (F.author != None) == models.Q(author__isnull=False) # noqa: E711 131 | assert isinstance(F.coalesce('author', 'title'), functions.Coalesce) 132 | assert isinstance(F.author.concat('title'), functions.Concat) 133 | assert isinstance(F.author.length(), functions.Length) 134 | assert isinstance(F.title.lower(), functions.Lower) 135 | assert isinstance(F.title.upper(), functions.Upper) 136 | assert isinstance(F.title[:10], functions.Substr) 137 | with pytest.raises(AssertionError): 138 | F.title[:-10] 139 | with pytest.raises(AttributeError): 140 | F.name 141 | with pytest.raises(TypeError): 142 | iter(F.title) 143 | assert hash(F.title) 144 | assert not (F.author == models.F('title')) 145 | ((field, values),) = transform('op', F.author.coalesce('title'), None).children 146 | assert field == 'author__op' and values == (F.title, None) 147 | 148 | assert isinstance(F.title.greatest('author'), functions.Greatest) 149 | assert isinstance(F.title.least('author'), functions.Least) 150 | assert F.now is functions.Now 151 | assert isinstance(F.quantity.cast(models.FloatField()), functions.Cast) 152 | assert isinstance(F.last_modified.extract('year'), functions.Extract) 153 | assert isinstance(F.last_modified.trunc('year'), functions.Trunc) 154 | 155 | for name, func in F.lookups.items(): 156 | models.CharField.register_lookup(func, name) 157 | assert books[F.author.length <= 1] 158 | assert books[F.author.lower == 'a'] 159 | assert books[F.author.upper == 'A'] 160 | 161 | 162 | def test_2(books): 163 | row = books['id', 'author'].first() 164 | assert (row.id, row.author) == row 165 | row = books[('author',)].min() 166 | assert (row.author__min,) == row 167 | key, values = next(iter(books[('quantity',)].groupby('author'))) 168 | assert next(values).quantity 169 | assert dict(books[F.author.find('A')].value_counts()) == {-1: 3, 0: 2} 170 | 171 | assert isinstance(F.quantity.cume_dist(), functions.CumeDist) 172 | assert isinstance(F.quantity.dense_rank(), functions.DenseRank) 173 | assert isinstance(F.quantity.first_value(), functions.FirstValue) 174 | assert isinstance(F.quantity.lag(), functions.Lag) 175 | assert isinstance(F.quantity.last_value(), functions.LastValue) 176 | assert isinstance(F.quantity.lead(), functions.Lead) 177 | assert isinstance(F.quantity.nth_value(), functions.NthValue) 178 | assert F.ntile is functions.Ntile 179 | assert isinstance(F.quantity.percent_rank(), functions.PercentRank) 180 | assert isinstance(F.quantity.rank(), functions.Rank) 181 | assert isinstance(F.quantity.row_number(), functions.RowNumber) 182 | 183 | point = 'POINT(0 0)' 184 | if gis: 185 | assert isinstance(F.location.azimuth(point), gis.functions.Azimuth) 186 | assert isinstance(F.location.line_locate_point(point), gis.functions.LineLocatePoint) 187 | 188 | 189 | def test_2_1(): 190 | assert (F.quantity.chr == '').children == [('quantity__chr', '')] 191 | assert isinstance(F.quantity.chr(), functions.Chr) 192 | assert (F.author.ord == 0).children == [('author__ord', 0)] 193 | assert isinstance(F.author.ord(), functions.Ord) 194 | 195 | assert isinstance(F.title[-10:], functions.Right) 196 | assert isinstance(F.author.replace('A', 'B'), functions.Replace) 197 | assert isinstance(F.author.repeat(3), functions.Repeat) 198 | 199 | assert isinstance(F.title.strip(), functions.Trim) 200 | assert isinstance(F.title.lstrip(), functions.LTrim) 201 | assert isinstance(F.title.rstrip(), functions.RTrim) 202 | assert isinstance(F.author.ljust(1), functions.LPad) 203 | assert isinstance(F.author.rjust(1), functions.RPad) 204 | 205 | if gis: 206 | assert isinstance(F.location.force_polygon_cw(), gis.functions.ForcePolygonCW) 207 | 208 | 209 | def test_2_2(): 210 | assert isinstance(F.x.nullif('y'), functions.NullIf) 211 | assert isinstance(reversed(F.x), functions.Reverse) 212 | assert isinstance(abs(F.x), functions.Abs) 213 | assert isinstance(F.x.acos(), functions.ACos) 214 | assert isinstance(F.x.asin(), functions.ASin) 215 | assert isinstance(F.x.atan(), functions.ATan) 216 | assert isinstance(F.x.atan2('y'), functions.ATan2) 217 | assert isinstance(math.ceil(F.x), functions.Ceil) 218 | assert isinstance(F.x.cos(), functions.Cos) 219 | assert isinstance(F.x.cot(), functions.Cot) 220 | assert isinstance(F.x.degrees(), functions.Degrees) 221 | assert isinstance(F.x.exp(), functions.Exp) 222 | assert isinstance(math.floor(F.x), functions.Floor) 223 | assert isinstance(F.x.log(), functions.Log) 224 | assert isinstance(F.x.log(2), functions.Log) 225 | assert isinstance(F.x % 2, functions.Mod) 226 | assert isinstance(2 % F.x, functions.Mod) 227 | assert isinstance(F.pi, functions.Pi) 228 | assert isinstance(F.x**2, functions.Power) 229 | assert isinstance(2**F.x, functions.Power) 230 | assert isinstance(F.x.radians(), functions.Radians) 231 | assert isinstance(round(F.x), functions.Round) 232 | assert isinstance(F.x.sin(), functions.Sin) 233 | assert isinstance(F.x.sqrt(), functions.Sqrt) 234 | assert isinstance(F.x.tan(), functions.Tan) 235 | 236 | assert isinstance(F.x.acos, F) 237 | assert isinstance(F.x.asin, F) 238 | assert isinstance(F.x.atan, F) 239 | assert isinstance(F.x.atan2, F) 240 | assert isinstance(F.x.cos, F) 241 | assert isinstance(F.x.cot, F) 242 | assert isinstance(F.x.degrees, F) 243 | assert isinstance(F.x.exp, F) 244 | assert isinstance(F.x.radians, F) 245 | assert isinstance(F.x.sin, F) 246 | assert isinstance(F.x.sqrt, F) 247 | assert isinstance(F.x.tan, F) 248 | 249 | 250 | def test_3(): 251 | assert isinstance(F.x.sign(), functions.Sign) 252 | assert isinstance(F.x.md5(), functions.MD5) 253 | assert isinstance(F.x.sha1(), functions.SHA1) 254 | assert isinstance(F.x.sha224(), functions.SHA224) 255 | assert isinstance(F.x.sha256(), functions.SHA256) 256 | assert isinstance(F.x.sha384(), functions.SHA384) 257 | assert isinstance(F.x.sha512(), functions.SHA512) 258 | 259 | assert isinstance(F.x.sign, F) 260 | assert isinstance(F.x.md5, F) 261 | 262 | 263 | def test_3_2(books): 264 | assert isinstance(F.x.collate('nocase'), functions.Collate) 265 | assert isinstance(F.json(), functions.JSONObject) 266 | assert isinstance(F.random(), functions.Random) 267 | 268 | case = {F.quantity <= 1: 'low', F.quantity >= 10: 'high', 'default': 'medium'} 269 | assert list(books.alias(amount=case).order_by('amount')['quantity']) == [10, 10, 1, 2, 2] 270 | assert books.alias(name=F.author.lower()).annotate(name=F('name')) 271 | 272 | 273 | def test_4(books): 274 | assert books['quantity'].filter(quantity=-1).sum(default=0) == 0 275 | 276 | 277 | def test_lookups(books): 278 | assert books[F.last_modified.year == timezone.now().year].count() == 5 279 | assert isinstance(F.quantity.min(), models.Min) 280 | assert isinstance(F.quantity.max(), models.Max) 281 | assert isinstance(F.quantity.sum(), models.Sum) 282 | assert isinstance(F.quantity.mean(), models.Avg) 283 | assert str(F.quantity.count()).startswith('Count(F(quantity)') 284 | assert str(F.count(distinct=True)) == "Count('*', distinct=True)" 285 | assert isinstance(F.quantity.var(sample=True), models.Variance) 286 | assert isinstance(F.quantity.std(sample=True), models.StdDev) 287 | exprs = list(map(F.author.contains, 'AB')) 288 | assert str(F.any(exprs)) == "(OR: ('author__contains', 'A'), ('author__contains', 'B'))" 289 | assert str(F.all(exprs)) == "(AND: ('author__contains', 'A'), ('author__contains', 'B'))" 290 | 291 | authors = books['author'] 292 | assert set(authors.isin('AB')) == {'A', 'B'} 293 | assert set(authors.iexact('a')) == {'A'} 294 | assert set(authors.icontains('a')) == {'A'} 295 | assert set(authors.startswith('A')) == {'A'} 296 | assert set(authors.istartswith('a')) == {'A'} 297 | assert set(authors.endswith('A')) == {'A'} 298 | assert set(authors.iendswith('a')) == {'A'} 299 | assert set(authors.range('A', 'B')) == {'A', 'B'} 300 | assert set(authors.regex('A')) == {'A'} 301 | assert set(authors.iregex('a')) == {'A'} 302 | 303 | 304 | def test_model(books): 305 | book = Book.objects.get(pk=1) 306 | assert list(book.object) == [book] 307 | assert len(Book.in_stock) == 5 308 | assert book.changed(quantity=5) == {'quantity': 10} 309 | assert book.changed(quantity=10) == {} 310 | assert book.update(quantity=2) == 1 311 | assert book.quantity == 2 and 2 in book.object['quantity'] 312 | 313 | 314 | def test_spatial_lookups(): 315 | point = 'POINT(0 0)' 316 | assert F.location.is_valid.children == [('location__isvalid', True)] 317 | assert F.location.contains(point, bb=True).children == [('location__bbcontains', point)] 318 | expected = [('location__contains_properly', point)] 319 | assert F.location.contains(point, properly=True).children == expected 320 | 321 | assert F.location.overlaps(point).children == [('location__overlaps', point)] 322 | assert F.location.overlaps(point, bb=True).children == [('location__bboverlaps', point)] 323 | assert F.location.overlaps(point, 'left').children == [('location__overlaps_left', point)] 324 | assert F.location.overlaps(point, 'right').children == [('location__overlaps_right', point)] 325 | assert F.location.overlaps(point, 'above').children == [('location__overlaps_above', point)] 326 | assert F.location.overlaps(point, 'below').children == [('location__overlaps_below', point)] 327 | 328 | assert F.location.within(point).children == [('location__within', point)] 329 | assert F.location.within(point, 0).children == [('location__dwithin', (point, 0))] 330 | 331 | assert F.location.contained(point).children == [('location__contained', point)] 332 | assert F.location.coveredby(point).children == [('location__coveredby', point)] 333 | assert F.location.covers(point).children == [('location__covers', point)] 334 | assert F.location.crosses(point).children == [('location__crosses', point)] 335 | assert F.location.disjoint(point).children == [('location__disjoint', point)] 336 | assert F.location.equals(point).children == [('location__equals', point)] 337 | assert F.location.intersects(point).children == [('location__intersects', point)] 338 | assert F.location.relate(point, '').children == [('location__relate', (point, ''))] 339 | assert F.location.touches(point).children == [('location__touches', point)] 340 | 341 | expected = [('location__left', point)] 342 | assert (F.location << point).children == F.location.left(point).children == expected 343 | expected = [('location__right', point)] 344 | assert (F.location >> point).children == F.location.right(point).children == expected 345 | assert F.location.above(point).children == [('location__strictly_above', point)] 346 | assert F.location.below(point).children == [('location__strictly_below', point)] 347 | 348 | 349 | @pytest.mark.skipif(not gis, reason='requires spatial lib') 350 | def test_spatial_functions(books): 351 | from django.contrib.gis.geos import Point 352 | 353 | point = Point(0, 0, srid=4326) 354 | 355 | assert isinstance(F.location.area, gis.functions.Area) 356 | assert isinstance(F.location.geojson(), gis.functions.AsGeoJSON) 357 | assert isinstance(F.location.gml(), gis.functions.AsGML) 358 | assert isinstance(F.location.kml(), gis.functions.AsKML) 359 | assert isinstance(F.location.svg(), gis.functions.AsSVG) 360 | assert isinstance(F.location.bounding_circle(), gis.functions.BoundingCircle) 361 | assert isinstance(F.location.centroid, gis.functions.Centroid) 362 | assert isinstance(F.location.envelope, gis.functions.Envelope) 363 | assert isinstance(F.location.geohash(), gis.functions.GeoHash) 364 | assert isinstance(F.location.make_valid(), gis.functions.MakeValid) 365 | assert isinstance(F.location.mem_size, gis.functions.MemSize) 366 | assert isinstance(F.location.num_geometries, gis.functions.NumGeometries) 367 | assert isinstance(F.location.num_points, gis.functions.NumPoints) 368 | assert isinstance(F.location.perimeter, gis.functions.Perimeter) 369 | assert isinstance(F.location.point_on_surface, gis.functions.PointOnSurface) 370 | assert isinstance(F.location.reverse(), gis.functions.Reverse) 371 | assert isinstance(F.location.scale(0, 0), gis.functions.Scale) 372 | assert isinstance(F.location.snap_to_grid(0), gis.functions.SnapToGrid) 373 | assert isinstance(F.location.transform(point.srid), gis.functions.Transform) 374 | assert isinstance(F.location.translate(0, 0), gis.functions.Translate) 375 | 376 | dist = F.location.distance(point) 377 | assert isinstance(dist, gis.functions.Distance) 378 | dists = (dist < 0), (dist <= 0), (dist > 0), (dist >= 0), dist.within(0) 379 | fields, items = zip(*F.all(dists).children) 380 | assert fields == ( 381 | 'location__distance_lt', 382 | 'location__distance_lte', 383 | 'location__distance_gt', 384 | 'location__distance_gte', 385 | 'location__dwithin', 386 | ) 387 | assert items == ((point, 0),) * 5 388 | ((field, values),) = (F.location.distance(point) > 0).children 389 | assert values == (point, 0) 390 | 391 | assert isinstance(F.location.difference(point), gis.functions.Difference) 392 | assert isinstance(F.location.intersection(point), gis.functions.Intersection) 393 | assert isinstance(F.location.symmetric_difference(point), gis.functions.SymDifference) 394 | assert isinstance(F.location.union(point), gis.functions.Union) 395 | 396 | assert type(books).collect.__name__ == 'Collect' 397 | assert type(books).extent.__name__ == 'Extent' 398 | assert type(books).extent3d.__name__ == 'Extent3D' 399 | assert type(books).make_line.__name__ == 'MakeLine' 400 | assert type(books).union.__name__ == 'Union' 401 | with pytest.raises(ValueError, match="Geospatial aggregates only allowed on geometry fields."): 402 | books['id'].collect() 403 | 404 | 405 | def test_enum(): 406 | @EnumField 407 | class gender(enum.Enum): 408 | M = 'Male' 409 | F = 'Female' 410 | 411 | assert gender.max_length == 1 412 | assert dict(gender.choices) == {'M': 'Male', 'F': 'Female'} 413 | 414 | class Gender(enum.Enum): 415 | MALE = 0 416 | FEMALE = 1 417 | 418 | gender = EnumField(Gender, str.title) 419 | assert isinstance(gender, models.IntegerField) 420 | assert dict(gender.choices) == {0: 'Male', 1: 'Female'} 421 | --------------------------------------------------------------------------------