├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── seal ├── __init__.py ├── apps.py ├── descriptors.py ├── exceptions.py ├── models.py └── query.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── settings.py ├── test_models.py └── test_query.py └── tox.ini /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'charettes/django-seal' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: 3.9 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -U pip 26 | python -m pip install -U setuptools twine wheel 27 | - name: Build package 28 | run: | 29 | python setup.py --version 30 | python setup.py sdist --format=gztar bdist_wheel 31 | twine check dist/* 32 | - name: Upload packages to Pypi 33 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | with: 36 | user: __token__ 37 | password: ${{ secrets.PYPI_API_TOKEN }} 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | max-parallel: 5 11 | matrix: 12 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Get pip cache dir 23 | id: pip-cache 24 | run: | 25 | echo "::set-output name=dir::$(pip cache dir)" 26 | 27 | - name: Cache 28 | uses: actions/cache@v4 29 | with: 30 | path: ${{ steps.pip-cache.outputs.dir }} 31 | key: 32 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} 33 | restore-keys: | 34 | ${{ matrix.python-version }}-v1- 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | python -m pip install --upgrade tox tox-gh-actions 40 | 41 | - name: Tox tests 42 | run: | 43 | tox -v 44 | 45 | - name: Coveralls 46 | uses: AndreMiras/coveralls-python-action@develop 47 | with: 48 | parallel: true 49 | flag-name: Unit Test 50 | 51 | coveralls_finish: 52 | needs: test 53 | runs-on: ubuntu-latest 54 | steps: 55 | - name: Coveralls Finished 56 | uses: AndreMiras/coveralls-python-action@develop 57 | with: 58 | parallel-finished: true 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | syntax:glob 2 | *.py[co] 3 | dist/ 4 | django_seal.egg-info/* 5 | .coverage 6 | .tox 7 | /.mypy_cache/ 8 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 1.7.0 2 | ===== 3 | :release-date: tbd 4 | 5 | - Drop support for Python < 3.9. 6 | - Add tested support for Django 5.2 and Python 3.13. 7 | - Lift restriction on ``django.contrib.contenttypes`` being installed (#84). 8 | 9 | 1.6.3 10 | ===== 11 | :release-date: 2024-09-05 12 | 13 | - Address a crash on unrestricted ``select_related()`` usage (#81) 14 | 15 | 1.6.2 16 | ===== 17 | :release-date: 2024-08-12 18 | 19 | - Add tested support for Django 5.1 and Python 3.12. 20 | - Drop support for Python < 3.8 and Django < 4.2. 21 | 22 | 1.6.1 23 | ===== 24 | :release-date: 2023-09-21 25 | 26 | - Add tested support for Django 5.0 (#78) 27 | - Adjust declarative sealing to respect MRO (#77) 28 | 29 | 1.6.0 30 | ===== 31 | :release-date: 2023-08-26 32 | 33 | - Add declarative sealing for models and managers (#74) 34 | 35 | 1.5.1 36 | ===== 37 | :release-date: 2023-04-26 38 | 39 | - Fixed a bug when pickling related queryset of sealed objects (#71) 40 | 41 | 1.5.0 42 | ===== 43 | :release-date: 2023-02-20 44 | 45 | - Added tested support for Python 3.10, 3.11 and Django 4.0, 4.1, and 4.2. 46 | - Dropped support for Python < 3.7 and Django < 3.2. 47 | 48 | 1.4.4 49 | ===== 50 | :release-date: 2021-07-30 51 | 52 | - Fixed a bug with prefetching of sealed models reverse one-to-one 53 | descriptors (#65) 54 | 55 | 1.4.3 56 | ===== 57 | :release-date: 2021-04-08 58 | 59 | - Address a regression introduced in 1.4.2 that made sealing querysets 60 | prefetching generic relationships return the wrong results. 61 | 62 | 1.4.2 63 | ===== 64 | :release-date: 2021-04-05 65 | 66 | - Properly handled related descriptors ``get_prefetch_queryset`` overrides (#58) 67 | 68 | 1.4.1 69 | ===== 70 | :release-date: 2021-03-30 71 | 72 | - Properly handled ``ForeignKeyDeferredAttribute`` deferral (#56) 73 | 74 | 1.4.0 75 | ===== 76 | :release-date: 2021-01-26 77 | 78 | - Added tested support for Python 3.9 and Django 3.2 79 | - Dropped support for Django 1.11 80 | 81 | 1.3.0 82 | ===== 83 | :release-date: 2021-01-11 84 | 85 | - Added tested support for Django 3.1 86 | - Dropped support for Python 2.7 and 3.5. 87 | - Allowed ``select_related()`` and ``prefetch_related()`` to be called after ``seal()`` (#45) 88 | - Addressed an a crash when combining ``prefetch_related`` string prefixes (#39) 89 | - Addressed a crash when dealing with self-referential many-to-many descriptors (#51) 90 | 91 | 1.2.3 92 | ===== 93 | :release-date: 2020-02-23 94 | 95 | - Added tested support for Python 3.8 and Django 3.0 96 | - Dropped support for Python 3.4 and Django 2.0 and 2.1 97 | - Addressed a ``prefetch_related`` crash against implicit ``related_name`` (#41) 98 | - Prevented sealed accesses on related queryset indexing (#42, #43) 99 | 100 | 1.2.0, 1.2.1, 1.2.2 101 | =================== 102 | :release-date: 2020-02-23 103 | 104 | - Botched releases 105 | 106 | 1.1.0 107 | ===== 108 | :release-date: 2019-02-20 109 | 110 | - Added tested support for Python 3.7 111 | - Added tested support for Django 2.2 112 | - Changed inheritance chain of ``BaseSealableManager`` (#37) 113 | 114 | 1.0.0 115 | ===== 116 | :release-date: 2018-06-05 117 | 118 | - Initial release 119 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Simon Charette, Zapier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-seal 2 | =========== 3 | 4 | .. image:: https://publicdomainvectors.org/photos/Seal2.png 5 | :target: https://publicdomainvectors.org 6 | :alt: Seal 7 | 8 | ------------ 9 | 10 | .. image:: https://github.com/charettes/django-seal/workflows/Test/badge.svg 11 | :target: https://github.com/charettes/django-seal/actions 12 | :alt: Build Status 13 | 14 | .. image:: https://coveralls.io/repos/github/charettes/django-seal/badge.svg?branch=master 15 | :target: https://coveralls.io/github/charettes/django-seal?branch=master 16 | :alt: Coverage status 17 | 18 | 19 | Django application providing queryset sealing capability to force appropriate usage of ``only()``/``defer()`` and 20 | ``select_related()``/``prefetch_related()``. 21 | 22 | Installation 23 | ------------ 24 | 25 | .. code:: sh 26 | 27 | pip install django-seal 28 | 29 | Usage 30 | ----- 31 | 32 | .. code:: python 33 | 34 | # models.py 35 | from django.db import models 36 | from seal.models import SealableModel 37 | 38 | class Location(SealableModel): 39 | latitude = models.FloatField() 40 | longitude = models.FloatField() 41 | 42 | class SeaLion(SealableModel): 43 | height = models.PositiveIntegerField() 44 | weight = models.PositiveIntegerField() 45 | location = models.ForeignKey(Location, models.CASCADE, null=True) 46 | previous_locations = models.ManyToManyField(Location, related_name='previous_visitors') 47 | 48 | By default ``UnsealedAttributeAccess`` warnings will be raised on sealed objects attributes accesses 49 | 50 | .. code:: python 51 | 52 | >>> location = Location.objects.create(latitude=51.585474, longitude=156.634331) 53 | >>> sealion = SeaLion.objects.create(height=1, weight=100, location=location) 54 | >>> sealion.previous_locations.add(location) 55 | >>> SeaLion.objects.only('height').seal().get().weight 56 | UnsealedAttributeAccess:: Attempt to fetch deferred field "weight" on sealed . 57 | >>> SeaLion.objects.seal().get().location 58 | UnsealedAttributeAccess: Attempt to fetch related field "location" on sealed . 59 | >>> SeaLion.objects.seal().get().previous_locations.all() 60 | UnsealedAttributeAccess: Attempt to fetch many-to-many field "previous_locations" on sealed . 61 | 62 | You can `elevate the warnings to exceptions by filtering them`_. This is useful to assert no unsealed attribute accesses are 63 | performed when running your test suite for example. 64 | 65 | .. code:: python 66 | 67 | >>> import warnings 68 | >>> from seal.exceptions import UnsealedAttributeAccess 69 | >>> warnings.filterwarnings('error', category=UnsealedAttributeAccess) 70 | >>> SeaLion.objects.only('height').seal().get().weight 71 | Traceback (most recent call last) 72 | ... 73 | UnsealedAttributeAccess:: Attempt to fetch deferred field "weight" on sealed . 74 | >>> SeaLion.objects.seal().get().location 75 | Traceback (most recent call last) 76 | ... 77 | UnsealedAttributeAccess: Attempt to fetch related field "location" on sealed . 78 | >>> SeaLion.objects.seal().get().previous_locations.all() 79 | Traceback (most recent call last) 80 | ... 81 | UnsealedAttributeAccess: Attempt to fetch many-to-many field "previous_locations" on sealed . 82 | 83 | Or you can `configure logging to capture warnings`_ to log unsealed attribute accesses to the ``py.warnings`` logger which is a 84 | nice way to identify and address unsealed attributes accesses from production logs without taking your application down if some 85 | instances happen to slip through your battery of tests. 86 | 87 | .. code:: python 88 | 89 | >>> import logging 90 | >>> logging.captureWarnings(True) 91 | 92 | .. _elevate the warnings to exceptions by filtering them: https://docs.python.org/3/library/warnings.html#warnings.filterwarnings 93 | .. _configure logging to capture warnings: https://docs.python.org/3/library/logging.html#logging.captureWarnings 94 | 95 | Sealable managers can also be automatically sealed at model definition time to avoid having to call ``seal()`` systematically 96 | by passing ``seal=True`` to ``SealableModel`` subclasses, ``SealableManager`` and ``SealableQuerySet.as_manager``. 97 | 98 | .. code-block:: python 99 | 100 | from django.db import models 101 | from seal.models import SealableManager, SealableModel, SealableQuerySet 102 | 103 | class Location(SealableModel, seal=True): 104 | latitude = models.FloatField() 105 | longitude = models.FloatField() 106 | 107 | class SeaLion(SealableModel): 108 | height = models.PositiveIntegerField() 109 | weight = models.PositiveIntegerField() 110 | location = models.ForeignKey(Location, models.CASCADE, null=True) 111 | previous_locations = models.ManyToManyField(Location, related_name='previous_visitors') 112 | 113 | objects = SealableManager(seal=True) 114 | others = SealableQuerySet.as_manager(seal=True) 115 | 116 | Development 117 | ----------- 118 | 119 | Make your changes, and then run tests via tox: 120 | 121 | .. code:: sh 122 | 123 | tox 124 | -------------------------------------------------------------------------------- /seal/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charettes/django-seal/a77a3539c093d9070f7fa42e4b71c1abbce4444d/seal/__init__.py -------------------------------------------------------------------------------- /seal/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig, apps 2 | 3 | 4 | class SealAppConfig(AppConfig): 5 | name = __name__ 6 | 7 | def ready(self): 8 | from .descriptors import make_contenttypes_sealable 9 | from .models import SealableModel, make_model_sealable 10 | 11 | try: 12 | apps.get_app_config("contenttypes") 13 | except LookupError: 14 | pass 15 | else: 16 | make_contenttypes_sealable() 17 | 18 | for model in apps.get_models(): 19 | opts = model._meta 20 | if opts.proxy or not issubclass(model, SealableModel): 21 | continue 22 | make_model_sealable(model) 23 | -------------------------------------------------------------------------------- /seal/descriptors.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from functools import lru_cache 3 | 4 | from django.db.models import QuerySet 5 | from django.db.models.fields import DeferredAttribute 6 | from django.db.models.fields.related import ( 7 | ForeignKeyDeferredAttribute, 8 | ForwardManyToOneDescriptor, 9 | ForwardOneToOneDescriptor, 10 | ManyToManyDescriptor, 11 | ReverseManyToOneDescriptor, 12 | ReverseOneToOneDescriptor, 13 | ) 14 | from django.utils.functional import cached_property 15 | 16 | from . import models 17 | from .exceptions import UnsealedAttributeAccess 18 | from .query import SealableQuerySet 19 | 20 | 21 | def _bare_repr(instance): 22 | return "<%s instance>" % instance.__class__.__name__ 23 | 24 | 25 | class _SealedRelatedQuerySet(QuerySet): 26 | """ 27 | QuerySet that prevents any fetching from taking place on its current form. 28 | 29 | As soon as the query is cloned it gets unsealed. 30 | """ 31 | 32 | def _clone(self, *args, **kwargs): 33 | clone = super()._clone(*args, **kwargs) 34 | clone.__class__ = self._unsealed_class 35 | return clone 36 | 37 | def __getitem__(self, item): 38 | if self._result_cache is None: 39 | warnings.warn( 40 | self._sealed_warning, category=UnsealedAttributeAccess, stacklevel=2 41 | ) 42 | return super().__getitem__(item) 43 | 44 | def _fetch_all(self): 45 | if self._result_cache is None: 46 | warnings.warn( 47 | self._sealed_warning, category=UnsealedAttributeAccess, stacklevel=3 48 | ) 49 | super()._fetch_all() 50 | 51 | def __reduce__(self): 52 | return ( 53 | _unpickle_sealed_related_queryset, 54 | (self._unsealed_class,), 55 | self.__getstate__(), 56 | ) 57 | 58 | 59 | class SealedPrefetchMixin(object): 60 | def _get_default_prefetch_queryset(self): 61 | return self.get_queryset() 62 | 63 | def get_prefetch_queryset(self, instances, queryset=None): 64 | if queryset is None: 65 | queryset = self._get_default_prefetch_queryset() 66 | if getattr(instances[0]._state, "sealed", False) and isinstance( 67 | queryset, SealableQuerySet 68 | ): 69 | queryset = queryset.seal() 70 | return super().get_prefetch_queryset(instances, queryset) 71 | 72 | def get_prefetch_querysets(self, instances, querysets=None): 73 | if querysets is None: 74 | querysets = [self._get_default_prefetch_queryset()] 75 | if getattr(instances[0]._state, "sealed", False): 76 | querysets = [ 77 | queryset.seal() if isinstance(queryset, SealableQuerySet) else queryset 78 | for queryset in querysets 79 | ] 80 | return super().get_prefetch_querysets(instances, querysets) 81 | 82 | 83 | @lru_cache(maxsize=100) 84 | def _sealed_related_queryset_type_factory(queryset_cls): 85 | if issubclass(queryset_cls, _SealedRelatedQuerySet): 86 | return queryset_cls 87 | return type( 88 | f"Sealed{queryset_cls.__name__}", 89 | (_SealedRelatedQuerySet, queryset_cls), 90 | { 91 | "_unsealed_class": queryset_cls, 92 | }, 93 | ) 94 | 95 | 96 | def _unpickle_sealed_related_queryset(queryset_cls): 97 | cls = _sealed_related_queryset_type_factory(queryset_cls) 98 | return cls.__new__(cls) 99 | 100 | 101 | def seal_related_queryset(queryset, warning): 102 | """ 103 | Seal a related queryset to prevent it from being fetched directly. 104 | """ 105 | queryset.__class__ = _sealed_related_queryset_type_factory(queryset.__class__) 106 | queryset._sealed_warning = warning 107 | return queryset 108 | 109 | 110 | def create_sealable_related_manager(related_manager_cls, field_name): 111 | class SealableRelatedManager(SealedPrefetchMixin, related_manager_cls): 112 | def _get_default_prefetch_queryset(self): 113 | # By-pass `related_manager_cls.get_queryset()` as that's the default 114 | # for dynamically created manager's `get_prefetch_queryset` when 115 | # no `queryset` is specified. 116 | return super(related_manager_cls, self).get_queryset() 117 | 118 | def get_queryset(self): 119 | if getattr(self.instance._state, "sealed", False): 120 | try: 121 | prefetch_cache_name = self.prefetch_cache_name 122 | except AttributeError: 123 | prefetch_cache_name = self.field.related_query_name() 124 | try: 125 | return self.instance._prefetched_objects_cache[prefetch_cache_name] 126 | except (AttributeError, KeyError): 127 | warning = ( 128 | 'Attempt to fetch many-to-many field "%s" on sealed %s.' 129 | % ( 130 | field_name, 131 | _bare_repr(self.instance), 132 | ) 133 | ) 134 | related_queryset = super().get_queryset() 135 | return seal_related_queryset(related_queryset, warning) 136 | return super().get_queryset() 137 | 138 | return SealableRelatedManager 139 | 140 | 141 | class SealableDeferredAttribute(DeferredAttribute): 142 | @cached_property 143 | def field_name(self): 144 | return self.field.attname 145 | 146 | def _check_parent_chain(self, instance, field_name=None): 147 | super()._check_parent_chain(instance) 148 | 149 | def __get__(self, instance, cls=None): 150 | if instance is None: 151 | return self 152 | if ( 153 | getattr(instance._state, "sealed", False) 154 | and instance.__dict__.get(self.field_name, self) is self 155 | and self._check_parent_chain(instance, self.field_name) is None 156 | ): 157 | message = 'Attempt to fetch deferred field "%s" on sealed %s.' % ( 158 | self.field_name, 159 | _bare_repr(instance), 160 | ) 161 | warnings.warn(message, category=UnsealedAttributeAccess, stacklevel=2) 162 | return super().__get__(instance, cls) 163 | 164 | 165 | class SealableForwardOneToOneDescriptor(SealedPrefetchMixin, ForwardOneToOneDescriptor): 166 | def get_object(self, instance): 167 | sealed = getattr(instance._state, "sealed", False) 168 | if sealed: 169 | rel_model = self.field.remote_field.model 170 | if self.field.remote_field.parent_link and issubclass( 171 | rel_model, models.SealableModel 172 | ): 173 | deferred = instance.get_deferred_fields() 174 | # Because it's a parent link, all the data is available in the 175 | # instance, so populate the parent model with this data. 176 | 177 | fields = {field.attname for field in rel_model._meta.concrete_fields} 178 | 179 | # If any of the related model's fields are deferred, prevent 180 | # the query from being performed. 181 | if any(field in fields for field in deferred): 182 | message = 'Attempt to fetch related field "%s" on sealed %s.' % ( 183 | self.field.name, 184 | _bare_repr(instance), 185 | ) 186 | warnings.warn( 187 | message, category=UnsealedAttributeAccess, stacklevel=3 188 | ) 189 | else: 190 | # When none of the fields inherited from the parent link 191 | # are deferred ForwardOneToOneDescriptor.get_object() simply 192 | # create an in-memory object from the existing field values. 193 | # Make sure this in-memory instance is sealed as well. 194 | obj = super().get_object(instance) 195 | obj.seal() 196 | return obj 197 | else: 198 | message = 'Attempt to fetch related field "%s" on sealed %s.' % ( 199 | self.field.name, 200 | _bare_repr(instance), 201 | ) 202 | warnings.warn(message, category=UnsealedAttributeAccess, stacklevel=3) 203 | return super().get_object(instance) 204 | 205 | 206 | class SealableReverseOneToOneDescriptor(SealedPrefetchMixin, ReverseOneToOneDescriptor): 207 | def get_queryset(self, **hints): 208 | instance = hints.get("instance") 209 | if instance and getattr(instance._state, "sealed", False): 210 | message = 'Attempt to fetch related field "%s" on sealed %s.' % ( 211 | self.related.name, 212 | _bare_repr(instance), 213 | ) 214 | warnings.warn(message, category=UnsealedAttributeAccess, stacklevel=3) 215 | return super().get_queryset(**hints) 216 | 217 | 218 | class SealableForwardManyToOneDescriptor(ForwardManyToOneDescriptor): 219 | def get_object(self, instance): 220 | if getattr(instance._state, "sealed", False): 221 | message = 'Attempt to fetch related field "%s" on sealed %s.' % ( 222 | self.field.name, 223 | _bare_repr(instance), 224 | ) 225 | warnings.warn(message, category=UnsealedAttributeAccess, stacklevel=3) 226 | return super().get_object(instance) 227 | 228 | 229 | class SealableReverseManyToOneDescriptor(ReverseManyToOneDescriptor): 230 | @cached_property 231 | def related_manager_cls(self): 232 | related_manager_cls = super().related_manager_cls 233 | return create_sealable_related_manager(related_manager_cls, self.rel.name) 234 | 235 | 236 | class SealableManyToManyDescriptor(ManyToManyDescriptor): 237 | @cached_property 238 | def related_manager_cls(self): 239 | related_manager_cls = super().related_manager_cls 240 | field_name = self.rel.name if self.reverse else self.field.name 241 | return create_sealable_related_manager(related_manager_cls, field_name) 242 | 243 | 244 | class SealableForeignKeyDeferredAttribute( 245 | SealableDeferredAttribute, ForeignKeyDeferredAttribute 246 | ): 247 | pass 248 | 249 | 250 | sealable_descriptor_classes = { 251 | DeferredAttribute: SealableDeferredAttribute, 252 | ForwardOneToOneDescriptor: SealableForwardOneToOneDescriptor, 253 | ReverseOneToOneDescriptor: SealableReverseOneToOneDescriptor, 254 | ForwardManyToOneDescriptor: SealableForwardManyToOneDescriptor, 255 | ReverseManyToOneDescriptor: SealableReverseManyToOneDescriptor, 256 | ManyToManyDescriptor: SealableManyToManyDescriptor, 257 | ForeignKeyDeferredAttribute: SealableForeignKeyDeferredAttribute, 258 | } 259 | 260 | 261 | def make_contenttypes_sealable(): 262 | from django.contrib.contenttypes.fields import ( 263 | GenericForeignKey, 264 | ReverseGenericManyToOneDescriptor, 265 | ) 266 | 267 | # Ensure the function is idempotent. 268 | if GenericForeignKey in sealable_descriptor_classes: 269 | return 270 | 271 | class SealableGenericForeignKey(GenericForeignKey): 272 | def __get__(self, instance, cls=None): 273 | if instance is None: 274 | return self 275 | 276 | if getattr(instance._state, "sealed", False) and not self.is_cached( 277 | instance 278 | ): 279 | message = 'Attempt to fetch related field "%s" on sealed %s.' % ( 280 | self.name, 281 | _bare_repr(instance), 282 | ) 283 | warnings.warn(message, category=UnsealedAttributeAccess, stacklevel=2) 284 | 285 | return super().__get__(instance, cls=cls) 286 | 287 | class SealableReverseGenericManyToOneDescriptor(ReverseGenericManyToOneDescriptor): 288 | @cached_property 289 | def related_manager_cls(self): 290 | related_manager_cls = super().related_manager_cls 291 | return create_sealable_related_manager(related_manager_cls, self.field.name) 292 | 293 | sealable_descriptor_classes[GenericForeignKey] = SealableGenericForeignKey 294 | sealable_descriptor_classes[ReverseGenericManyToOneDescriptor] = ( 295 | SealableReverseGenericManyToOneDescriptor 296 | ) 297 | -------------------------------------------------------------------------------- /seal/exceptions.py: -------------------------------------------------------------------------------- 1 | class UnsealedAttributeAccess(AttributeError, Warning): 2 | pass 3 | -------------------------------------------------------------------------------- /seal/models.py: -------------------------------------------------------------------------------- 1 | from django.core import checks 2 | from django.db import models 3 | from django.db.models.fields.related import lazy_related_operation 4 | 5 | from . import descriptors 6 | from .query import SealableQuerySet 7 | 8 | 9 | class BaseSealableManager(models.manager.Manager): 10 | def __init__(self, seal=None): 11 | self._seal_queryset = seal 12 | super().__init__() 13 | 14 | def _get_model(self): 15 | return self._model 16 | 17 | def _set_model(self, model): 18 | self._model = model 19 | if self._seal_queryset is None: 20 | self._seal_queryset = getattr(model, "_seal_managers", None) 21 | 22 | # Intercept .model assignment to inherit ._seal_managers as the 23 | # contribute_to_class() method is not called abstract model inheritance 24 | # of managers. 25 | model = property(_get_model, _set_model) 26 | 27 | def get_queryset(self): 28 | queryset = super().get_queryset() 29 | if self._seal_queryset: 30 | queryset = queryset.seal() 31 | return queryset 32 | 33 | def check(self, **kwargs): 34 | errors = super().check(**kwargs) 35 | if not issubclass(self.model, SealableModel): 36 | if getattr(self, "_built_with_as_manager", False): 37 | origin = "%s.as_manager()" % self._queryset_class.__name__ 38 | else: 39 | origin = self.__class__.__name__ 40 | errors.append( 41 | checks.Error( 42 | "%s can only be used on seal.SealableModel subclasses." % origin, 43 | id="seal.E001", 44 | hint="Make %s inherit from seal.SealableModel." 45 | % self.model._meta.label, 46 | obj=self, 47 | ) 48 | ) 49 | return errors 50 | 51 | 52 | SealableQuerySet._base_manager_class = BaseSealableManager 53 | SealableManager = BaseSealableManager.from_queryset(SealableQuerySet, "SealableManager") 54 | 55 | 56 | class SealableModel(models.Model): 57 | """ 58 | Abstract model class that turns deferred and related fields accesses that 59 | would incur a database query into exceptions once sealed. 60 | """ 61 | 62 | def __init_subclass__(cls, seal=None, **kwargs): 63 | if seal is None: 64 | seal = getattr(cls, "_seal_managers", seal) 65 | cls._seal_managers = seal 66 | return super().__init_subclass__(**kwargs) 67 | 68 | objects = SealableManager() 69 | 70 | class Meta: 71 | abstract = True 72 | 73 | def seal(self): 74 | """ 75 | Seal the instance to turn deferred and related fields access that would 76 | required fetching from the database into exceptions. 77 | """ 78 | self._state.sealed = True 79 | 80 | 81 | def make_descriptor_sealable(model, attname): 82 | """ 83 | Make a descriptor sealable if a sealable class is defined. 84 | """ 85 | try: 86 | descriptor = getattr(model, attname) 87 | except AttributeError: 88 | # Handle hidden reverse accessor case. e.g. related_name='+' 89 | return 90 | sealable_descriptor_class = descriptors.sealable_descriptor_classes.get( 91 | descriptor.__class__ 92 | ) 93 | if sealable_descriptor_class: 94 | descriptor.__class__ = sealable_descriptor_class 95 | 96 | 97 | def make_remote_field_descriptor_sealable(model, related_model, remote_field): 98 | """ 99 | Make a remote field descriptor sealable if a sealable class is defined. 100 | """ 101 | if not issubclass(related_model, SealableModel): 102 | return 103 | accessor_name = remote_field.get_accessor_name() 104 | # Self-referential many-to-many fields don't have a reverse accessor. 105 | if accessor_name is None: 106 | return 107 | make_descriptor_sealable(related_model, accessor_name) 108 | 109 | 110 | def make_model_sealable(model): 111 | """ 112 | Replace forward fields descriptors by sealable ones and reverse fields 113 | descriptors attached to SealableModel subclasses as well. 114 | 115 | This function should be called on a third-party model once all apps are 116 | done loading models such as from an AppConfig.ready(). 117 | """ 118 | opts = model._meta 119 | for field in opts.local_fields + opts.local_many_to_many + opts.private_fields: 120 | name = field.name 121 | attnames = {name, getattr(field, "attname", name)} 122 | for attname in attnames: 123 | make_descriptor_sealable(model, attname) 124 | remote_field = field.remote_field 125 | if remote_field: 126 | # Use lazy_related_operation because lazy relationships might not 127 | # be resolved yet. 128 | lazy_related_operation( 129 | make_remote_field_descriptor_sealable, 130 | model, 131 | remote_field.model, 132 | remote_field=remote_field, 133 | ) 134 | # Non SealableModel subclasses won't have remote fields descriptors 135 | # attached to them made sealable so make sure to make locally defined 136 | # related objects sealable. 137 | if not issubclass(model, SealableModel): 138 | for related_object in opts.related_objects: 139 | make_descriptor_sealable(model, related_object.get_accessor_name()) 140 | -------------------------------------------------------------------------------- /seal/query.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from operator import attrgetter 3 | 4 | from django.db import models 5 | from django.db.models.query_utils import select_related_descend 6 | 7 | cached_value_getter = attrgetter("get_cached_value") 8 | 9 | 10 | def get_restricted_select_related_getters(lookups, opts): 11 | """Turn a select_related dict structure into a tree of attribute getters""" 12 | for lookup, nested_lookups in lookups.items(): 13 | field = opts.get_field(lookup) 14 | lookup_opts = field.related_model._meta 15 | yield ( 16 | cached_value_getter(field), 17 | tuple(get_restricted_select_related_getters(nested_lookups, lookup_opts)), 18 | ) 19 | 20 | 21 | def get_unrestricted_select_related_getters(opts, max_depth, cur_depth=1): 22 | if cur_depth > max_depth: 23 | return 24 | for field in opts.fields: 25 | if not select_related_descend(field, False, None, {}): 26 | continue 27 | related_model_meta = field.related_model._meta 28 | yield ( 29 | cached_value_getter(field), 30 | tuple( 31 | get_unrestricted_select_related_getters( 32 | related_model_meta, max_depth=max_depth, cur_depth=cur_depth + 1 33 | ) 34 | ), 35 | ) 36 | 37 | 38 | def walk_select_relateds(obj, getters): 39 | """Walk select related of obj from getters.""" 40 | for getter, nested_getters in getters: 41 | related_obj = getter(obj) 42 | if related_obj is None: 43 | # We don't need to seal a None relation or any of its children. 44 | continue 45 | yield related_obj 46 | yield from walk_select_relateds(related_obj, nested_getters) 47 | 48 | 49 | class SealedModelIterable(models.query.ModelIterable): 50 | def _sealed_iterator(self): 51 | """Iterate over objects and seal them.""" 52 | objs = super().__iter__() 53 | for obj in objs: 54 | obj._state.sealed = True 55 | yield obj 56 | 57 | def _sealed_related_iterator(self, related_walker): 58 | """Iterate over objects and seal them and their select related.""" 59 | for obj in self._sealed_iterator(): 60 | for related_obj in related_walker(obj): 61 | related_obj._state.sealed = True 62 | yield obj 63 | 64 | def __iter__(self): 65 | query = self.queryset.query 66 | select_related = query.select_related 67 | if select_related: 68 | opts = self.queryset.model._meta 69 | if isinstance(select_related, dict): 70 | select_related_getters = tuple( 71 | get_restricted_select_related_getters( 72 | self.queryset.query.select_related, opts 73 | ) 74 | ) 75 | else: 76 | select_related_getters = tuple( 77 | get_unrestricted_select_related_getters( 78 | opts, max_depth=query.max_depth 79 | ) 80 | ) 81 | related_walker = partial( 82 | walk_select_relateds, getters=select_related_getters 83 | ) 84 | iterator = self._sealed_related_iterator(related_walker) 85 | else: 86 | iterator = self._sealed_iterator() 87 | yield from iterator 88 | 89 | 90 | class SealableQuerySet(models.QuerySet): 91 | _base_manager_class = None 92 | 93 | def as_manager(cls, seal=None): 94 | manager = cls._base_manager_class.from_queryset(cls)(seal=seal) 95 | manager._built_with_as_manager = True 96 | return manager 97 | 98 | as_manager.queryset_only = True 99 | as_manager = classmethod(as_manager) 100 | 101 | def seal(self, iterable_class=SealedModelIterable): 102 | if self._fields is not None: 103 | raise TypeError("Cannot call seal() after .values() or .values_list()") 104 | if not issubclass(iterable_class, SealedModelIterable): 105 | raise TypeError( 106 | "iterable_class %r is not a subclass of SealedModelIterable" 107 | % iterable_class 108 | ) 109 | clone = self._clone() 110 | clone._iterable_class = iterable_class 111 | return clone 112 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | combine_as_imports=true 3 | include_trailing_comma=true 4 | multi_line_output=3 5 | 6 | [coverage:run] 7 | source = seal 8 | branch = 1 9 | relative_files = 1 10 | 11 | [flake8] 12 | max-line-length = 119 13 | 14 | [wheel] 15 | universal = 1 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import find_packages, setup 3 | 4 | with open("README.rst") as file_: 5 | long_description = file_.read() 6 | 7 | setup( 8 | name="django-seal", 9 | version="1.6.3", 10 | description=( 11 | "Allows ORM constructs to be sealed to prevent them from executing " 12 | "queries on attribute accesses." 13 | ), 14 | long_description=long_description, 15 | long_description_content_type="text/x-rst", 16 | url="https://github.com/charettes/django-seal", 17 | author="Simon Charette", 18 | author_email="simon.charette@zapier.com", 19 | install_requires=[ 20 | "Django>=4.2", 21 | ], 22 | packages=find_packages(exclude=["tests", "tests.*"]), 23 | license="MIT License", 24 | classifiers=[ 25 | "Development Status :: 5 - Production/Stable", 26 | "Environment :: Web Environment", 27 | "Framework :: Django", 28 | "Framework :: Django :: 4.2", 29 | "Framework :: Django :: 5.0", 30 | "Framework :: Django :: 5.1", 31 | "Framework :: Django :: 5.2", 32 | "Intended Audience :: Developers", 33 | "License :: OSI Approved :: MIT License", 34 | "Operating System :: OS Independent", 35 | "Programming Language :: Python", 36 | "Programming Language :: Python :: 3", 37 | "Programming Language :: Python :: 3.9", 38 | "Programming Language :: Python :: 3.10", 39 | "Programming Language :: Python :: 3.11", 40 | "Programming Language :: Python :: 3.12", 41 | "Programming Language :: Python :: 3.13", 42 | "Topic :: Software Development :: Libraries :: Python Modules", 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charettes/django-seal/a77a3539c093d9070f7fa42e4b71c1abbce4444d/tests/__init__.py -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.20 on 2019-02-19 16:03 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [("contenttypes", "0002_remove_content_type_name")] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Climate", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("temperature", models.IntegerField()), 26 | ], 27 | options={"abstract": False}, 28 | ), 29 | migrations.CreateModel( 30 | name="Leak", 31 | fields=[ 32 | ( 33 | "id", 34 | models.AutoField( 35 | auto_created=True, 36 | primary_key=True, 37 | serialize=False, 38 | verbose_name="ID", 39 | ), 40 | ), 41 | ("description", models.TextField()), 42 | ], 43 | ), 44 | migrations.CreateModel( 45 | name="Location", 46 | fields=[ 47 | ( 48 | "id", 49 | models.AutoField( 50 | auto_created=True, 51 | primary_key=True, 52 | serialize=False, 53 | verbose_name="ID", 54 | ), 55 | ), 56 | ("latitude", models.FloatField()), 57 | ("longitude", models.FloatField()), 58 | ( 59 | "climates", 60 | models.ManyToManyField( 61 | blank=True, related_name="locations", to="tests.Climate" 62 | ), 63 | ), 64 | ( 65 | "related_locations", 66 | models.ManyToManyField(blank=True, to="tests.Location"), 67 | ), 68 | ], 69 | options={"abstract": False}, 70 | ), 71 | migrations.CreateModel( 72 | name="Nickname", 73 | fields=[ 74 | ( 75 | "id", 76 | models.AutoField( 77 | auto_created=True, 78 | primary_key=True, 79 | serialize=False, 80 | verbose_name="ID", 81 | ), 82 | ), 83 | ("name", models.CharField(max_length=24)), 84 | ("object_id", models.PositiveIntegerField()), 85 | ( 86 | "content_type", 87 | models.ForeignKey( 88 | on_delete=django.db.models.deletion.CASCADE, 89 | to="contenttypes.ContentType", 90 | ), 91 | ), 92 | ], 93 | options={"abstract": False}, 94 | ), 95 | migrations.CreateModel( 96 | name="SeaGull", 97 | fields=[ 98 | ( 99 | "id", 100 | models.AutoField( 101 | auto_created=True, 102 | primary_key=True, 103 | serialize=False, 104 | verbose_name="ID", 105 | ), 106 | ) 107 | ], 108 | options={"abstract": False}, 109 | ), 110 | migrations.CreateModel( 111 | name="SeaLion", 112 | fields=[ 113 | ( 114 | "id", 115 | models.AutoField( 116 | auto_created=True, 117 | primary_key=True, 118 | serialize=False, 119 | verbose_name="ID", 120 | ), 121 | ), 122 | ("height", models.PositiveIntegerField()), 123 | ("weight", models.PositiveIntegerField()), 124 | ], 125 | options={"abstract": False}, 126 | ), 127 | migrations.CreateModel( 128 | name="GreatSeaLion", 129 | fields=[ 130 | ( 131 | "sealion_ptr", 132 | models.OneToOneField( 133 | auto_created=True, 134 | on_delete=django.db.models.deletion.CASCADE, 135 | parent_link=True, 136 | primary_key=True, 137 | serialize=False, 138 | to="tests.SeaLion", 139 | ), 140 | ) 141 | ], 142 | options={"abstract": False}, 143 | bases=("tests.sealion",), 144 | ), 145 | migrations.AddField( 146 | model_name="sealion", 147 | name="leak", 148 | field=models.ForeignKey( 149 | null=True, 150 | on_delete=django.db.models.deletion.CASCADE, 151 | related_name="sealion_just_friends", 152 | to="tests.Leak", 153 | ), 154 | ), 155 | migrations.AddField( 156 | model_name="sealion", 157 | name="leak_o2o", 158 | field=models.OneToOneField( 159 | null=True, 160 | on_delete=django.db.models.deletion.CASCADE, 161 | related_name="sealion_soulmate", 162 | to="tests.Leak", 163 | ), 164 | ), 165 | migrations.AddField( 166 | model_name="sealion", 167 | name="location", 168 | field=models.ForeignKey( 169 | null=True, 170 | on_delete=django.db.models.deletion.CASCADE, 171 | related_name="visitors", 172 | to="tests.Location", 173 | ), 174 | ), 175 | migrations.AddField( 176 | model_name="sealion", 177 | name="previous_locations", 178 | field=models.ManyToManyField( 179 | related_name="previous_visitors", to="tests.Location" 180 | ), 181 | ), 182 | migrations.AddField( 183 | model_name="seagull", 184 | name="sealion", 185 | field=models.OneToOneField( 186 | null=True, 187 | on_delete=django.db.models.deletion.CASCADE, 188 | related_name="gull", 189 | to="tests.SeaLion", 190 | ), 191 | ), 192 | migrations.CreateModel( 193 | name="SealionProxy", 194 | fields=[], 195 | options={"proxy": True, "indexes": []}, 196 | bases=("tests.sealion",), 197 | ), 198 | migrations.CreateModel( 199 | name="Island", 200 | fields=[ 201 | ( 202 | "id", 203 | models.AutoField( 204 | auto_created=True, 205 | primary_key=True, 206 | serialize=False, 207 | verbose_name="ID", 208 | ), 209 | ), 210 | ( 211 | "location", 212 | models.ForeignKey( 213 | to="tests.Location", 214 | on_delete=django.db.models.deletion.CASCADE, 215 | ), 216 | ), 217 | ], 218 | ), 219 | ] 220 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charettes/django-seal/a77a3539c093d9070f7fa42e4b71c1abbce4444d/tests/migrations/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.fields import ( 2 | GenericForeignKey, 3 | GenericRelation, 4 | ) 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.db import models 7 | 8 | from seal.models import SealableModel 9 | 10 | 11 | class Nickname(SealableModel): 12 | name = models.CharField(max_length=24) 13 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 14 | object_id = models.PositiveIntegerField() 15 | content_object = GenericForeignKey("content_type", "object_id") 16 | 17 | 18 | class Climate(SealableModel): 19 | temperature = models.IntegerField() 20 | 21 | 22 | class Location(SealableModel): 23 | latitude = models.FloatField() 24 | longitude = models.FloatField() 25 | climates = models.ManyToManyField(Climate, blank=True, related_name="locations") 26 | related_locations = models.ManyToManyField("self") 27 | 28 | 29 | class Island(SealableModel): 30 | # Explicitly avoid setting a related_name. 31 | location = models.ForeignKey(Location, on_delete=models.CASCADE) 32 | 33 | 34 | class Leak(models.Model): 35 | description = models.TextField() 36 | 37 | 38 | class SeaLion(SealableModel): 39 | height = models.PositiveIntegerField() 40 | weight = models.PositiveIntegerField() 41 | location = models.ForeignKey( 42 | Location, models.CASCADE, null=True, related_name="visitors" 43 | ) 44 | previous_locations = models.ManyToManyField( 45 | Location, related_name="previous_visitors" 46 | ) 47 | leak = models.ForeignKey( 48 | Leak, models.CASCADE, null=True, related_name="sealion_just_friends" 49 | ) 50 | leak_o2o = models.OneToOneField( 51 | Leak, models.CASCADE, null=True, related_name="sealion_soulmate" 52 | ) 53 | 54 | def __str__(self): 55 | return repr(self) 56 | 57 | def __repr__(self): 58 | return "" % (self.id, self.height, self.weight) 59 | 60 | 61 | class SeaLionAbstractSubclass(SeaLion): 62 | class Meta: 63 | abstract = True 64 | 65 | 66 | class SealionProxy(SeaLion): 67 | class Meta: 68 | proxy = True 69 | 70 | 71 | class GreatSeaLion(SeaLion): 72 | pass 73 | 74 | 75 | class SeaGull(SealableModel): 76 | sealion = models.OneToOneField( 77 | SeaLion, models.CASCADE, null=True, related_name="gull" 78 | ) 79 | nicknames = GenericRelation("Nickname") 80 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "not-secret-anymore" 2 | 3 | TIME_ZONE = "America/Montreal" 4 | 5 | DATABASES = { 6 | "default": { 7 | "ENGINE": "django.db.backends.sqlite3", 8 | }, 9 | } 10 | 11 | INSTALLED_APPS = [ 12 | "django.contrib.contenttypes", 13 | "seal", 14 | "tests", 15 | ] 16 | 17 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 18 | 19 | USE_TZ = False 20 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.apps import apps 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.core import checks 6 | from django.db import models 7 | from django.test import SimpleTestCase, TestCase 8 | from django.test.utils import isolate_apps 9 | 10 | from seal.descriptors import ( 11 | SealableDeferredAttribute, 12 | SealableForwardManyToOneDescriptor, 13 | SealableForwardOneToOneDescriptor, 14 | SealableManyToManyDescriptor, 15 | SealableReverseManyToOneDescriptor, 16 | SealableReverseOneToOneDescriptor, 17 | ) 18 | from seal.exceptions import UnsealedAttributeAccess 19 | from seal.models import SealableManager, SealableModel, make_model_sealable 20 | from seal.query import SealableQuerySet 21 | 22 | from .models import GreatSeaLion, Location, Nickname, SeaGull, SeaLion 23 | 24 | 25 | class SealableModelTests(SimpleTestCase): 26 | def setUp(self): 27 | warnings.filterwarnings("error", category=UnsealedAttributeAccess) 28 | self.addCleanup(warnings.resetwarnings) 29 | 30 | def test_sealed_instance_deferred_attribute_access(self): 31 | instance = SeaLion.from_db("default", ["id"], [1]) 32 | instance.seal() 33 | message = ( 34 | 'Attempt to fetch deferred field "weight" on sealed ' 35 | ) 36 | with self.assertRaisesMessage(UnsealedAttributeAccess, message): 37 | instance.weight 38 | 39 | def test_sealed_instance_deferred_foreign_key_attribute_access(self): 40 | instance = SeaLion.from_db("default", ["id"], [1]) 41 | instance.seal() 42 | message = ( 43 | 'Attempt to fetch deferred field "location_id" on sealed ' 44 | ) 45 | with self.assertRaisesMessage(UnsealedAttributeAccess, message): 46 | instance.location_id 47 | 48 | def test_sealed_instance_foreign_key_access(self): 49 | instance = SeaLion.from_db("default", ["id", "location_id"], [1, 1]) 50 | instance.seal() 51 | message = ( 52 | 'Attempt to fetch related field "location" on sealed ' 53 | ) 54 | with self.assertRaisesMessage(UnsealedAttributeAccess, message): 55 | instance.location 56 | 57 | def test_sealed_instance_reverse_foreign_key_access(self): 58 | instance = Location.from_db("default", ["id"], [1]) 59 | instance.seal() 60 | message = 'Attempt to fetch many-to-many field "visitors" on sealed ' 61 | visitors = instance.visitors.all() 62 | with self.assertRaisesMessage(UnsealedAttributeAccess, message): 63 | list(visitors) 64 | 65 | def test_sealed_instance_one_to_one_access(self): 66 | instance = SeaGull.from_db("default", ["id", "sealion_id"], [1, 1]) 67 | instance.seal() 68 | message = ( 69 | 'Attempt to fetch related field "sealion" on sealed ' 70 | ) 71 | with self.assertRaisesMessage(UnsealedAttributeAccess, message): 72 | instance.sealion 73 | 74 | def test_sealed_instance_reverse_one_to_one_access(self): 75 | instance = SeaLion.from_db("default", ["id"], [1]) 76 | instance.seal() 77 | message = 'Attempt to fetch related field "gull" on sealed ' 78 | with self.assertRaisesMessage(UnsealedAttributeAccess, message): 79 | instance.gull 80 | 81 | def test_sealed_instance_parent_link_access(self): 82 | instance = SeaLion.from_db("default", ["id"], [1]) 83 | instance.seal() 84 | message = ( 85 | 'Attempt to fetch related field "greatsealion" on sealed ' 86 | ) 87 | with self.assertRaisesMessage(UnsealedAttributeAccess, message): 88 | instance.greatsealion 89 | 90 | def test_sealed_instance_reverse_parent_link_access(self): 91 | instance = GreatSeaLion.from_db("default", ["sealion_ptr_id"], [1]) 92 | instance.seal() 93 | message = 'Attempt to fetch related field "sealion_ptr" on sealed ' 94 | with self.assertRaisesMessage(UnsealedAttributeAccess, message): 95 | instance.sealion_ptr 96 | 97 | def test_sealed_instance_reverse_parent_link_access_sealed(self): 98 | instance = GreatSeaLion.from_db( 99 | "default", 100 | [ 101 | "id", 102 | "sealion_ptr_id", 103 | "height", 104 | "weight", 105 | "location_id", 106 | "leak_id", 107 | "leak_o2o_id", 108 | ], 109 | [1, 1, 1, 1, 1, 1, 1], 110 | ) 111 | instance.seal() 112 | message = ( 113 | 'Attempt to fetch related field "location" on sealed ' 114 | ) 115 | with self.assertRaisesMessage(UnsealedAttributeAccess, message): 116 | instance.sealion_ptr.location 117 | 118 | def test_sealed_instance_m2m_access(self): 119 | instance = SeaLion.from_db("default", ["id"], [1]) 120 | instance.seal() 121 | message = 'Attempt to fetch many-to-many field "previous_locations" on sealed ' 122 | previous_locations = instance.previous_locations.all() 123 | with self.assertRaisesMessage(UnsealedAttributeAccess, message): 124 | list(previous_locations) 125 | 126 | def test_sealed_instance_reverse_m2m_access(self): 127 | instance = Location.from_db("default", ["id"], [1]) 128 | instance.seal() 129 | message = 'Attempt to fetch many-to-many field "previous_visitors" on sealed ' 130 | previous_visitors = instance.previous_visitors.all() 131 | with self.assertRaisesMessage(UnsealedAttributeAccess, message): 132 | list(previous_visitors) 133 | 134 | def test_sealed_instance_self_referential_m2m_acccess(self): 135 | instance = Location.from_db("default", ["id"], [1]) 136 | instance.seal() 137 | message = 'Attempt to fetch many-to-many field "related_locations" on sealed ' 138 | previous_visitors = instance.related_locations.all() 139 | with self.assertRaisesMessage(UnsealedAttributeAccess, message): 140 | list(previous_visitors) 141 | 142 | 143 | class ContentTypesSealableModelTests(TestCase): 144 | @classmethod 145 | def setUpTestData(cls): 146 | tests_models = tuple(apps.get_app_config("tests").get_models()) 147 | ContentType.objects.get_for_models(*tests_models, for_concrete_models=True) 148 | 149 | def setUp(self): 150 | warnings.filterwarnings("error", category=UnsealedAttributeAccess) 151 | self.addCleanup(warnings.resetwarnings) 152 | 153 | def test_sealed_instance_generic_foreign_key(self): 154 | instance = Nickname.from_db( 155 | "default", ["id", "content_type_id", "object_id"], [1, 1, 1] 156 | ) 157 | instance.seal() 158 | message = 'Attempt to fetch related field "content_object" on sealed ' 159 | with self.assertNumQueries(0), self.assertRaisesMessage( 160 | UnsealedAttributeAccess, message 161 | ): 162 | instance.content_object 163 | 164 | def test_sealed_instance_generic_relation(self): 165 | instance = SeaGull.from_db("default", ["id"], [1]) 166 | instance.seal() 167 | message = 'Attempt to fetch many-to-many field "nicknames" on sealed ' 168 | nicknames = instance.nicknames.all() 169 | with self.assertNumQueries(0), self.assertRaisesMessage( 170 | UnsealedAttributeAccess, message 171 | ): 172 | list(nicknames) 173 | 174 | 175 | class SealableManagerTests(SimpleTestCase): 176 | def test_isinstance_manager(self): 177 | """Manager classes are subclasses of Manager as many third-party apps expect.""" 178 | self.assertIsInstance(SealableManager(), models.Manager) 179 | self.assertIsInstance(SealableQuerySet.as_manager(), models.Manager) 180 | 181 | @isolate_apps("tests") 182 | def test_declarative_seal(self): 183 | class SealedManagers(SealableModel): 184 | manager = SealableManager(seal=True) 185 | as_manager = SealableQuerySet.as_manager(seal=True) 186 | 187 | sealed_iterable_class = SealedManagers.objects.seal()._iterable_class 188 | self.assertIsNot( 189 | SealedManagers.objects.all()._iterable_class, sealed_iterable_class 190 | ) 191 | self.assertIs( 192 | SealedManagers.manager.all()._iterable_class, sealed_iterable_class 193 | ) 194 | self.assertIs( 195 | SealedManagers.as_manager.all()._iterable_class, sealed_iterable_class 196 | ) 197 | 198 | class MixinInitSubclass: 199 | def __init_subclass__(cls, foo=None, **kwargs): 200 | cls.foo = foo 201 | super().__init_subclass__(**kwargs) 202 | 203 | class SealedBaseModel(MixinInitSubclass, SealableModel, foo="bar", seal=True): 204 | class Meta: 205 | abstract = True 206 | 207 | self.assertEqual(SealedBaseModel.foo, "bar") 208 | 209 | class SealedModel(SealedBaseModel): 210 | manager = SealableManager(seal=False) 211 | as_manager = SealableQuerySet.as_manager(seal=False) 212 | 213 | self.assertIs(SealedModel.objects.all()._iterable_class, sealed_iterable_class) 214 | self.assertIsNot( 215 | SealedModel.manager.all()._iterable_class, sealed_iterable_class 216 | ) 217 | self.assertIsNot( 218 | SealedModel.as_manager.all()._iterable_class, sealed_iterable_class 219 | ) 220 | 221 | @isolate_apps("tests") 222 | def test_non_sealable_model(self): 223 | class Foo(models.Model): 224 | manager = SealableManager() 225 | as_manager = SealableQuerySet.as_manager() 226 | 227 | self.assertEqual( 228 | Foo.manager.check(), 229 | [ 230 | checks.Error( 231 | "SealableManager can only be used on seal.SealableModel subclasses.", 232 | id="seal.E001", 233 | hint="Make tests.Foo inherit from seal.SealableModel.", 234 | obj=Foo.manager, 235 | ) 236 | ], 237 | ) 238 | self.assertEqual( 239 | Foo.as_manager.check(), 240 | [ 241 | checks.Error( 242 | "SealableQuerySet.as_manager() can only be used on seal.SealableModel subclasses.", 243 | id="seal.E001", 244 | hint="Make tests.Foo inherit from seal.SealableModel.", 245 | obj=Foo.as_manager, 246 | ) 247 | ], 248 | ) 249 | 250 | 251 | class MakeModelSealableTests(SimpleTestCase): 252 | @isolate_apps("tests") 253 | def test_make_non_sealable_model_subclass(self): 254 | class Foo(models.Model): 255 | pass 256 | 257 | class Bar(models.Model): 258 | foo = models.BooleanField(default=False) 259 | fk = models.ForeignKey(Foo, models.CASCADE, related_name="fk_bar") 260 | o2o = models.OneToOneField(Foo, models.CASCADE, related_name="o2o_bar") 261 | m2m = models.ManyToManyField(Foo, related_name="m2m_bar") 262 | 263 | make_model_sealable(Bar) 264 | 265 | # Forward fields descriptors should have been made sealable. 266 | self.assertIsInstance(Bar.foo, SealableDeferredAttribute) 267 | self.assertIsInstance(Bar.fk, SealableForwardManyToOneDescriptor) 268 | self.assertIsInstance(Bar.o2o, SealableForwardOneToOneDescriptor) 269 | self.assertIsInstance(Bar.m2m, SealableManyToManyDescriptor) 270 | 271 | # But not the remote fields. 272 | self.assertNotIsInstance(Foo.fk_bar, SealableReverseManyToOneDescriptor) 273 | self.assertNotIsInstance(Foo.o2o_bar, SealableReverseOneToOneDescriptor) 274 | self.assertNotIsInstance(Foo.m2m_bar, SealableManyToManyDescriptor) 275 | 276 | # Should seal local related objects. 277 | make_model_sealable(Foo) 278 | self.assertIsInstance(Foo.fk_bar, SealableReverseManyToOneDescriptor) 279 | self.assertIsInstance(Foo.o2o_bar, SealableReverseOneToOneDescriptor) 280 | self.assertIsInstance(Foo.m2m_bar, SealableManyToManyDescriptor) 281 | -------------------------------------------------------------------------------- /tests/test_query.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | import warnings 3 | 4 | from django.apps import apps 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.db import models 7 | from django.db.models import Prefetch 8 | from django.db.models.query import ModelIterable 9 | from django.test import SimpleTestCase, TestCase 10 | from django.test.utils import isolate_apps 11 | 12 | from seal.descriptors import _SealedRelatedQuerySet 13 | from seal.exceptions import UnsealedAttributeAccess 14 | from seal.models import make_model_sealable 15 | from seal.query import SealableQuerySet, SealedModelIterable 16 | 17 | from .models import ( 18 | Climate, 19 | GreatSeaLion, 20 | Island, 21 | Leak, 22 | Location, 23 | Nickname, 24 | SeaGull, 25 | SeaLion, 26 | ) 27 | 28 | 29 | class SealableQuerySetTests(TestCase): 30 | @classmethod 31 | def setUpTestData(cls): 32 | cls.location = Location.objects.create(latitude=51.585474, longitude=156.634331) 33 | cls.climate = Climate.objects.create(temperature=100) 34 | cls.location.climates.add(cls.climate) 35 | cls.leak = Leak.objects.create(description="Salt water") 36 | cls.great_sealion = GreatSeaLion.objects.create( 37 | height=1, 38 | weight=100, 39 | location=cls.location, 40 | leak=cls.leak, 41 | leak_o2o=cls.leak, 42 | ) 43 | cls.sealion = cls.great_sealion.sealion_ptr 44 | cls.sealion.previous_locations.add(cls.location) 45 | cls.gull = SeaGull.objects.create(sealion=cls.sealion) 46 | cls.nickname = Nickname.objects.create( 47 | name="Jonathan Livingston", content_object=cls.gull 48 | ) 49 | cls.island = Island.objects.create(location=cls.location) 50 | tests_models = tuple(apps.get_app_config("tests").get_models()) 51 | ContentType.objects.get_for_models(*tests_models, for_concrete_models=True) 52 | 53 | def setUp(self): 54 | warnings.filterwarnings("error", category=UnsealedAttributeAccess) 55 | self.addCleanup(warnings.resetwarnings) 56 | 57 | def test_state_sealed_assigned(self): 58 | instance = SeaLion.objects.seal().get() 59 | self.assertTrue(instance._state.sealed) 60 | 61 | def test_sealed_deferred_field(self): 62 | instance = SeaLion.objects.seal().defer("weight").get() 63 | message = ( 64 | 'Attempt to fetch deferred field "weight" on sealed ' 65 | ) 66 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 67 | instance.weight 68 | self.assertEqual(ctx.filename, __file__) 69 | 70 | def test_not_sealed_deferred_field(self): 71 | instance = SeaLion.objects.defer("weight").get() 72 | self.assertEqual(instance.weight, 100) 73 | 74 | def test_sealed_foreign_key(self): 75 | instance = SeaLion.objects.seal().get() 76 | message = ( 77 | 'Attempt to fetch related field "location" on sealed ' 78 | ) 79 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 80 | instance.location 81 | self.assertEqual(ctx.filename, __file__) 82 | 83 | def test_not_sealed_foreign_key(self): 84 | instance = SeaLion.objects.get() 85 | self.assertEqual(instance.location, self.location) 86 | 87 | def test_sealed_select_related_foreign_key(self): 88 | instance = SeaLion.objects.select_related("location").seal().get() 89 | self.assertEqual(instance.location, self.location) 90 | instance = SeaGull.objects.select_related("sealion").seal().get() 91 | message = ( 92 | 'Attempt to fetch related field "location" on sealed ' 93 | ) 94 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 95 | instance.sealion.location 96 | instance = SeaGull.objects.select_related("sealion__location").seal().get() 97 | self.assertEqual(instance.sealion.location, self.location) 98 | self.assertEqual(ctx.filename, __file__) 99 | 100 | def test_sealed_select_related_none_foreign_key(self): 101 | SeaLion.objects.update(location=None) 102 | instance = SeaLion.objects.select_related("location").seal().get() 103 | self.assertIsNone(instance.location) 104 | SeaGull.objects.update(sealion=None) 105 | instance = SeaGull.objects.select_related("sealion__location").seal().get() 106 | self.assertIsNone(instance.sealion) 107 | 108 | def test_select_related_foreign_key_leak(self): 109 | instance = SeaLion.objects.get() 110 | self.assertEqual(instance.leak.description, self.leak.description) 111 | 112 | instance = SeaLion.objects.select_related("leak").get() 113 | self.assertEqual(instance.leak.description, self.leak.description) 114 | 115 | def test_select_related_foreign_key_leak_o2o(self): 116 | instance = SeaLion.objects.get() 117 | self.assertEqual(instance.leak_o2o.description, self.leak.description) 118 | 119 | instance = SeaLion.objects.select_related("leak_o2o").get() 120 | self.assertEqual(instance.leak_o2o.description, self.leak.description) 121 | 122 | def test_sealed_select_related_foreign_key_leak(self): 123 | instance = ( 124 | SeaLion.objects.select_related("leak") 125 | .defer("leak__description") 126 | .seal() 127 | .get() 128 | ) 129 | with self.assertNumQueries(1): 130 | self.assertEqual(instance.leak.description, self.leak.description) 131 | 132 | def test_sealed_select_related_foreign_key_leak_o2o(self): 133 | instance = ( 134 | SeaLion.objects.select_related("leak_o2o") 135 | .defer("leak_o2o__description") 136 | .seal() 137 | .get() 138 | ) 139 | with self.assertNumQueries(1): 140 | self.assertEqual(instance.leak_o2o.description, self.leak.description) 141 | 142 | def test_sealed_select_related_deferred_field(self): 143 | instance = ( 144 | SeaGull.objects.select_related( 145 | "sealion__location", 146 | ) 147 | .only("sealion__location__latitude") 148 | .seal() 149 | .get() 150 | ) 151 | self.assertEqual(instance.sealion.location, self.location) 152 | self.assertEqual(instance.sealion.location.latitude, self.location.latitude) 153 | message = ( 154 | 'Attempt to fetch deferred field "longitude" on sealed ' 155 | ) 156 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 157 | instance.sealion.location.longitude 158 | self.assertEqual(ctx.filename, __file__) 159 | 160 | def test_sealed_one_to_one(self): 161 | instance = SeaGull.objects.seal().get() 162 | message = ( 163 | 'Attempt to fetch related field "sealion" on sealed ' 164 | ) 165 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 166 | instance.sealion 167 | self.assertEqual(ctx.filename, __file__) 168 | 169 | def test_not_sealed_one_to_one(self): 170 | instance = SeaGull.objects.get() 171 | self.assertEqual(instance.sealion, self.sealion) 172 | 173 | def test_sealed_select_related_one_to_one(self): 174 | instance = SeaGull.objects.select_related("sealion").seal().get() 175 | self.assertEqual(instance.sealion, self.sealion) 176 | 177 | def test_sealed_select_related_reverse_one_to_one(self): 178 | instance = SeaLion.objects.select_related("gull").seal().get() 179 | self.assertEqual(instance.gull, self.gull) 180 | self.gull.sealion = None 181 | self.gull.save(update_fields={"sealion"}) 182 | instance = SeaLion.objects.select_related("gull").seal().get() 183 | with self.assertRaises(SeaLion.gull.RelatedObjectDoesNotExist): 184 | instance.gull 185 | 186 | def test_sealed_select_related_unrestricted(self): 187 | instance = Island.objects.select_related().seal().get() 188 | self.assertEqual(instance.location, self.location) 189 | instance = SeaLion.objects.select_related().seal().get() 190 | message = ( 191 | 'Attempt to fetch related field "location" on sealed ' 192 | ) 193 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 194 | # null=True relationships are not followed when using 195 | # an unrestricted select_related() 196 | instance.location 197 | self.assertEqual(ctx.filename, __file__) 198 | 199 | def test_sealed_prefetch_related_reverse_one_to_one(self): 200 | instance = SeaLion.objects.prefetch_related("gull").seal().get() 201 | self.assertEqual(instance.gull, self.gull) 202 | self.gull.sealion = None 203 | self.gull.save(update_fields={"sealion"}) 204 | instance = SeaLion.objects.prefetch_related("gull").seal().get() 205 | with self.assertRaises(SeaLion.gull.RelatedObjectDoesNotExist): 206 | instance.gull 207 | 208 | def test_sealed_many_to_many(self): 209 | instance = SeaLion.objects.seal().get() 210 | message = 'Attempt to fetch many-to-many field "previous_locations" on sealed ' 211 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 212 | list(instance.previous_locations.all()) 213 | self.assertEqual(ctx.filename, __file__) 214 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 215 | instance.previous_locations.all()[0] 216 | self.assertEqual(ctx.filename, __file__) 217 | 218 | def test_sealed_many_to_many_queryset(self): 219 | instance = SeaLion.objects.seal().get() 220 | self.assertEqual( 221 | instance.previous_locations.get(pk=self.location.pk), self.location 222 | ) 223 | self.assertFalse( 224 | isinstance( 225 | instance.previous_locations.filter(pk=self.location.pk), 226 | _SealedRelatedQuerySet, 227 | ) 228 | ) 229 | 230 | def test_not_sealed_many_to_many(self): 231 | instance = SeaLion.objects.get() 232 | self.assertSequenceEqual(instance.previous_locations.all(), [self.location]) 233 | 234 | def test_sealed_string_prefetched_many_to_many(self): 235 | instance = SeaLion.objects.prefetch_related("previous_locations").seal().get() 236 | with self.assertNumQueries(0): 237 | self.assertSequenceEqual(instance.previous_locations.all(), [self.location]) 238 | instance = instance.previous_locations.all()[0] 239 | message = 'Attempt to fetch many-to-many field "previous_visitors" on sealed ' 240 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 241 | list(instance.previous_visitors.all()) 242 | self.assertEqual(ctx.filename, __file__) 243 | 244 | def test_sealed_prefetch_prefetched_many_to_many(self): 245 | instance = ( 246 | SeaLion.objects.prefetch_related( 247 | Prefetch("previous_locations"), 248 | ) 249 | .seal() 250 | .get() 251 | ) 252 | with self.assertNumQueries(0): 253 | self.assertSequenceEqual(instance.previous_locations.all(), [self.location]) 254 | instance = instance.previous_locations.all()[0] 255 | message = 'Attempt to fetch many-to-many field "previous_visitors" on sealed ' 256 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 257 | list(instance.previous_visitors.all()) 258 | self.assertEqual(ctx.filename, __file__) 259 | 260 | def test_sealed_prefetch_queryset_prefetched_many_to_many(self): 261 | instance = ( 262 | SeaLion.objects.prefetch_related( 263 | Prefetch("previous_locations", Location.objects.all()), 264 | ) 265 | .seal() 266 | .get() 267 | ) 268 | with self.assertNumQueries(0): 269 | self.assertSequenceEqual(instance.previous_locations.all(), [self.location]) 270 | instance = instance.previous_locations.all()[0] 271 | message = 'Attempt to fetch many-to-many field "previous_visitors" on sealed ' 272 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 273 | list(instance.previous_visitors.all()) 274 | self.assertEqual(ctx.filename, __file__) 275 | 276 | def test_sealed_string_prefetched_nested_many_to_many(self): 277 | with self.assertNumQueries(3): 278 | instance = ( 279 | SeaLion.objects.prefetch_related( 280 | "previous_locations__previous_visitors" 281 | ) 282 | .seal() 283 | .get() 284 | ) 285 | with self.assertNumQueries(0): 286 | self.assertSequenceEqual(instance.previous_locations.all(), [self.location]) 287 | self.assertSequenceEqual( 288 | instance.previous_locations.all()[0].previous_visitors.all(), 289 | [self.sealion], 290 | ) 291 | instance = instance.previous_locations.all()[0].previous_visitors.all()[0] 292 | message = 'Attempt to fetch many-to-many field "previous_locations" on sealed ' 293 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 294 | list(instance.previous_locations.all()) 295 | self.assertEqual(ctx.filename, __file__) 296 | 297 | def test_sealed_prefetch_prefetched_nested_many_to_many(self): 298 | instance = ( 299 | SeaLion.objects.prefetch_related( 300 | Prefetch("previous_locations__previous_visitors"), 301 | ) 302 | .seal() 303 | .get() 304 | ) 305 | with self.assertNumQueries(0): 306 | self.assertSequenceEqual(instance.previous_locations.all(), [self.location]) 307 | self.assertSequenceEqual( 308 | instance.previous_locations.all()[0].previous_visitors.all(), 309 | [self.sealion], 310 | ) 311 | instance = instance.previous_locations.all()[0].previous_visitors.all()[0] 312 | message = 'Attempt to fetch many-to-many field "previous_locations" on sealed ' 313 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 314 | list(instance.previous_locations.all()) 315 | self.assertEqual(ctx.filename, __file__) 316 | 317 | def test_prefetched_sealed_many_to_many(self): 318 | instance = SeaLion.objects.prefetch_related( 319 | Prefetch("previous_locations", Location.objects.seal()), 320 | ).get() 321 | with self.assertNumQueries(0): 322 | self.assertSequenceEqual(instance.previous_locations.all(), [self.location]) 323 | message = 'Attempt to fetch many-to-many field "previous_visitors" on sealed ' 324 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 325 | list(instance.previous_locations.all()[0].previous_visitors.all()) 326 | self.assertEqual(ctx.filename, __file__) 327 | 328 | def test_sealed_deferred_parent_link(self): 329 | instance = GreatSeaLion.objects.only("pk").seal().get() 330 | message = 'Attempt to fetch related field "sealion_ptr" on sealed ' 331 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 332 | instance.sealion_ptr 333 | self.assertEqual(ctx.filename, __file__) 334 | 335 | def test_not_sealed_parent_link(self): 336 | instance = GreatSeaLion.objects.only("pk").get() 337 | self.assertEqual(instance.sealion_ptr, self.sealion) 338 | 339 | def test_sealed_parent_link(self): 340 | instance = GreatSeaLion.objects.seal().get() 341 | with self.assertNumQueries(0): 342 | self.assertEqual(instance.sealion_ptr, self.sealion) 343 | 344 | def test_sealed_generic_foreign_key(self): 345 | instance = Nickname.objects.seal().get() 346 | message = 'Attempt to fetch related field "content_object" on sealed ' 347 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 348 | instance.content_object 349 | self.assertEqual(ctx.filename, __file__) 350 | 351 | def test_not_sealed_generic_foreign_key(self): 352 | instance = Nickname.objects.get() 353 | self.assertEqual(instance.content_object, self.gull) 354 | 355 | def test_sealed_prefetch_related_generic_foreign_key(self): 356 | instance = Nickname.objects.prefetch_related("content_object").seal().get() 357 | with self.assertNumQueries(0): 358 | self.assertEqual(instance.content_object, self.gull) 359 | 360 | def test_sealed_reverse_foreign_key(self): 361 | instance = Location.objects.seal().get() 362 | message = 'Attempt to fetch many-to-many field "visitors" on sealed ' 363 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 364 | list(instance.visitors.all()) 365 | self.assertEqual(ctx.filename, __file__) 366 | 367 | def test_not_sealed_reverse_foreign_key(self): 368 | instance = Location.objects.get() 369 | self.assertSequenceEqual(instance.visitors.all(), [self.sealion]) 370 | 371 | def test_sealed_prefetched_reverse_foreign_key(self): 372 | instance = Location.objects.prefetch_related("visitors").seal().get() 373 | self.assertSequenceEqual(instance.visitors.all(), [self.sealion]) 374 | 375 | def test_sealed_reverse_parent_link(self): 376 | instance = SeaLion.objects.seal().get() 377 | message = ( 378 | 'Attempt to fetch related field "greatsealion" on sealed ' 379 | ) 380 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 381 | instance.greatsealion 382 | self.assertEqual(ctx.filename, __file__) 383 | 384 | def test_not_sealed_reverse_parent_link(self): 385 | instance = SeaLion.objects.get() 386 | self.assertEqual(instance.greatsealion, self.great_sealion) 387 | 388 | def test_sealed_select_related_reverse_parent_link(self): 389 | instance = SeaLion.objects.select_related("greatsealion").seal().get() 390 | self.assertEqual(instance.greatsealion, self.great_sealion) 391 | 392 | def test_sealed_reverse_many_to_many(self): 393 | instance = Location.objects.seal().get() 394 | message = 'Attempt to fetch many-to-many field "previous_visitors" on sealed ' 395 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 396 | list(instance.previous_visitors.all()) 397 | self.assertEqual(ctx.filename, __file__) 398 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 399 | instance.previous_visitors.all()[0] 400 | self.assertEqual(ctx.filename, __file__) 401 | 402 | def test_sealed_reverse_many_to_many_queryset(self): 403 | instance = Location.objects.seal().get() 404 | self.assertEqual( 405 | instance.previous_visitors.get(pk=self.sealion.pk), self.sealion 406 | ) 407 | self.assertFalse( 408 | isinstance( 409 | instance.previous_visitors.filter(pk=self.sealion.pk), 410 | _SealedRelatedQuerySet, 411 | ) 412 | ) 413 | 414 | def test_not_reverse_sealed_many_to_many(self): 415 | instance = Location.objects.get() 416 | self.assertSequenceEqual(instance.previous_visitors.all(), [self.sealion]) 417 | 418 | def test_sealed_prefetched_reverse_many_to_many(self): 419 | instance = Location.objects.prefetch_related("previous_visitors").seal().get() 420 | self.assertSequenceEqual(instance.previous_visitors.all(), [self.sealion]) 421 | 422 | def test_sealed_generic_relation(self): 423 | instance = SeaGull.objects.seal().get() 424 | message = 'Attempt to fetch many-to-many field "nicknames" on sealed ' 425 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 426 | list(instance.nicknames.all()) 427 | self.assertEqual(ctx.filename, __file__) 428 | with self.assertWarnsMessage(UnsealedAttributeAccess, message) as ctx: 429 | instance.nicknames.all()[0] 430 | self.assertEqual(ctx.filename, __file__) 431 | 432 | def test_not_sealed_generic_relation(self): 433 | instance = SeaGull.objects.get() 434 | self.assertSequenceEqual(instance.nicknames.all(), [self.nickname]) 435 | 436 | def test_sealed_prefetched_generic_relation(self): 437 | instance = SeaGull.objects.prefetch_related("nicknames").seal().get() 438 | self.assertSequenceEqual(instance.nicknames.all(), [self.nickname]) 439 | 440 | def test_sealed_prefetched_select_related_many_to_many(self): 441 | with self.assertNumQueries(2): 442 | instance = ( 443 | SeaLion.objects.select_related( 444 | "location", 445 | ) 446 | .prefetch_related( 447 | "location__climates", 448 | ) 449 | .seal() 450 | .get() 451 | ) 452 | with self.assertNumQueries(0): 453 | self.assertSequenceEqual(instance.location.climates.all(), [self.climate]) 454 | 455 | def test_prefetch_without_related_name(self): 456 | location = Location.objects.prefetch_related("island_set").seal().get() 457 | self.assertSequenceEqual(location.island_set.all(), [self.island]) 458 | 459 | def test_prefetch_combine(self): 460 | with self.assertNumQueries(6): 461 | instance = ( 462 | SeaLion.objects.prefetch_related( 463 | "location", 464 | "location__climates", 465 | "location__previous_visitors__location", 466 | "location__previous_visitors__previous_locations", 467 | ) 468 | .seal() 469 | .get() 470 | ) 471 | with self.assertNumQueries(0): 472 | self.assertEqual(instance.location, self.location) 473 | self.assertSequenceEqual(instance.location.climates.all(), [self.climate]) 474 | self.assertSequenceEqual( 475 | instance.location.previous_visitors.all(), [self.sealion] 476 | ) 477 | self.assertEqual( 478 | instance.location.previous_visitors.all()[0].location, self.location 479 | ) 480 | self.assertSequenceEqual( 481 | instance.location.previous_visitors.all()[0].previous_locations.all(), 482 | [self.location], 483 | ) 484 | 485 | def test_sealed_prefetch_related_results_cache(self): 486 | """Some related managers fetch objects in get_prefetch_queryset().""" 487 | location_relations = ["climates", "related_locations", "visitors"] 488 | for relation in location_relations: 489 | with self.subTest(relation=relation), self.assertNumQueries(2): 490 | list(Location.objects.seal().prefetch_related(relation).all()) 491 | sea_lion_relations = ["location", "previous_locations", "leak", "leak_o2o"] 492 | for relation in sea_lion_relations: 493 | with self.subTest(relation=relation), self.assertNumQueries(2): 494 | list(SeaLion.objects.seal().prefetch_related(relation).all()) 495 | 496 | def test_sealed_prefetch_many_to_many_results(self): 497 | other_location = Location.objects.create( 498 | latitude=51.585474, longitude=156.634331 499 | ) 500 | other_climate = Climate.objects.create(temperature=60) 501 | other_location.climates.add(other_climate) 502 | with self.assertNumQueries(2): 503 | results = list( 504 | Location.objects.seal().prefetch_related("climates").order_by("pk") 505 | ) 506 | self.assertEqual(results, [self.location, other_location]) 507 | with self.assertNumQueries(0): 508 | self.assertEqual(list(results[0].climates.all()), [self.climate]) 509 | self.assertEqual(list(results[1].climates.all()), [other_climate]) 510 | 511 | def test_sealed_prefetch_reverse_many_to_one_results(self): 512 | other_location = Location.objects.create( 513 | latitude=51.585474, longitude=156.634331 514 | ) 515 | other_sealion = SeaLion.objects.create( 516 | height=1, weight=2, location=other_location 517 | ) 518 | with self.assertNumQueries(2): 519 | results = list( 520 | Location.objects.seal().prefetch_related("visitors").order_by("pk") 521 | ) 522 | self.assertEqual(results, [self.location, other_location]) 523 | with self.assertNumQueries(0): 524 | self.assertEqual(list(results[0].visitors.all()), [self.sealion]) 525 | self.assertEqual(list(results[1].visitors.all()), [other_sealion]) 526 | 527 | def test_sealed_prefetch_reverse_generic_many_to_one_results(self): 528 | other_sealion = SeaLion.objects.create(height=1, weight=2) 529 | other_gull = SeaGull.objects.create(sealion=other_sealion) 530 | other_nickname = Nickname.objects.create( 531 | name="Test Nickname", content_object=other_gull 532 | ) 533 | with self.assertNumQueries(2): 534 | results = list( 535 | SeaGull.objects.seal().prefetch_related("nicknames").order_by("pk") 536 | ) 537 | self.assertEqual(results, [self.gull, other_gull]) 538 | with self.assertNumQueries(0): 539 | self.assertEqual(list(results[0].nicknames.all()), [self.nickname]) 540 | self.assertEqual(list(results[1].nicknames.all()), [other_nickname]) 541 | 542 | def test_related_sealed_pickleability(self): 543 | location = Location.objects.prefetch_related("climates").seal().get() 544 | climates_dump = pickle.dumps(location.climates.all()) 545 | climates = pickle.loads(climates_dump) 546 | with self.assertNumQueries(0): 547 | self.assertEqual(list(climates)[0], self.climate) 548 | 549 | 550 | class SealableQuerySetInteractionTests(SimpleTestCase): 551 | def test_values_seal_disallowed(self): 552 | with self.assertRaisesMessage( 553 | TypeError, "Cannot call seal() after .values() or .values_list()" 554 | ): 555 | SeaGull.objects.values("id").seal() 556 | 557 | def test_values_list_seal_disallowed(self): 558 | with self.assertRaisesMessage( 559 | TypeError, "Cannot call seal() after .values() or .values_list()" 560 | ): 561 | SeaGull.objects.values_list("id").seal() 562 | 563 | def test_seal_sealable_model_iterable_subclass(self): 564 | class SealableModelIterableSubclass(SealedModelIterable): 565 | pass 566 | 567 | queryset = SeaGull.objects.seal(iterable_class=SealableModelIterableSubclass) 568 | self.assertIs(queryset._iterable_class, SealableModelIterableSubclass) 569 | 570 | def test_seal_non_sealable_model_iterable_subclass(self): 571 | message = ( 572 | "iterable_class " 573 | "is not a subclass of SealedModelIterable" 574 | ) 575 | with self.assertRaisesMessage(TypeError, message): 576 | SeaGull.objects.seal(iterable_class=ModelIterable) 577 | 578 | 579 | class SealableQuerySetNonSealableModelTests(TestCase): 580 | """ 581 | A SealableQuerySet should be usable on non SealableModel subclasses. 582 | """ 583 | 584 | @classmethod 585 | def setUpTestData(cls): 586 | cls.location = Location.objects.create(latitude=51.585474, longitude=156.634331) 587 | cls.climate = Climate.objects.create(temperature=100) 588 | cls.location.climates.add(cls.climate) 589 | cls.sealion = SeaLion.objects.create( 590 | height=1, weight=100, location=cls.location 591 | ) 592 | 593 | @isolate_apps("tests") 594 | def test_sealed_non_sealable_model(self): 595 | class NonSealableLocation(models.Model): 596 | class Meta: 597 | db_table = Location._meta.db_table 598 | 599 | queryset = SealableQuerySet(model=NonSealableLocation) 600 | instance = queryset.seal().get() 601 | self.assertTrue(instance._state.sealed) 602 | 603 | @isolate_apps("tests") 604 | def test_sealed_select_related_non_sealable_model(self): 605 | class NonSealableLocation(models.Model): 606 | class Meta: 607 | db_table = Location._meta.db_table 608 | 609 | class NonSealableSeaLion(models.Model): 610 | location = models.ForeignKey(NonSealableLocation, models.CASCADE) 611 | 612 | class Meta: 613 | db_table = SeaLion._meta.db_table 614 | 615 | queryset = SealableQuerySet(model=NonSealableSeaLion) 616 | instance = queryset.select_related("location").seal().get() 617 | self.assertTrue(instance._state.sealed) 618 | self.assertTrue(instance.location._state.sealed) 619 | 620 | @isolate_apps("tests") 621 | def test_sealed_prefetch_related_non_sealable_model(self): 622 | class NonSealableClimate(models.Model): 623 | objects = SealableQuerySet.as_manager() 624 | 625 | class Meta: 626 | db_table = Climate._meta.db_table 627 | 628 | class NonSealableLocationClimatesThrough(models.Model): 629 | climate = models.ForeignKey(NonSealableClimate, models.CASCADE) 630 | location = models.ForeignKey("NonSealableLocation", models.CASCADE) 631 | 632 | class Meta: 633 | db_table = Location.climates.through._meta.db_table 634 | 635 | class NonSealableLocation(models.Model): 636 | climates = models.ManyToManyField( 637 | NonSealableClimate, through=NonSealableLocationClimatesThrough 638 | ) 639 | 640 | class Meta: 641 | db_table = Location._meta.db_table 642 | 643 | make_model_sealable(NonSealableLocation) 644 | queryset = SealableQuerySet(model=NonSealableLocation) 645 | instance = queryset.prefetch_related("climates").seal().get() 646 | self.assertTrue(instance._state.sealed) 647 | with self.assertNumQueries(0): 648 | self.assertTrue(instance.climates.all()[0]._state.sealed) 649 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = true 3 | args_are_paths = false 4 | envlist = 5 | black, 6 | flake8, 7 | isort, 8 | pypi, 9 | py39-{4.2}, 10 | py310-{4.2,5.0,5.1,5.2}, 11 | py311-{4.2,5.0,5.1,5.2}, 12 | py312-{4.2,5.0,5.1,5.2,main}, 13 | py313-{5.1,5.2,main} 14 | 15 | [gh-actions] 16 | python = 17 | 3.9: py39, black, flake8, isort, pypi 18 | 3.10: py310 19 | 3.11: py311 20 | 3.12: py312 21 | 3.13: py313 22 | 23 | [testenv] 24 | usedevelop = true 25 | commands = 26 | {envpython} -R -Wonce {envbindir}/coverage run -a -m django test -v2 --settings=tests.settings {posargs} 27 | coverage report 28 | deps = 29 | coverage 30 | 4.2: Django>=4.2,<5 31 | 5.0: Django>=5.0,<5.1 32 | 5.1: Django>=5.1,<5.2 33 | 5.2: Django>=5.2a1,<6 34 | main: https://github.com/django/django/archive/main.tar.gz 35 | passenv = 36 | GITHUB_* 37 | 38 | [testenv:flake8] 39 | usedevelop = false 40 | commands = flake8 41 | deps = flake8 42 | 43 | [testenv:isort] 44 | usedevelop = false 45 | commands = isort --recursive --check-only --diff seal tests 46 | deps = 47 | isort 48 | Django<4 49 | 50 | [testenv:black] 51 | usedevelop = false 52 | commands = black --check seal tests 53 | deps = black 54 | 55 | [testenv:pypi] 56 | usedevelop = false 57 | commands = 58 | python setup.py sdist --format=gztar bdist_wheel 59 | twine check dist/* 60 | deps = 61 | pip 62 | setuptools 63 | twine 64 | wheel 65 | --------------------------------------------------------------------------------