Skip to content

Commit e790e80

Browse files
committed
docs for expanding updates on neighboring models
1 parent ba1d1e9 commit e790e80

File tree

4 files changed

+263
-8
lines changed

4 files changed

+263
-8
lines changed

docs/examples.rst

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,174 @@ limited for m2m fields. Also see below under optimization examples.
309309
as much as possible.
310310

311311

312+
Multi Table Inheritance
313+
-----------------------
314+
315+
.. |br| raw:: html
316+
317+
<br />
318+
319+
320+
Multi table inheritance works with computed fields with some restrictions you have to be aware of.
321+
The following requires basic knowledge about multi table inheritance in Django and its similarities
322+
to o2o relations on accessor level (also see official Django docs).
323+
324+
Neighboring Models
325+
^^^^^^^^^^^^^^^^^^
326+
327+
Let's illustrate dealing with updates from neighboring models with an example.
328+
(Note: The example can also be found in `example.test_full` under `tests/test_multi_table_example.py`)
329+
330+
.. code-block:: python
331+
332+
from django.db import models
333+
from computedfields.models import ComputedFieldsModel, computed
334+
335+
class User(ComputedFieldsModel):
336+
forname = models.CharField(max_length=32)
337+
surname = models.CharField(max_length=32)
338+
339+
@computed(models.CharField(max_length=64), depends=[
340+
['self', ['forname', 'surname']]
341+
])
342+
def fullname(self):
343+
return '{}, {}'.format(self.surname, self.forname)
344+
345+
class EmailUser(User):
346+
email = models.CharField(max_length=32)
347+
348+
@computed(models.CharField(max_length=128), depends=[
349+
['self', ['email', 'fullname']],
350+
['user_ptr', ['fullname']] # trigger updates from User type as well
351+
])
352+
def email_contact(self):
353+
return '{} <{}>'.format(self.fullname, self.email)
354+
355+
class Work(ComputedFieldsModel):
356+
subject = models.CharField(max_length=32)
357+
user = models.ForeignKey(User, on_delete=models.CASCADE)
358+
359+
@computed(models.CharField(max_length=64), depends=[
360+
['self', ['subject']],
361+
['user', ['fullname']],
362+
['user.emailuser', ['fullname']] # trigger updates from EmailUser type as well
363+
])
364+
def descriptive_assigment(self):
365+
return '"{}" is assigned to "{}"'.format(self.subject, self.user.fullname)
366+
367+
In the example there are two surprising `depends` rules:
368+
369+
1. ``['user_ptr', ['fullname']]`` on ``EmailUser.email_contact``
370+
2. ``['user.emailuser', ['fullname']]`` on ``Work.descriptive_assigment``
371+
372+
Both are needed to expand the update rules in a way, that parent or derived models are also respected
373+
for the field updates. While the first rule extends updates to the parent model `User`
374+
(ascending in the model inheritance), the second one expands updates to a descendant.
375+
376+
*Why do I have to create those counter-intuitive rules?*
377+
378+
Currently the resolver does not expand on multi table inheritance automatically.
379+
Furthermore it might not be wanted in all circumstances, that parent or derived models
380+
trigger updates on other ends. Thus it has to be set explicitly (might change with future versions,
381+
if highly demanded).
382+
383+
*When do I have to place those additional rules?*
384+
385+
In general the resolver updates computed fields only from model-field associations,
386+
that were explicitly given in `depends` rules. Therefore it will not catch changes on
387+
parent or derived models.
388+
389+
In the example above without the first rule any changes to an instance of `User` will not
390+
trigger a recalculation of ``EmailUser.email_contact``. This is most likely unwanted behavior for this
391+
particular example, as anyone would expect, that changing parts of the name should update the email contact
392+
information here.
393+
394+
Without the second rule, ``Work.descriptive_assigment`` will not be updated from changes of an
395+
`EmailUser` instance, which again is probably unwanted, as anyone would expect `EmailUser` to behave
396+
like a `User` instance here.
397+
398+
*How to derive those rules?*
399+
400+
To understand, how to construct those additional rules, we have to look first at the rules,
401+
they are derived from:
402+
403+
- first one is derived from ``['self', ['email', 'fullname']]``
404+
- second one is derived from ``['user', ['fullname']]``
405+
406+
**Step 1 - check, whether the path ends on multi table model**
407+
408+
Looking at the relation paths (left side of the rules), both have something in common - they both end
409+
on a model with multi table inheritance (`self` in 1. pointing to `EmailUser` model,
410+
`user` in 2. pointing to `User` model). So whenever a relation ends on a multi table model,
411+
there is a high chance, that you might want to apply additional rules for neighboring models.
412+
413+
**Step 2 - derive new relational path from model inheritance**
414+
415+
Next question is, whether you want to expand ascending or descending or both in the model inheritance:
416+
417+
- For ascending expansion append the o2o field name denoting the parent model.
418+
- For descending expansion append reverse o2o relation name pointing to the derived model.
419+
420+
(Note: If a relation expands on `self` entries, `self` has to removed from the path.)
421+
422+
At this point it is important to know, how Django denotes multi table relations on model field level.
423+
By default the o2o field is placed on the descendent model as `modelname_ptr`, while the reverse relation
424+
gets the child modelname on the ancestor model as `modelname` (all lowercase).
425+
426+
In the example above ascending from `EmailUser` to `User` creates a relational path `user_ptr`,
427+
while descending from `User` to `EmailUser` needs a relational path of `emailuser`.
428+
429+
**Step 3 - apply fieldnames on right side**
430+
431+
For descending rules you can just copy over the field names on the right side. For the descent from
432+
`User` to `EmailUser` we finally get:
433+
434+
- ``['user.emailuser', ['fullname']]``
435+
436+
to be added to `depends` on ``Work.descriptive_assigment``.
437+
438+
For ascending rules you should be careful not to copy over field names on the right side, that are defined on
439+
descendent models. After removing `email` from the field names we finally get for the ascent from `EmailUser`
440+
to `User`:
441+
442+
- ``['user_ptr', ['fullname']]``
443+
444+
to be added to `depends` on ``EmailUser.email_contact``.
445+
446+
Up-Pulling Fields
447+
^^^^^^^^^^^^^^^^^
448+
449+
The resolver has a special rule for handling dependencies to fields on derived multi table models.
450+
Therefore it is possible to create a computed field on the parent model, that conditionally
451+
updates from different descendent model fields, example:
452+
453+
.. code-block:: python
454+
455+
class Base(ComputedFieldsModel):
456+
@computed(models.CharField(max_length=32), depends=[
457+
['a', ['type']], # pull type field from A descendant
458+
['b', ['type']], # pull type field from B descendant
459+
['b.c', ['subtype']] # pull subtype field from C descendant
460+
])
461+
def type(self):
462+
if hasattr(self, 'a'):
463+
return a.type
464+
if hasattr(self, 'b'):
465+
if hasattr(self, 'b'):
466+
return self.b.c.subtype
467+
return b.type
468+
return ''
469+
470+
class A(Base):
471+
type = models.CharField(max_length=32, default='a')
472+
class B(Base):
473+
type = models.CharField(max_length=32, default='b')
474+
class C(B):
475+
subtype = models.CharField(max_length=32, default='sub-c')
476+
477+
Note that you have to guard the attribute access yourself in the method code.
478+
479+
312480
Forced Update of Computed Fields
313481
--------------------------------
314482

