├── joinfield ├── __init__.py ├── tests │ ├── __init__.py │ ├── models.py │ ├── test_sqlite_settings.py │ ├── test_mysql_settings.py │ ├── test_postgres_settings.py │ └── test.py └── joinfield.py ├── dist ├── django_joinfield-0.2.1.tar.gz └── django_joinfield-0.2.1-py3-none-any.whl ├── pyproject.toml ├── runtests.py ├── LICENSE ├── .github └── workflows │ ├── sqlite_test.yml │ ├── postgres_test.yml │ └── mysql_test.yml └── Readme.rst /joinfield/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /joinfield/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dist/django_joinfield-0.2.1.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martsberger/django-joinfield/HEAD/dist/django_joinfield-0.2.1.tar.gz -------------------------------------------------------------------------------- /dist/django_joinfield-0.2.1-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martsberger/django-joinfield/HEAD/dist/django_joinfield-0.2.1-py3-none-any.whl -------------------------------------------------------------------------------- /joinfield/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from joinfield.joinfield import JoinField 4 | 5 | 6 | class Surname(models.Model): 7 | name = models.CharField(primary_key=True, max_length=75) 8 | 9 | 10 | class Person(models.Model): 11 | first_name = models.CharField(max_length=75) 12 | last_name = JoinField(Surname, on_delete=models.DO_NOTHING) 13 | -------------------------------------------------------------------------------- /joinfield/tests/test_sqlite_settings.py: -------------------------------------------------------------------------------- 1 | BACKEND = 'sqlite' 2 | 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.sqlite3', 6 | 'NAME': ':memory:', 7 | } 8 | } 9 | 10 | INSTALLED_APPS = ( 11 | 'joinfield.tests', 12 | ) 13 | 14 | SITE_ID = 1, 15 | 16 | SECRET_KEY = 'secret' 17 | 18 | MIDDLEWARE_CLASSES = ( 19 | 'django.middleware.common.CommonMiddleware', 20 | 'django.middleware.csrf.CsrfViewMiddleware', 21 | ) 22 | -------------------------------------------------------------------------------- /joinfield/tests/test_mysql_settings.py: -------------------------------------------------------------------------------- 1 | BACKEND = 'mysql' 2 | 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.mysql', 6 | 'NAME': 'joinfield', 7 | 'USER': 'root', 8 | 'PASSWORD': 'mysql', 9 | 'HOST': '127.0.0.1', 10 | 'PORT': '3306' 11 | } 12 | } 13 | 14 | INSTALLED_APPS = ( 15 | 'joinfield.tests', 16 | ) 17 | 18 | SITE_ID = 1, 19 | 20 | SECRET_KEY = 'secret' 21 | 22 | MIDDLEWARE_CLASSES = ( 23 | 'django.middleware.common.CommonMiddleware', 24 | 'django.middleware.csrf.CsrfViewMiddleware', 25 | ) 26 | -------------------------------------------------------------------------------- /joinfield/tests/test_postgres_settings.py: -------------------------------------------------------------------------------- 1 | BACKEND = 'postgres' 2 | 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 6 | 'NAME': 'joinfield', 7 | 'USER': 'postgres', 8 | 'PASSWORD': 'postgres', 9 | 'HOST': 'localhost' 10 | } 11 | } 12 | 13 | INSTALLED_APPS = ( 14 | 'joinfield.tests', 15 | ) 16 | 17 | SITE_ID = 1, 18 | 19 | SECRET_KEY = 'secret' 20 | 21 | MIDDLEWARE_CLASSES = ( 22 | 'django.middleware.common.CommonMiddleware', 23 | 'django.middleware.csrf.CsrfViewMiddleware', 24 | ) 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-joinfield" 7 | version = "0.2.1" 8 | authors = [ 9 | { name="Brad Martsberger", email="bradley.marts@gmail.com"}, 10 | ] 11 | description = "A field type for Django models that allows joins to a related model without a foreign key." 12 | readme = "Readme.rst" 13 | requires-python = ">=3.8" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent", 18 | ] 19 | dependencies = [ 20 | "django>=3.2", 21 | ] 22 | 23 | [project.urls] 24 | "Homepage" = "https://github.com/martsberger/django-joinfield" 25 | "Download" = "https://github.com/martsberger/django-joinfield/archive/0.2.1.tar.gz" -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from optparse import OptionParser 4 | 5 | 6 | def parse_args(): 7 | parser = OptionParser() 8 | parser.add_option('-s', '--settings', default='joinfield.tests.test_sqlite_settings', help='Define settings.') 9 | return parser.parse_args() 10 | 11 | if __name__ == '__main__': 12 | options, tests = parse_args() 13 | os.environ['DJANGO_SETTINGS_MODULE'] = options.settings 14 | 15 | # Local imports because DJANGO_SETTINGS_MODULE needs to be set first 16 | import django 17 | from django.test.utils import get_runner 18 | from django.conf import settings 19 | 20 | if hasattr(django, 'setup'): 21 | django.setup() 22 | 23 | TestRunner = get_runner(settings) 24 | runner = TestRunner(verbosity=2, interactive=True, failfast=False) 25 | sys.exit(runner.run_tests(tests)) 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Brad Martsberger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/sqlite_test.yml: -------------------------------------------------------------------------------- 1 | name: Sqlite Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | max-parallel: 4 14 | matrix: 15 | python-version: [ 3.8, 3.9, 3.10.13, 3.11 ] 16 | django-version: [ 3.2.21, 4.1.11, 4.2.5 ] 17 | exclude: 18 | - python-version: 3.8 19 | django-version: 4.1.11 20 | - python-version: 3.8 21 | django-version: 4.2.5 22 | - python-version: 3.11 23 | django-version: 3.2.21 24 | - python-version: 3.11 25 | django-version: 4.1.11 26 | steps: 27 | - uses: actions/checkout@v2 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v2 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | - name: Install Dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install coverage 36 | pip install codecov 37 | pip install -q Django==${{ matrix.django-version }} 38 | - name: Run Tests 39 | run: | 40 | coverage run --omit="*site-packages*","*test*" runtests.py --settings=joinfield.tests.test_sqlite_settings 41 | codecov 42 | -------------------------------------------------------------------------------- /.github/workflows/postgres_test.yml: -------------------------------------------------------------------------------- 1 | name: Postgres Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | max-parallel: 4 14 | matrix: 15 | python-version: [ 3.8, 3.9, 3.10.13, 3.11 ] 16 | django-version: [ 3.2.21, 4.1.11, 4.2.5 ] 17 | exclude: 18 | - python-version: 3.8 19 | django-version: 4.1.11 20 | - python-version: 3.8 21 | django-version: 4.2.5 22 | - python-version: 3.11 23 | django-version: 3.2.21 24 | - python-version: 3.11 25 | django-version: 4.1.11 26 | services: 27 | postgres: 28 | image: postgres 29 | ports: 30 | - 5432:5432 31 | env: 32 | POSTGRES_PASSWORD: postgres 33 | POSTGRES_USER: postgres 34 | options: >- 35 | --health-cmd pg_isready 36 | --health-interval 10s 37 | --health-timeout 5s 38 | --health-retries 5 39 | steps: 40 | - uses: actions/checkout@v2 41 | - name: Set up Python ${{ matrix.python-version }} 42 | uses: actions/setup-python@v2 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | - name: Install Dependencies 46 | run: | 47 | python -m pip install --upgrade pip 48 | pip install coverage 49 | pip install codecov 50 | pip install psycopg2 51 | pip install -q Django==${{ matrix.django-version }} 52 | - name: Run Tests 53 | run: | 54 | coverage run --omit="*site-packages*","*test*" runtests.py --settings=joinfield.tests.test_postgres_settings 55 | codecov 56 | -------------------------------------------------------------------------------- /.github/workflows/mysql_test.yml: -------------------------------------------------------------------------------- 1 | name: MySql Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | max-parallel: 4 14 | matrix: 15 | python-version: [3.8, 3.9, 3.10.13, 3.11] 16 | django-version: [3.2.21, 4.1.11, 4.2.5] 17 | exclude: 18 | - python-version: 3.8 19 | django-version: 4.1.11 20 | - python-version: 3.8 21 | django-version: 4.2.5 22 | - python-version: 3.11 23 | django-version: 3.2.21 24 | - python-version: 3.11 25 | django-version: 4.1.11 26 | services: 27 | mysql: 28 | image: mysql 29 | ports: 30 | - 3306:3306 31 | env: 32 | MYSQL_PASSWORD: mysql 33 | MYSQL_USER: mysql 34 | MYSQL_ROOT_PASSWORD: mysql 35 | MYSQL_DATABASE: mysql 36 | MYSQL_HOST: 127.0.0.1 37 | options: >- 38 | --health-cmd="mysqladmin ping" 39 | --health-interval=10s 40 | --health-timeout=5s 41 | --health-retries=5 42 | steps: 43 | - uses: actions/checkout@v2 44 | - name: Set up Python ${{ matrix.python-version }} 45 | uses: actions/setup-python@v2 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | - name: Install Dependencies 49 | run: | 50 | python -m pip install --upgrade pip 51 | pip install coverage 52 | pip install codecov 53 | pip install mysqlclient 54 | pip install -q Django==${{ matrix.django-version }} 55 | - name: Run Tests 56 | run: | 57 | coverage run --omit="*site-packages*","*test*" runtests.py --settings=joinfield.tests.test_mysql_settings 58 | codecov 59 | -------------------------------------------------------------------------------- /joinfield/joinfield.py: -------------------------------------------------------------------------------- 1 | from django.db import router 2 | from django.db.models import ForeignKey 3 | from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor 4 | 5 | 6 | class JoinFieldDescriptor(ForwardManyToOneDescriptor): 7 | def __set__(self, instance, value): 8 | """ 9 | Set the related instance or literal value through the forward relation. 10 | 11 | when setting ``child.parent = parent``: 12 | 13 | - ``self`` is the descriptor managing the ``parent`` attribute 14 | - ``instance`` is the ``child`` instance 15 | - ``value`` is the ``parent`` instance on the right of the equal sign 16 | or a literal value 17 | """ 18 | if isinstance(value, self.field.remote_field.model._meta.concrete_model): 19 | # Make sure the router allows the relation 20 | if instance._state.db is None: 21 | instance._state.db = router.db_for_write(instance.__class__, instance=value) 22 | elif value._state.db is None: 23 | value._state.db = router.db_for_write(value.__class__, instance=instance) 24 | elif value._state.db is not None and instance._state.db is not None: 25 | if not router.allow_relation(value, instance): 26 | raise ValueError('Cannot assign "%r": the current database router prevents this relation.' % value) 27 | 28 | # If value is a related instance, get the value from the related 29 | # field attribute 30 | related_value = getattr(value, self.field.target_field.name) 31 | 32 | else: 33 | related_value = value 34 | value = None 35 | 36 | # Set the related instance cache used by __get__ to avoid an SQL query 37 | # when accessing the attribute we just set. 38 | self.field.set_cached_value(instance, value) 39 | 40 | # Set the values of the related field. 41 | for lh_field, rh_field in self.field.related_fields: 42 | setattr(instance, lh_field.attname, related_value) 43 | 44 | def __get__(self, instance, cls=None): 45 | try: 46 | return super(JoinFieldDescriptor, self).__get__(instance, cls=cls) 47 | except self.field.related_model.DoesNotExist: 48 | return None 49 | 50 | 51 | class JoinField(ForeignKey): 52 | forward_related_accessor_class = JoinFieldDescriptor 53 | requires_unique_target = False 54 | 55 | def __init__(self, *args, **kwargs): 56 | kwargs['db_constraint'] = kwargs.get('db_constraint', False) 57 | self.db_field_name = kwargs.pop('db_field_name', None) 58 | super(JoinField, self).__init__(*args, **kwargs) 59 | 60 | def get_attname(self): 61 | return self.db_field_name or super(JoinField, self).get_attname() 62 | -------------------------------------------------------------------------------- /joinfield/tests/test.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Count 2 | from django.test import TestCase 3 | 4 | from joinfield.tests.models import Person, Surname 5 | 6 | 7 | class Tests(TestCase): 8 | 9 | def test_join_field(self): 10 | # Create a child object with no related object parent 11 | Person.objects.create(first_name='Jon', last_name='Doe') 12 | 13 | # We can retrieve this object from the database 14 | jon_doe = Person.objects.get(first_name='Jon', last_name='Doe') 15 | self.assertIsNone(jon_doe.last_name) 16 | self.assertEqual(jon_doe.last_name_id, 'Doe') 17 | 18 | # Create a parent object corresponding to the existing child 19 | doe = Surname.objects.create(name='Doe') 20 | 21 | jon_doe = Person.objects.get(first_name='Jon', last_name='Doe') 22 | self.assertEqual(jon_doe.last_name, doe) 23 | self.assertEqual(jon_doe.last_name_id, 'Doe') 24 | 25 | # Create a child object with a related instance assigned to join field 26 | jane_doe = Person.objects.create(first_name='Jane', last_name=doe) 27 | 28 | self.assertEqual(jane_doe.last_name, doe) 29 | self.assertEqual(jane_doe.last_name_id, 'Doe') 30 | 31 | def test_join_field_forward_filter(self): 32 | doe = Surname.objects.create(name='Doe') 33 | jon = Person.objects.create(first_name='Jon', last_name='Doe') 34 | jane = Person.objects.create(first_name='Jane', last_name='Doe') 35 | 36 | does = Person.objects.filter(last_name='Doe') 37 | self.assertQuerysetEqual(does, [jon, jane], ordered=False) 38 | 39 | jamal = Person.objects.create(first_name='jamal', last_name=doe) 40 | 41 | does = Person.objects.filter(last_name='Doe') 42 | self.assertQuerysetEqual(does, [jon, jane, jamal], ordered=False) 43 | 44 | does = Person.objects.filter(last_name=doe) 45 | self.assertQuerysetEqual(does, [jon, jane, jamal], ordered=False) 46 | 47 | def test_join_field_reverse_filter(self): 48 | doe = Surname.objects.create(name='Doe') 49 | smith = Surname.objects.create(name='Smith') 50 | 51 | Person.objects.create(first_name='Jon', last_name=doe) 52 | Person.objects.create(first_name='Jane', last_name=doe) 53 | Person.objects.create(first_name='Jack', last_name=smith) 54 | 55 | surnames = Surname.objects.filter(person__first_name='Jon') 56 | self.assertQuerysetEqual(surnames, [doe], ordered=False) 57 | 58 | def test_join_field_annotate(self): 59 | doe = Surname.objects.create(name='Doe') 60 | smith = Surname.objects.create(name='Smith') 61 | 62 | Person.objects.create(first_name='Jon', last_name=doe) 63 | Person.objects.create(first_name='Jane', last_name=doe) 64 | Person.objects.create(first_name='Jack', last_name=smith) 65 | 66 | surname_counts = Surname.objects.annotate(count=Count('person')) 67 | expected_surname_counts = { 68 | 'Doe': 2, 69 | 'Smith': 1 70 | } 71 | 72 | for surname in surname_counts: 73 | self.assertEqual(surname.count, expected_surname_counts[surname.name]) 74 | 75 | def test_join_field_child_set(self): 76 | Surname.objects.create(name='Doe') 77 | Surname.objects.create(name='Smith') 78 | 79 | jon = Person.objects.create(first_name='Jon', last_name='Doe') 80 | jane = Person.objects.create(first_name='Jane', last_name='Doe') 81 | Person.objects.create(first_name='Jack', last_name='Smith') 82 | 83 | doe = Surname.objects.get(name='Doe') 84 | 85 | does = doe.person_set.all() 86 | 87 | self.assertQuerysetEqual(does, [jon, jane], ordered=False) 88 | 89 | def test_join_field_delete(self): 90 | doe = Surname.objects.create(name='Doe') 91 | smith = Surname.objects.create(name='Smith') 92 | 93 | Person.objects.create(first_name='Jon', last_name=doe) 94 | Person.objects.create(first_name='Jane', last_name=doe) 95 | Person.objects.create(first_name='Jack', last_name=smith) 96 | 97 | # Deleting a Surname should not delete a Person pointing at it 98 | self.assertEqual(Surname.objects.count(), 2) 99 | self.assertEqual(Person.objects.count(), 3) 100 | 101 | jon = Person.objects.get(first_name='Jon', last_name='Doe') 102 | self.assertEqual(jon.last_name, doe) 103 | self.assertEqual(jon.last_name_id, 'Doe') 104 | 105 | doe.delete() 106 | 107 | self.assertEqual(Surname.objects.count(), 1) 108 | self.assertEqual(Person.objects.count(), 3) 109 | 110 | jon = Person.objects.get(first_name='Jon', last_name='Doe') 111 | self.assertIsNone(jon.last_name) 112 | self.assertEqual(jon.last_name_id, 'Doe') 113 | -------------------------------------------------------------------------------- /Readme.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/martsberger/django-joinfield.svg?branch=master 2 | :target: https://travis-ci.org/martsberger/django-joinfield 3 | 4 | Django JoinField 5 | ================ 6 | 7 | This package provides a field type for Django models that allows 8 | joins to a related model without a foreign key. 9 | 10 | Quickstart 11 | ---------- 12 | 13 | Install via pip:: 14 | 15 | pip install django-joinfield 16 | 17 | Put ``joinfield`` in INSTALLED_APPS in your settings file. Then you can import 18 | JoinField and use it when defining your models:: 19 | 20 | from joinfield.joinfield import JoinField 21 | 22 | class Parent(models.Model): 23 | column = models.CharField(max_length=64) 24 | 25 | 26 | class Child(models.Model): 27 | parent = JoinField(Parent, to_field='column') 28 | 29 | The database column created in the child table will have the type defined by 30 | the ``to_field`` parent column. In this case, a CharField. It will not be a 31 | foreign key and there will be no database constraints between these 32 | two tables. 33 | 34 | Now you can do joins between child and parent using the orm:: 35 | 36 | parent = Parent.objects.first() 37 | children = Child.objects.filter(parent=parent) 38 | 39 | # Or 40 | children = Child.objects.filter(parent__column='some value') 41 | 42 | Examples 43 | -------- 44 | 45 | Let's imagine a database of people and family information. One table might 46 | contain general family information keyed by Surname:: 47 | 48 | class Surname(models.Model): 49 | name = models.CharField(max_length=32, primary_key=True) 50 | crest = models.ImageField(...) 51 | references = models.ManyToManyField('FamilyDocuments', ...) 52 | origin = models.ForeignKeyField('Country') 53 | 54 | A separate table contains the individual people. Each person has a last name 55 | and if that last name corresponds to a Surname in the Surname table, we want 56 | to be able to join from Person to Surname. However, not every person's last 57 | name will correspond to a Surname that we have detailed information for. So 58 | we don't want to require a record in the Surname table in order to create a 59 | Person. The person class will use the JoinField:: 60 | 61 | class Person(models.Model): 62 | first_name = models.CharField(max_length=32) 63 | last_name = JoinField(Surname, on_delete=models.DO_NOTHING) 64 | 65 | This will result in the database column for Person.last_name being a CharField 66 | just like it is defined on Surname.name. 67 | 68 | A Person object can be created by assigning either a Surname object or a 69 | literal value to the last_name attribute:: 70 | 71 | # Create a Person object with a literal value for last name 72 | Person.objects.create(first_name='Jon', last_name='Doe') 73 | 74 | # A Surname object can be created after the fact 75 | doe = Surname.objects.create(name='Doe') 76 | 77 | # A person can be created by passing a Surname object as the last_name 78 | Person.objects.create(first_name='Jane', last_name=doe) 79 | 80 | The ORM can be used for both forward and reverse relationships:: 81 | 82 | # These two queries are equivalent 83 | Person.objects.filter(last_name='Doe') # literal filter 84 | Person.objects.filter(last_name=doe) # filter on forward relationship 85 | 86 | # The reverse relationship can also be used for filtering 87 | Surname.objects.filter(person__first_name='Jon') 88 | 89 | # And annotations 90 | Surname.objects.annotate(count=Count('person')) 91 | 92 | The ``_id`` field 93 | ----------------- 94 | 95 | Other than the lack of database constraints (which provides the ability to 96 | assign a literal in addition to an instance), the JoinField is very much 97 | like a ForeignKey field. The value that is stored in the database is the 98 | literal value assigned or the value of the field on the instance we join to. 99 | When an instance is retrieved, the attribute defined as a JoinField will have 100 | the instance we join to as it's value. An additional attribute with "_id" 101 | appended stores the literal value. 102 | 103 | From the example above, when a Person instance is retrieved, the last_name 104 | attribute will be a Surname instance and the last_name_id attribute will be 105 | the actual value of the last_name. For Person instances that don't join to 106 | any value in the Surname table (not valid for ForeignKey), the last_name 107 | attribute will be None and the last_name_id attribute will still have the 108 | literal last_name value. 109 | 110 | For example:: 111 | 112 | jon = Person.objects.get(first_name='Jon', last_name='Doe') 113 | print (jon.first_name, jon.last_name, jon.last_name_id) 114 | 115 | Will print the following:: 116 | 117 | (u'Jon', , u'Doe') 118 | 119 | And if we create a Person with no Surname:: 120 | 121 | james = Person.objects.create(first_name='James', last_name='Smith') 122 | print (james.first_name, james.last_name, james.last_name_id) 123 | 124 | prints the following:: 125 | 126 | (u'James', None, u'Smith') 127 | 128 | License 129 | ------- 130 | 131 | MIT 132 | 133 | Copyright 2023 Brad Martsberger 134 | 135 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 136 | 137 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 138 | 139 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------------------------