Skip to content

Commit b25b215

Browse files
author
Daan van der Kallen
committed
Basic version
1 parent 669d4bc commit b25b215

File tree

7 files changed

+200
-3
lines changed

7 files changed

+200
-3
lines changed

binder/stored/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .base import Stored # noqa
2+
3+
4+
default_app_config = 'binder.stored.apps.StoredAppConfig'

binder/stored/apps.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django.apps import AppConfig
2+
3+
from .signal import apps_ready
4+
5+
6+
class StoredAppConfig(AppConfig):
7+
8+
name = 'binder.stored'
9+
10+
def ready(self):
11+
apps_ready.send(sender=None)

binder/stored/base.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
from collections import namedtuple
2+
3+
from django.db.models import F, Aggregate
4+
from django.db.models.signals import post_save, class_prepared
5+
from django.db.models.expressions import BaseExpression
6+
from django.conf import settings
7+
8+
from .signal import apps_ready
9+
10+
11+
Dep = namedtuple('Dep', ['model', 'fields', 'rev_path', 'rev_field'])
12+
13+
14+
def get_deps_base(model, expr):
15+
"""
16+
Given a model and an expr yield all changes that could affect the result
17+
of this expr.
18+
19+
A change is defined as a 4-tuple of (model, changed, rev_path, rev_field).
20+
"""
21+
from ..plugins.loaded_values import LoadedValuesMixin
22+
23+
if not issubclass(model, LoadedValuesMixin):
24+
raise ValueError(f'{model} should inherit from LoadedValuesMixin if you want to use it in a stored field')
25+
26+
if isinstance(expr, Aggregate):
27+
expr, = expr.source_expressions
28+
29+
if isinstance(expr, F):
30+
head, sep, tail = expr.name.partition('__')
31+
32+
field = model._meta.get_field(head)
33+
if not sep and field.is_relation:
34+
sep = '__'
35+
tail = 'id'
36+
37+
if not sep:
38+
if head != 'id':
39+
yield Dep(model, {head}, 'id', 'id')
40+
return
41+
42+
if not field.is_relation:
43+
raise ValueError(f'expected {model.__name__}.{field} to be a relation')
44+
45+
if field.one_to_many:
46+
yield Dep(field.related_model, {field.remote_field.name}, 'id', field.remote_field.column)
47+
elif field.many_to_one:
48+
yield Dep(model, {head}, 'id', 'id')
49+
else:
50+
raise ValueError('unsupported type of relation')
51+
52+
for dep in get_deps(field.related_model, F(tail)):
53+
if dep.rev_path != 'id':
54+
yield dep._replace(rev_path=f'{head}__{dep.rev_path}')
55+
elif field.one_to_many:
56+
yield dep._replace(rev_field=field.remote_field.column)
57+
else:
58+
yield dep._replace(rev_path=head)
59+
60+
else:
61+
raise ValueError(f'cannot infer deps for {expr!r}')
62+
63+
64+
def get_deps(*args, **kwargs):
65+
deps = {}
66+
for dep in get_deps_base(*args, **kwargs):
67+
key = dep._replace(fields=None)
68+
try:
69+
base_dep = deps[key]
70+
except KeyError:
71+
deps[key] = dep
72+
else:
73+
deps[key] = dep._replace(fields=base_dep.fields | dep.fields)
74+
return deps.values()
75+
76+
77+
class Stored:
78+
79+
def __init__(self, expr):
80+
self.expr = expr
81+
82+
def __set_name__(self, model, name):
83+
from ..views import fix_output_field
84+
85+
if 'binder.stored' not in settings.INSTALLED_APPS:
86+
raise ValueError('cannot use Stored if \'binder.stored\' is not in INSTALLED_APPS')
87+
88+
# We dont actually want this to be the attribute
89+
delattr(model, name)
90+
91+
# Get field
92+
fix_output_field(self.expr, model)
93+
if isinstance(self.expr, F):
94+
field = self.expr._output_field_or_none
95+
elif isinstance(self.expr, BaseExpression):
96+
field = self.expr.field
97+
else:
98+
raise ValueError(
99+
'{}.{} is not a valid django query expression'
100+
.format(model.__name__, name)
101+
)
102+
103+
# Make blank & nullable copy of field
104+
_, _, args, kwargs = field.deconstruct()
105+
kwargs['blank'] = True
106+
kwargs['null'] = True
107+
field = type(field)(*args, **kwargs)
108+
field.__binder_stored_expr = self.expr
109+
110+
# Add the field
111+
def add_field(**kwargs):
112+
class_prepared.disconnect(add_field, sender=model)
113+
114+
model.add_to_class(name, field)
115+
116+
class_prepared.connect(add_field, sender=model, weak=False)
117+
118+
# Add triggers for deps
119+
def add_triggers(**kwargs):
120+
apps_ready.disconnect(add_triggers)
121+
122+
register_init(model, name, self.expr)
123+
for dep in get_deps(model, self.expr):
124+
register_dep(model, name, self.expr, dep)
125+
126+
apps_ready.connect(add_triggers, weak=False)
127+
128+
129+
def update_queryset(queryset, name, expr):
130+
updates = []
131+
for obj in queryset.annotate(updated_value=expr):
132+
setattr(obj, name, obj.updated_value)
133+
updates.append(obj)
134+
queryset.model.objects.bulk_update(updates, [name])
135+
136+
137+
def register_init(model, name, expr):
138+
def update_values(instance, **kwargs):
139+
if instance.field_changed('id'):
140+
update_queryset(model.objects.filter(id=instance.id), name, expr)
141+
142+
post_save.connect(update_values, sender=model, weak=False)
143+
144+
145+
def register_dep(model, name, expr, dep):
146+
def update_values(instance, **kwargs):
147+
if instance.field_changed('id', *dep.fields):
148+
ids = [getattr(instance, dep.rev_field)]
149+
if instance.field_changed(dep.rev_field):
150+
ids.append(instance.get_old_value(dep.rev_field))
151+
update_queryset(model.objects.filter(id__in=ids), name, expr)
152+
153+
post_save.connect(update_values, sender=dep.model, weak=False)