docs/manual.rst

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -316,15 +316,13 @@ on abstract models or on the final model. They are treated as local computed fie
316316
Multi Table Inheritance
317317
^^^^^^^^^^^^^^^^^^^^^^^
318318

319-
Multi table inheritance is currently treated as **not** supported.
320-
It still works with the following restrictions:
319+
Multi table inheritance is supported with the following restriction:
321320

322-
- No up- or downcasting yet, therefore updates are only triggered on the model, that was given
323-
to `update_dependent`. It is currently your responsibility to either invoke `update_dependent`
324-
for derived or parent models as well, or to mark the dependency correctly via its o2o relation.
325-
- Conditional "up-pulling" (depending on values from certain derived models) does not work.
321+
.. NOTE::
322+
323+
**No automatic up- or downcasting** - the resolver strictly limits updates to model types listed in `depends`.
324+
Also see example documentation on how to expand updates to neighboring model types manually.
326325

327-
(This will change with future versions.)
328326

329327
Proxy Models
330328
^^^^^^^^^^^^

example/test_full/models.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,7 @@ def backward_name(self):
777777
except type(self).o.RelatedObjectDoesNotExist:
778778
return ''
779779

780+
780781
# multi table tests
781782
class MtRelated(models.Model):
782783
name = models.CharField(max_length=32)
@@ -829,7 +830,6 @@ class MtSubDerived(MtDerived2):
829830
sub = models.CharField(max_length=32)
830831

831832

832-
833833
# Test classes for multi table inheritance support
834834
class ParentModel(ComputedFieldsModel):
835835
x = models.IntegerField(default=0)
@@ -887,6 +887,7 @@ class DependsOnParentComputed(ComputedFieldsModel):
887887
def z2(self):
888888
return self.parent.z * 2
889889

