├── .coveragerc ├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.rst ├── reverse_unique ├── __init__.py ├── fields.py └── models.py ├── reverse_unique_tests ├── __init__.py ├── models.py ├── settings.py └── tests.py ├── setup.cfg ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = reverse_unique 3 | branch = 1 4 | omit = reverse_unique/test* 5 | relative_files = 1 6 | -------------------------------------------------------------------------------- /.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.6', '3.7', '3.8', '3.9', '3.10'] 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@v2 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 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | .*.swp 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Anssi Kääriäinen 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | Neither the name of the {organization} nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-reverse-unique 2 | ===================== 3 | 4 | .. image:: https://github.com/akaariai/django-reverse-unique/workflows/Test/badge.svg 5 | :target: https://github.com/akaariai/django-reverse-unique/actions 6 | :alt: Build Status 7 | 8 | .. image:: https://coveralls.io/repos/akaariai/django-reverse-unique/badge.svg?branch=master 9 | :target: https://coveralls.io/r/akaariai/django-reverse-unique?branch=master 10 | :alt: Coverage Status 11 | 12 | A ReverseUnique model field implementation for Django 13 | 14 | The ReverseUnique field can be used to access single model instances from 15 | the reverse side of ForeignKey. Essentially, ReverseUnique can be used to 16 | generate OneToOneField like behaviour in the reverse direction of a normal 17 | ForeignKey. The idea is to add an unique filter condition when traversing the 18 | foreign key to reverse direction. 19 | 20 | To be able to use reverse unique, you will need a unique constraint for the 21 | reverse side or otherwise know that only one instance on the reverse side can 22 | match. 23 | 24 | Example 25 | ~~~~~~~ 26 | 27 | It is always nice to see actual use cases. We will model employees with time 28 | dependent salaries in this example. This use case could be modelled as:: 29 | 30 | class Employee(models.Model): 31 | name = models.TextField() 32 | 33 | class EmployeeSalary(models.Model): 34 | employee = models.ForeignKey(Employee, related_name='employee_salaries') 35 | salary = models.IntegerField() 36 | valid_from = models.DateField() 37 | valid_until = models.DateField(null=True) 38 | 39 | It is possible to save data like "Anssi has salary of 10€ from 2000-1-1 to 2009-12-31, 40 | and salary of 11€ from 2010-1-1 to infinity (modelled as None in the models). 41 | 42 | Unfortunately when using these models it isn't trivial to just fetch the 43 | employee and his salary to display to the user. It would be possible to do so by 44 | looping through all salaries of an employee and checking which of the EmployeeSalaries 45 | is currently in effect. However, this approach has a couple of drawbacks: 46 | 47 | - It doesn't perform well in list views 48 | - Most of all It is impossible to execute queries that refer the employee's current 49 | salary. For example, getting the top 10 best paid employees or the average 50 | salary of employees is impossible to achieve in single query. 51 | 52 | Django-reverse-unique to the rescue! Lets change the Employee model to:: 53 | 54 | from datetime import datetime 55 | class Employee(models.Model): 56 | name = models.TextField() 57 | current_salary = models.ReverseUnique( 58 | "EmployeeSalary", 59 | filter=Q(valid_from__gte=datetime.now) & 60 | (Q(valid_until__isnull=True) | Q(valid_until__lte=datetime.now)) 61 | ) 62 | 63 | Now we can simply issue a query like:: 64 | 65 | Employee.objects.order_by('current_salary__salary')[0:10] 66 | 67 | or:: 68 | 69 | Employee.objects.aggregate(avg_salary=Avg('current_salary__salary')) 70 | 71 | What did happen there? We added a ReverseUnique field. This field is the reverse 72 | of the EmployeeSalary.employee foreign key with an additional restriction that the 73 | relation must be valid at the moment the query is executed. The first 74 | "EmployeeSalary" argument refers to the EmployeeSalary model (we have to use 75 | string as the EmployeeSalary model is defined after the Employee model). The 76 | filter argument is a Q-object which can refer to the fields of the remote model. 77 | 78 | Another common problem for Django applications is how to store model translations. 79 | The storage problem can be solved with django-reverse-unique. Here is a complete 80 | example for that use case:: 81 | 82 | from django.db import models 83 | from reverse_unique import ReverseUnique 84 | from django.utils.translation import get_language, activate 85 | 86 | class Article(models.Model): 87 | active_translation = ReverseUnique("ArticleTranslation", 88 | filters=Q(lang=get_language)) 89 | 90 | class ArticleTranslation(models.Model): 91 | article = models.ForeignKey(Article) 92 | lang = models.CharField(max_length=2) 93 | title = models.CharField(max_length=100) 94 | body = models.TextField() 95 | 96 | class Meta: 97 | unique_together = ('article', 'lang') 98 | 99 | activate("fi") 100 | objs = Article.objects.filter( 101 | active_translation__title__icontains="foo" 102 | ).select_related('active_translation') 103 | # Generated query is 104 | # select article.*, article_translation.* 105 | # from article 106 | # join article_translation on article_translation.article_id = article.id 107 | # and article_translation.lang = 'fi' 108 | # If you activate "en" instead, the lang is changed. 109 | # Now you can access objs[0].active_translation without generating more 110 | # queries. 111 | 112 | Similarly one could fetch current active reservation for a hotel room etc. 113 | 114 | Installation 115 | ~~~~~~~~~~~~ 116 | 117 | The requirement for ReverseUnique is Django 1.6+. You will need to place the 118 | reverse_unique directory in Python path, then just use it like done in above 119 | example. The tests (reverse_unique/tests.py) contain a couple more examples. 120 | Easiest way to install is:: 121 | 122 | pip install -e git://github.com/akaariai/django-reverse-unique.git#egg=reverse_unique 123 | 124 | Testing 125 | ~~~~~~~ 126 | 127 | You'll need to have a supported version of Django installed. Go to 128 | testproject directory and run:: 129 | 130 | python manage.py test reverse_unique 131 | -------------------------------------------------------------------------------- /reverse_unique/__init__.py: -------------------------------------------------------------------------------- 1 | from .fields import ReverseUnique # noqa 2 | -------------------------------------------------------------------------------- /reverse_unique/fields.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.db import models 3 | from django.db.models.fields.related import ForeignObject 4 | from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor 5 | 6 | 7 | class ReverseUniqueDescriptor(ForwardManyToOneDescriptor): 8 | def __set__(self, instance, value): 9 | if instance is None: 10 | raise AttributeError("%s must be accessed via instance" % self.field.name) 11 | instance.__dict__[self.field.get_cache_name()] = value 12 | if value is not None and not self.field.remote_field.multiple: 13 | setattr(value, self.field.related.get_cache_name(), instance) 14 | 15 | def __get__(self, instance, *args, **kwargs): 16 | try: 17 | return super().__get__(instance, *args, **kwargs) 18 | except self.field.remote_field.model.DoesNotExist: 19 | instance.__dict__[self.field.get_cache_name()] = None 20 | return None 21 | 22 | 23 | class ReverseUnique(ForeignObject): 24 | requires_unique_target = False 25 | 26 | def __init__(self, *args, **kwargs): 27 | self.filters = kwargs.pop('filters') 28 | self.through = kwargs.pop('through', None) 29 | kwargs['from_fields'] = [] 30 | kwargs['to_fields'] = [] 31 | kwargs['null'] = True 32 | kwargs['related_name'] = '+' 33 | kwargs['on_delete'] = models.DO_NOTHING 34 | super().__init__(*args, **kwargs) 35 | 36 | def resolve_related_fields(self): 37 | if self.through is None: 38 | possible_models = [self.model] + [m for m in self.model.__mro__ if hasattr(m, '_meta')] 39 | possible_targets = [f for f in self.remote_field.model._meta.concrete_fields 40 | if f.remote_field and f.remote_field.model in possible_models] 41 | if len(possible_targets) != 1: 42 | raise Exception("Found %s target fields instead of one, the fields found were %s." 43 | % (len(possible_targets), [f.name for f in possible_targets])) 44 | related_field = possible_targets[0] 45 | else: 46 | related_field = self.model._meta.get_field(self.through).field 47 | if related_field.remote_field.model._meta.concrete_model != self.model._meta.concrete_model: 48 | # We have found a foreign key pointing to parent model. 49 | # This will only work if the fk is pointing to a value 50 | # that can be found from the child model, too. This is 51 | # the case only when we have parent pointer in child 52 | # pointing to same field as the found foreign key is 53 | # pointing to. Lets find this out. And, lets handle 54 | # only the single column case for now. 55 | if len(related_field.to_fields) > 1: 56 | raise ValueError( 57 | "FIXME: No support for multi-column joins in parent join case." 58 | ) 59 | to_fields = self._find_parent_link(related_field) 60 | else: 61 | to_fields = [f.name for f in related_field.foreign_related_fields] 62 | self.to_fields = [f.name for f in related_field.local_related_fields] 63 | self.from_fields = to_fields 64 | return super().resolve_related_fields() 65 | 66 | def _find_parent_link(self, related_field): 67 | """ 68 | Find a field containing the value of related_field in local concrete 69 | fields or raise an error if the value isn't available in local table. 70 | 71 | Technical reason for this is that parent model joining is done later 72 | than filter join production, and that means proucing a join against 73 | parent tables will not work. 74 | """ 75 | # The hard part here is to verify that the value in fact can be found 76 | # from local field. Lets first build the ancestor link chain 77 | ancestor_links = [] 78 | curr_model = self.model 79 | while True: 80 | found_link = curr_model._meta.get_ancestor_link(related_field.remote_field.model) 81 | if not found_link: 82 | # OK, we found to parent model. Lets check that the pointed to 83 | # field contains the correct value. 84 | last_link = ancestor_links[-1] 85 | if last_link.foreign_related_fields != related_field.foreign_related_fields: 86 | curr_opts = curr_model._meta 87 | rel_opts = self.remote_field.model._meta 88 | opts = self.model._meta 89 | raise ValueError( 90 | "The field(s) %s of model %s.%s which %s.%s.%s is " 91 | "pointing to cannot be found from %s.%s. " 92 | "Add ReverseUnique to parent instead." % ( 93 | ', '.join([f.name for f in related_field.foreign_related_fields]), 94 | curr_opts.app_label, curr_opts.object_name, 95 | rel_opts.app_label, rel_opts.object_name, related_field.name, 96 | opts.app_label, opts.object_name 97 | ) 98 | ) 99 | break 100 | if ancestor_links: 101 | assert found_link.local_related_fields == ancestor_links[-1].foreign_related_fields 102 | ancestor_links.append(found_link) 103 | curr_model = found_link.remote_field.model 104 | return [self.model._meta.get_ancestor_link(related_field.remote_field.model).name] 105 | 106 | def get_filters(self): 107 | if callable(self.filters): 108 | return self.filters() 109 | else: 110 | return self.filters 111 | 112 | def _get_extra_restriction(self, alias, related_alias): 113 | remote_model = self.remote_field.model 114 | qs = remote_model.objects.filter(self.get_filters()).query 115 | my_table = self.model._meta.db_table 116 | rel_table = remote_model._meta.db_table 117 | illegal_tables = set([t for t in qs.alias_map if qs.alias_refcount[t] > 0]).difference( 118 | set([my_table, rel_table])) 119 | if illegal_tables: 120 | raise Exception("This field's filters refers illegal tables: %s" % illegal_tables) 121 | where = qs.where 122 | where.relabel_aliases({my_table: related_alias, rel_table: alias}) 123 | return where 124 | 125 | if django.VERSION[0] >= 4: 126 | get_extra_restriction = _get_extra_restriction 127 | else: 128 | def get_extra_restriction(self, where_class, alias, related_alias): 129 | return self._get_extra_restriction(alias, related_alias) 130 | 131 | def get_extra_descriptor_filter(self, instance): 132 | return self.get_filters() 133 | 134 | def get_path_info(self, *args, **kwargs): 135 | ret = super().get_path_info(*args, **kwargs) 136 | assert len(ret) == 1 137 | return [ret[0]._replace(direct=False)] 138 | 139 | def contribute_to_class(self, cls, name): 140 | super().contribute_to_class(cls, name) 141 | setattr(cls, self.name, ReverseUniqueDescriptor(self)) 142 | 143 | def deconstruct(self): 144 | name, path, args, kwargs = super().deconstruct() 145 | kwargs['filters'] = self.filters 146 | if self.through is not None: 147 | kwargs['through'] = self.through 148 | return name, path, args, kwargs 149 | -------------------------------------------------------------------------------- /reverse_unique/models.py: -------------------------------------------------------------------------------- 1 | # Needed for Django to recognize this as an app 2 | -------------------------------------------------------------------------------- /reverse_unique_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akaariai/django-reverse-unique/3425e10dccd0ac01469500e47bae66b1999c4a8e/reverse_unique_tests/__init__.py -------------------------------------------------------------------------------- /reverse_unique_tests/models.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from django.db import models 4 | from django.db.models import Q, F 5 | from django.utils.translation import get_language 6 | from reverse_unique import ReverseUnique 7 | 8 | 9 | def filter_lang(): 10 | return Q(lang=get_language()) 11 | 12 | 13 | class Article(models.Model): 14 | pub_date = models.DateField() 15 | active_translation = ReverseUnique( 16 | "ArticleTranslation", filters=filter_lang) 17 | 18 | class Meta: 19 | app_label = 'reverse_unique' 20 | 21 | 22 | class Lang(models.Model): 23 | code = models.CharField(max_length=2, primary_key=True) 24 | 25 | class Meta: 26 | app_label = 'reverse_unique' 27 | 28 | 29 | class ArticleTranslation(models.Model): 30 | article = models.ForeignKey(Article, on_delete=models.CASCADE) 31 | lang = models.ForeignKey(Lang, on_delete=models.CASCADE) 32 | title = models.CharField(max_length=100) 33 | abstract = models.CharField(max_length=100, null=True) 34 | body = models.TextField() 35 | 36 | class Meta: 37 | unique_together = ('article', 'lang') 38 | app_label = 'reverse_unique' 39 | 40 | 41 | # The idea for DefaultTranslationArticle is that article's have default 42 | # language. This allows testing of filter condition targeting both 43 | # tables in the join. 44 | class DefaultTranslationArticle(models.Model): 45 | pub_date = models.DateField() 46 | default_lang = models.CharField(max_length=2) 47 | active_translation = ReverseUnique( 48 | "DefaultTranslationArticleTranslation", filters=filter_lang) 49 | default_translation = ReverseUnique( 50 | "DefaultTranslationArticleTranslation", filters=Q(lang=F('article__default_lang'))) 51 | 52 | class Meta: 53 | app_label = 'reverse_unique' 54 | 55 | 56 | class DefaultTranslationArticleTranslation(models.Model): 57 | article = models.ForeignKey(DefaultTranslationArticle, on_delete=models.CASCADE) 58 | lang = models.CharField(max_length=2) 59 | title = models.CharField(max_length=100) 60 | abstract = models.CharField(max_length=100, null=True) 61 | body = models.TextField() 62 | 63 | class Meta: 64 | unique_together = ('article', 'lang') 65 | app_label = 'reverse_unique' 66 | 67 | 68 | class Guest(models.Model): 69 | name = models.CharField(max_length=100) 70 | 71 | class Meta: 72 | app_label = 'reverse_unique' 73 | 74 | 75 | def filter_reservations(): 76 | return Q(from_date__lte=date.today()) & ( 77 | Q(until_date__gte=date.today()) | Q(until_date__isnull=True)) 78 | 79 | 80 | class Room(models.Model): 81 | current_reservation = ReverseUnique( 82 | "Reservation", through='reservations', 83 | filters=filter_reservations) 84 | 85 | class Meta: 86 | app_label = 'reverse_unique' 87 | 88 | 89 | class Reservation(models.Model): 90 | room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='reservations') 91 | guest = models.ForeignKey(Guest, on_delete=models.CASCADE) 92 | from_date = models.DateField() 93 | until_date = models.DateField(null=True) # NULL means reservation "forever". 94 | 95 | class Meta: 96 | app_label = 'reverse_unique' 97 | 98 | 99 | class Parent(models.Model): 100 | rel1 = ReverseUnique("Rel1", filters=Q(f1="foo")) 101 | uniq_field = models.CharField(max_length=10, unique=True, null=True) 102 | 103 | class Meta: 104 | app_label = 'reverse_unique' 105 | 106 | 107 | class Rel1(models.Model): 108 | parent = models.ForeignKey(Parent, on_delete=models.CASCADE, related_name="rel1list") 109 | f1 = models.CharField(max_length=10) 110 | 111 | class Meta: 112 | app_label = 'reverse_unique' 113 | 114 | 115 | class Child(Parent): 116 | rel2 = ReverseUnique("Rel2", filters=Q(f1="foo")) 117 | 118 | class Meta: 119 | app_label = 'reverse_unique' 120 | 121 | 122 | class AnotherChild(Child): 123 | rel1_child = ReverseUnique("Rel1", filters=Q(f1__startswith="foo")) 124 | 125 | class Meta: 126 | app_label = 'reverse_unique' 127 | 128 | 129 | class Rel2(models.Model): 130 | child = models.ForeignKey(Child, on_delete=models.CASCADE, related_name="rel2list") 131 | f1 = models.CharField(max_length=10) 132 | 133 | class Meta: 134 | app_label = 'reverse_unique' 135 | 136 | 137 | class Rel3(models.Model): 138 | a_model = models.ForeignKey(Parent, on_delete=models.CASCADE, to_field='uniq_field') 139 | 140 | class Meta: 141 | app_label = 'reverse_unique' 142 | -------------------------------------------------------------------------------- /reverse_unique_tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'not-anymore' 2 | 3 | LANGUAGE_CODE = 'en-us' 4 | TIME_ZONE = 'UTC' 5 | USE_I18N = True 6 | USE_L10N = True 7 | USE_TZ = False 8 | 9 | DATABASES = { 10 | 'default': { 11 | 'ENGINE': 'django.db.backends.sqlite3', 12 | } 13 | } 14 | 15 | INSTALLED_APPS = [ 16 | 'reverse_unique', 17 | 'reverse_unique_tests', 18 | ] 19 | 20 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' -------------------------------------------------------------------------------- /reverse_unique_tests/tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django import forms 4 | from django.db import models 5 | from django.test import TestCase 6 | from django.utils.translation import activate 7 | 8 | from reverse_unique import ReverseUnique 9 | 10 | from .models import ( 11 | Article, ArticleTranslation, Lang, DefaultTranslationArticle, 12 | DefaultTranslationArticleTranslation, Guest, Room, Reservation, 13 | Parent, Child, AnotherChild, Rel1, Rel2) 14 | 15 | 16 | class ReverseUniqueTests(TestCase): 17 | 18 | def test_translations(self): 19 | activate('fi') 20 | fi = Lang.objects.create(code="fi") 21 | en = Lang.objects.create(code="en") 22 | a1 = Article.objects.create(pub_date=datetime.date.today()) 23 | at1_fi = ArticleTranslation(article=a1, lang=fi, title='Otsikko', body='Diipadaapa') 24 | at1_fi.save() 25 | at2_en = ArticleTranslation(article=a1, lang=en, title='Title', body='Lalalalala') 26 | at2_en.save() 27 | with self.assertNumQueries(1): 28 | fetched = Article.objects.select_related('active_translation').get( 29 | active_translation__title='Otsikko') 30 | self.assertTrue(fetched.active_translation.title == 'Otsikko') 31 | a2 = Article.objects.create(pub_date=datetime.date.today()) 32 | at2_fi = ArticleTranslation(article=a2, lang=fi, title='Atsikko', body='Diipadaapa', 33 | abstract='dipad') 34 | at2_fi.save() 35 | a3 = Article.objects.create(pub_date=datetime.date.today()) 36 | at3_en = ArticleTranslation(article=a3, lang=en, title='A title', body='lalalalala', 37 | abstract='lala') 38 | at3_en.save() 39 | # Test model initialization with active_translation field. 40 | a3 = Article(id=a3.id, pub_date=a3.pub_date, active_translation=at3_en) 41 | a3.save() 42 | self.assertEqual( 43 | list(Article.objects.filter(active_translation__abstract=None)), 44 | [a1, a3]) 45 | self.assertEqual( 46 | list(Article.objects.filter(active_translation__abstract=None, 47 | active_translation__pk__isnull=False)), 48 | [a1]) 49 | activate('en') 50 | self.assertEqual( 51 | list(Article.objects.filter(active_translation__abstract=None)), 52 | [a1, a2]) 53 | 54 | def test_foreign_key_raises_informative_does_not_exist(self): 55 | referrer = ArticleTranslation() 56 | with self.assertRaisesMessage(Article.DoesNotExist, 'ArticleTranslation has no article'): 57 | referrer.article 58 | 59 | def test_descriptor(self): 60 | activate('fi') 61 | fi = Lang.objects.create(code="fi") 62 | en = Lang.objects.create(code="en") 63 | a1 = Article.objects.create(pub_date=datetime.date.today()) 64 | at1_fi = ArticleTranslation(article=a1, lang=fi, title='Otsikko', body='Diipadaapa') 65 | at1_fi.save() 66 | at2_en = ArticleTranslation(article=a1, lang=en, title='Title', body='Lalalalala') 67 | at2_en.save() 68 | with self.assertNumQueries(1): 69 | self.assertEqual(a1.active_translation.title, "Otsikko") 70 | self.assertEqual(a1.active_translation.body, "Diipadaapa") 71 | # The change in current languate isn't unfortunately noticed 72 | activate("en") 73 | with self.assertNumQueries(0): 74 | self.assertEqual(a1.active_translation.title, "Otsikko") 75 | a1 = Article.objects.get(pk=a1.pk) 76 | with self.assertNumQueries(1): 77 | self.assertEqual(a1.active_translation.title, "Title") 78 | 79 | def test_default_trans_article(self): 80 | activate('fi') 81 | a1 = DefaultTranslationArticle.objects.create( 82 | pub_date=datetime.date.today(), default_lang="fi") 83 | at1_fi = DefaultTranslationArticleTranslation( 84 | article=a1, lang='fi', title='Otsikko', body='Diipadaapa') 85 | at1_fi.save() 86 | at2_en = DefaultTranslationArticleTranslation( 87 | article=a1, lang='en', title='Title', body='Lalalalala') 88 | at2_en.save() 89 | with self.assertNumQueries(2): 90 | # 2 queries needed as the ORM doesn't know that active_translation == 91 | # default_translation in this case. 92 | self.assertEqual(a1.active_translation.title, "Otsikko") 93 | self.assertEqual(a1.default_translation.title, "Otsikko") 94 | activate("en") 95 | a1 = DefaultTranslationArticle.objects.get(pk=a1.pk) 96 | with self.assertNumQueries(2): 97 | self.assertEqual(a1.active_translation.title, "Title") 98 | self.assertEqual(a1.default_translation.title, "Otsikko") 99 | qs = DefaultTranslationArticle.objects.filter(active_translation__title="Title") 100 | self.assertEqual(len(qs), 1) 101 | qs = DefaultTranslationArticle.objects.filter(active_translation__title="Otsikko") 102 | self.assertEqual(len(qs), 0) 103 | qs = DefaultTranslationArticle.objects.filter(default_translation__title="Title") 104 | self.assertEqual(len(qs), 0) 105 | qs = DefaultTranslationArticle.objects.filter(default_translation__title="Otsikko") 106 | self.assertEqual(len(qs), 1) 107 | qs = DefaultTranslationArticle.objects.filter(active_translation__abstract__isnull=True) 108 | self.assertEqual(len(qs), 1) 109 | qs = DefaultTranslationArticle.objects.filter(default_translation__abstract__isnull=True) 110 | self.assertEqual(len(qs), 1) 111 | with self.assertNumQueries(1): 112 | a1 = DefaultTranslationArticle.objects.select_related( 113 | 'active_translation', 'default_translation').get(pk=a1.pk) 114 | self.assertEqual(a1.active_translation.title, "Title") 115 | self.assertEqual(a1.default_translation.title, "Otsikko") 116 | 117 | a1 = DefaultTranslationArticle.objects.only( 118 | 'active_translation__title').select_related('active_translation').get(pk=a1.pk) 119 | with self.assertNumQueries(0): 120 | self.assertEqual(a1.active_translation.title, "Title") 121 | with self.assertNumQueries(1): 122 | self.assertEqual(a1.active_translation.body, "Lalalalala") 123 | 124 | def test_reservations(self): 125 | today = datetime.date.today() 126 | g1 = Guest.objects.create(name="John") 127 | g2 = Guest.objects.create(name="Mary") 128 | room1 = Room.objects.create() 129 | room2 = Room.objects.create() 130 | room3 = Room.objects.create() 131 | Reservation.objects.create(room=room1, guest=g1, from_date=today) 132 | Reservation.objects.create( 133 | room=room1, guest=g1, from_date=today - datetime.timedelta(days=10), 134 | until_date=today - datetime.timedelta(days=9)) 135 | Reservation.objects.create(room=room2, guest=g2, from_date=today, until_date=today) 136 | Reservation.objects.create( 137 | room=room2, guest=g1, from_date=today - datetime.timedelta(days=10), 138 | until_date=today - datetime.timedelta(days=9)) 139 | Reservation.objects.create(room=room3, guest=g2, 140 | from_date=today + datetime.timedelta(days=1)) 141 | self.assertEqual(room1.current_reservation.guest, g1) 142 | self.assertEqual(room2.current_reservation.guest, g2) 143 | self.assertEqual(room3.current_reservation, None) 144 | self.assertQuerysetEqual( 145 | Room.objects.filter(current_reservation__isnull=True), [room3], 146 | lambda x: x) 147 | self.assertQuerysetEqual( 148 | Room.objects.filter(current_reservation__guest=g1), [room1], 149 | lambda x: x) 150 | self.assertQuerysetEqual( 151 | Room.objects.exclude(current_reservation__guest=g1).order_by('pk'), 152 | [room2, room3], lambda x: x) 153 | 154 | def test_delete(self): 155 | """ 156 | Deleting an object pointed to by reverse unique should not delete 157 | the related model. 158 | """ 159 | g1 = Guest.objects.create(name="John") 160 | room1 = Room.objects.create() 161 | today = datetime.date.today() 162 | r1 = Reservation.objects.create(room=room1, guest=g1, from_date=today) 163 | r1.delete() 164 | self.assertQuerysetEqual( 165 | Room.objects.all(), [room1], lambda x: x) 166 | self.assertQuerysetEqual( 167 | Reservation.objects.all(), []) 168 | self.assertQuerysetEqual( 169 | Guest.objects.all(), [g1], lambda x: x) 170 | 171 | 172 | class FormsTests(TestCase): 173 | # ForeignObjects should not have any form fields, currently the user needs 174 | # to manually deal with the foreignobject relation. 175 | class ArticleForm(forms.ModelForm): 176 | class Meta: 177 | model = Article 178 | fields = '__all__' 179 | 180 | def test_foreign_object_form(self): 181 | # A very crude test checking that the non-concrete fields do not get form fields. 182 | form = FormsTests.ArticleForm() 183 | self.assertIn('id_pub_date', form.as_table()) 184 | self.assertNotIn('active_translation', form.as_table()) 185 | form = FormsTests.ArticleForm(data={'pub_date': str(datetime.date.today())}) 186 | self.assertTrue(form.is_valid()) 187 | a = form.save() 188 | self.assertEqual(a.pub_date, datetime.date.today()) 189 | form = FormsTests.ArticleForm(instance=a, data={'pub_date': '2013-01-01'}) 190 | a2 = form.save() 191 | self.assertEqual(a.pk, a2.pk) 192 | self.assertEqual(a2.pub_date, datetime.date(2013, 1, 1)) 193 | 194 | 195 | class InheritanceTests(TestCase): 196 | def test_simple_join(self): 197 | c1 = Child.objects.create() 198 | self.assertQuerysetEqual( 199 | Child.objects.select_related('rel1'), [c1], lambda x: x) 200 | self.assertQuerysetEqual( 201 | Child.objects.select_related('rel2'), [c1], lambda x: x) 202 | self.assertQuerysetEqual( 203 | Child.objects.select_related('rel1', 'rel2'), [c1], lambda x: x) 204 | Rel1.objects.create(f1='foo', parent=c1) 205 | Rel2.objects.create(f1='foo', child=c1) 206 | with self.assertNumQueries(1): 207 | qs = list(Child.objects.select_related('rel1', 'rel2')) 208 | self.assertEqual( 209 | qs, [c1]) 210 | self.assertEqual(qs[0].rel1.f1, "foo") 211 | 212 | def test_value_must_be_found_from_local_model(self): 213 | class FailingChild(Parent): 214 | rev_uniq = ReverseUnique("Rel3", filters=()) 215 | 216 | class Meta: 217 | app_label = 'reverse_unique' 218 | 219 | with self.assertRaisesMessage( 220 | ValueError, 221 | 'The field(s) uniq_field of model reverse_unique.Parent which ' 222 | 'reverse_unique.Rel3.a_model is pointing to cannot be found from ' 223 | 'reverse_unique.FailingChild. Add ReverseUnique to parent instead.'): 224 | # Unfortunately we get the error only at first query, not at 225 | # model definition time. 226 | FailingChild.objects.filter(rev_uniq__pk__contains=1) 227 | 228 | class FailingChild2(Parent): 229 | parent_ptr = models.OneToOneField( 230 | Parent, on_delete=models.CASCADE, parent_link=True, to_field='uniq_field' 231 | ) 232 | rel4 = ReverseUnique("Rel1", filters=()) 233 | 234 | class Meta: 235 | app_label = 'reverse_unique' 236 | 237 | with self.assertRaisesMessage( 238 | ValueError, 239 | 'The field(s) id of model reverse_unique.Parent which ' 240 | 'reverse_unique.Rel1.parent is pointing to cannot be found from ' 241 | 'reverse_unique.FailingChild2. Add ReverseUnique to parent instead.'): 242 | # The local model doesn't contain parent's id - so can't generate 243 | # working query... 244 | FailingChild2.objects.filter(rel4__id__contains=1) 245 | 246 | def test_through_parent(self): 247 | c1 = AnotherChild.objects.create(uniq_field='1') 248 | c2 = AnotherChild.objects.create(uniq_field='2') 249 | Rel1.objects.create(f1='foobar', parent=c1) 250 | Rel1.objects.create(f1='foobaz', parent=c2) 251 | self.assertEqual( 252 | AnotherChild.objects.get(rel1_child__f1__endswith='baz'), c2 253 | ) 254 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [flake8] 5 | exclude = .git 6 | ignore = E123,E128,E402,E501,W503,E731,W601 7 | max-line-length = 119 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | import os 3 | from setuptools import setup, find_packages 4 | 5 | 6 | def read(fname): 7 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 8 | 9 | setup( 10 | name="django-reverse-unique", 11 | version="1.1", 12 | description="A ReverseUnique field implementation for Django", 13 | long_description=read('README.rst'), 14 | url='https://github.com/akaariai/django-reverse-unique', 15 | license='BSD', 16 | author='Anssi Kääriäinen', 17 | author_email='akaariai@gmail.com', 18 | classifiers=[ 19 | 'Development Status :: 5 - Production/Stable', 20 | 'Environment :: Console', 21 | 'Framework :: Django', 22 | 'Framework :: Django :: 3.2', 23 | 'Framework :: Django :: 4.0', 24 | 'Framework :: Django :: 4.1', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: BSD License', 27 | 'Operating System :: OS Independent', 28 | 'Programming Language :: Python :: 3', 29 | 'Programming Language :: Python :: 3.6', 30 | 'Programming Language :: Python :: 3.7', 31 | 'Programming Language :: Python :: 3.8', 32 | 'Programming Language :: Python :: 3.9', 33 | 'Programming Language :: Python :: 3.10', 34 | ], 35 | packages=find_packages(exclude=['tests']), 36 | install_requires=['django'], 37 | ) 38 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | args_are_paths = false 3 | envlist = 4 | flake8, 5 | py36-3.2, 6 | py37-3.2, 7 | py38-{3.2,4.0,4.1,main}, 8 | py39-{3.2,4.0,4.1,main}, 9 | py310-{3.2,4.0,4.1,main}, 10 | 11 | [gh-actions] 12 | python = 13 | 3.6: py36, flake8, isort 14 | 3.7: py37 15 | 3.8: py38 16 | 3.9: py39 17 | 3.10: py310 18 | 19 | [testenv] 20 | usedevelop = true 21 | commands = 22 | {envpython} -R -Wonce {envbindir}/coverage run -a -m django test -v2 --settings=reverse_unique_tests.settings {posargs} 23 | coverage report 24 | deps = 25 | coverage 26 | 3.2: Django>=3.2,<4.0 27 | 4.0: Django>=4.0,<4.1 28 | 4.1: Django>=4.1,<4.2 29 | main: https://github.com/django/django/archive/main.tar.gz 30 | passenv = 31 | GITHUB_* 32 | 33 | [testenv:flake8] 34 | basepython = python3.6 35 | commands = 36 | flake8 reverse_unique 37 | deps = 38 | flake8 39 | --------------------------------------------------------------------------------