binder/stored/signal.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from django.dispatch import Signal
2+
3+
4+
apps_ready = Signal()

tests/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from django import setup
1+
import django
22
from django.conf import settings
33
from django.core.management import call_command
44
import os
@@ -55,6 +55,7 @@
5555
'binder.plugins.token_auth',
5656
'tests',
5757
'tests.testapp',
58+
'binder.stored',
5859
],
5960
'MIGRATION_MODULES': {
6061
'testapp': None,
@@ -111,7 +112,7 @@
111112
}
112113
})
113114

114-
setup()
115+
django.setup()
115116

116117
# Do the dance to ensure the models are synched to the DB.
117118
# This saves us from having to include migrations

tests/test_stored.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from django.test import TestCase
2+
3+
from .testapp.models import Zoo, Animal
4+
5+
6+
class StoredTest(TestCase):
7+
8+
def test_deps(self):
9+
zoo = Zoo.objects.create(name='Zoo')
10+
11+
zoo.refresh_from_db()
12+
self.assertEqual(zoo.stored_animal_count, 0)
13+
14+
for n in range(1, 11):
15+
Animal.objects.create(zoo=zoo, name=f'Animal {n}')
16+
zoo.refresh_from_db()
17+
self.assertEqual(zoo.stored_animal_count, n)

tests/testapp/models/zoo.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import os
22
import datetime
3+
34
from django.core.exceptions import ValidationError
45
from django.db import models
6+
from django.db.models import Count
57
from django.db.models.signals import post_delete
8+
69
from binder.models import BinderModel, BinderImageField
10+
from binder.stored import Stored
11+
from binder.plugins.loaded_values import LoadedValuesMixin
712

813
def delete_files(sender, instance=None, **kwargs):
914
for field in sender._meta.fields:
@@ -16,7 +21,7 @@ def delete_files(sender, instance=None, **kwargs):
1621

1722
# From the api docs: a zoo with a name. It also has a founding date,
1823
# which is nullable (representing "unknown").
19-
class Zoo(BinderModel):
24+
class Zoo(LoadedValuesMixin, BinderModel):
2025
name = models.TextField()
2126
founding_date = models.DateField(null=True, blank=True)
2227
floor_plan = models.ImageField(upload_to='floor-plans', null=True, blank=True)
@@ -35,6 +40,8 @@ class Zoo(BinderModel):
3540

3641
binder_picture_custom_extensions = BinderImageField(allowed_extensions=['png'], blank=True, null=True)
3742

43+
stored_animal_count = Stored(Count('animals'))
44+
3845
def __str__(self):
3946
return 'zoo %d: %s' % (self.pk, self.name)
4047

0 commit comments

Comments
 (0)