890+
890891
# ptr based multi table access
891892
class MtPtrBase(models.Model):
892893
basename = models.CharField(max_length=32)
@@ -898,3 +899,35 @@ class MtPtrDerived(MtPtrBase, ComputedFieldsModel):
898899
])
899900
def comp(self):
900901
return self.basename
902+
903+
904+
# test multi table example in docs
905+
class User(ComputedFieldsModel):
906+
forname = models.CharField(max_length=32)
907+
surname = models.CharField(max_length=32)
908+
909+
@computed(models.CharField(max_length=64), depends=[['self', ['forname', 'surname']]])
910+
def fullname(self):
911+
return '{}, {}'.format(self.surname, self.forname)
912+
913+
class EmailUser(User):
914+
email = models.CharField(max_length=32)
915+
916+
@computed(models.CharField(max_length=128), depends=[
917+
['self', ['email', 'fullname']],
918+
['user_ptr', ['fullname']] # trigger updates from User type as well
919+
])
920+
def email_contact(self):
921+
return '{} <{}>'.format(self.fullname, self.email)
922+
923+
class Work(ComputedFieldsModel):
924+
subject = models.CharField(max_length=32)
925+
user = models.ForeignKey(User, on_delete=models.CASCADE)
926+
927+
@computed(models.CharField(max_length=64), depends=[
928+
['self', ['subject']],
929+
['user', ['fullname']],
930+
['user.emailuser', ['fullname']] # trigger updates from EmailUser type as well
931+
])
932+
def descriptive_assigment(self):
933+
return '"{}" is assigned to "{}"'.format(self.subject, self.user.fullname)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from django.test import TestCase
2+
from ..models import User, EmailUser, Work
3+
4+
5+
class TestUserMultiTable(TestCase):
6+
def setUp(self):
7+
self.normal_user = User.objects.create(forname='John', surname='Doe')
8+
self.email_user = EmailUser.objects.create(forname='Sally', surname='Housecoat', email='s.h@example.com')
9+
self.work1 = Work.objects.create(subject='close window', user=self.normal_user)
10+
self.work2 = Work.objects.create(subject='open door', user=self.email_user)
11+
12+
def test_initial(self):
13+
# working on local fields should correctly update all fields including those on parent models
14+
self.assertEqual(self.normal_user.fullname, 'Doe, John')
15+
self.assertEqual(self.email_user.fullname, 'Housecoat, Sally')
16+
self.assertEqual(self.email_user.email_contact, 'Housecoat, Sally <s.h@example.com>')
17+
self.assertEqual(self.work1.descriptive_assigment, '"close window" is assigned to "Doe, John"')
18+
self.assertEqual(self.work2.descriptive_assigment, '"open door" is assigned to "Housecoat, Sally"')
19+
20+
def test_change_surname_on_user(self):
21+
john = User.objects.get(forname='John')
22+
john.surname = 'Bow'
23+
john.save(update_fields=['surname'])
24+
john.refresh_from_db()
25+
self.assertEqual(john.fullname, 'Bow, John')
26+
27+
sally = User.objects.get(forname='Sally')
28+
sally.surname = 'Houseboat'
29+
sally.save(update_fields=['surname'])
30+
sally.refresh_from_db()
31+
self.assertEqual(sally.fullname, 'Houseboat, Sally')
32+
33+
# this only updates .email_contact correctly with ['user_ptr', ['fullname']] in depends
34+
# (ascending rule to extend EmailUser to User)
35+
self.email_user.refresh_from_db()
36+
self.assertEqual(self.email_user.email_contact, 'Houseboat, Sally <s.h@example.com>')
37+
38+
# this updates correctly since User got updated
39+
self.work1.refresh_from_db()
40+
self.assertEqual(self.work1.descriptive_assigment, '"close window" is assigned to "Bow, John"')
41+
self.work2.refresh_from_db()
42+
self.assertEqual(self.work2.descriptive_assigment, '"open door" is assigned to "Houseboat, Sally"')
43+
44+
def test_change_surname_on_emailuser(self):
45+
sally = EmailUser.objects.get(forname='Sally')
46+
sally.surname = 'Houseboat'
47+
sally.save(update_fields=['surname'])
48+
49+
# this work correctly since .fullname is treated as local field
50+
sally.refresh_from_db()
51+
self.assertEqual(sally.fullname, 'Houseboat, Sally')
52+
53+
# .descriptive_assigment only updates correctly with ['user.emailuser', ['fullname']] in depends
54+
# (descending rule to extend User to EmailUser)
55+
self.work2.refresh_from_db()
56+
self.assertEqual(self.work2.descriptive_assigment, '"open door" is assigned to "Houseboat, Sally"')

0 commit comments

Comments
 (0)