Skip to content

Commit

Permalink
Basic version
Browse files Browse the repository at this point in the history
  • Loading branch information
daanvdk committed May 30, 2022
1 parent 669d4bc commit b25b215
Show file tree
Hide file tree
Showing 7 changed files with 200 additions and 3 deletions.
4 changes: 4 additions & 0 deletions binder/stored/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .base import Stored # noqa


default_app_config = 'binder.stored.apps.StoredAppConfig'
11 changes: 11 additions & 0 deletions binder/stored/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.apps import AppConfig

from .signal import apps_ready


class StoredAppConfig(AppConfig):

name = 'binder.stored'

def ready(self):
apps_ready.send(sender=None)
153 changes: 153 additions & 0 deletions binder/stored/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
from collections import namedtuple

from django.db.models import F, Aggregate
from django.db.models.signals import post_save, class_prepared
from django.db.models.expressions import BaseExpression
from django.conf import settings

from .signal import apps_ready


Dep = namedtuple('Dep', ['model', 'fields', 'rev_path', 'rev_field'])


def get_deps_base(model, expr):
"""
Given a model and an expr yield all changes that could affect the result
of this expr.
A change is defined as a 4-tuple of (model, changed, rev_path, rev_field).
"""
from ..plugins.loaded_values import LoadedValuesMixin

if not issubclass(model, LoadedValuesMixin):
raise ValueError(f'{model} should inherit from LoadedValuesMixin if you want to use it in a stored field')

if isinstance(expr, Aggregate):
expr, = expr.source_expressions

if isinstance(expr, F):
head, sep, tail = expr.name.partition('__')

field = model._meta.get_field(head)
if not sep and field.is_relation:
sep = '__'
tail = 'id'

if not sep:
if head != 'id':
yield Dep(model, {head}, 'id', 'id')
return

if not field.is_relation:
raise ValueError(f'expected {model.__name__}.{field} to be a relation')

if field.one_to_many:
yield Dep(field.related_model, {field.remote_field.name}, 'id', field.remote_field.column)
elif field.many_to_one:
yield Dep(model, {head}, 'id', 'id')
else:
raise ValueError('unsupported type of relation')

for dep in get_deps(field.related_model, F(tail)):
if dep.rev_path != 'id':
yield dep._replace(rev_path=f'{head}__{dep.rev_path}')
elif field.one_to_many:
yield dep._replace(rev_field=field.remote_field.column)
else:
yield dep._replace(rev_path=head)

else:
raise ValueError(f'cannot infer deps for {expr!r}')


def get_deps(*args, **kwargs):
deps = {}
for dep in get_deps_base(*args, **kwargs):
key = dep._replace(fields=None)
try:
base_dep = deps[key]
except KeyError:
deps[key] = dep
else:
deps[key] = dep._replace(fields=base_dep.fields | dep.fields)
return deps.values()


class Stored:

def __init__(self, expr):
self.expr = expr

def __set_name__(self, model, name):
from ..views import fix_output_field

if 'binder.stored' not in settings.INSTALLED_APPS:
raise ValueError('cannot use Stored if \'binder.stored\' is not in INSTALLED_APPS')

# We dont actually want this to be the attribute
delattr(model, name)

# Get field
fix_output_field(self.expr, model)
if isinstance(self.expr, F):
field = self.expr._output_field_or_none
elif isinstance(self.expr, BaseExpression):
field = self.expr.field
else:
raise ValueError(
'{}.{} is not a valid django query expression'
.format(model.__name__, name)
)

# Make blank & nullable copy of field
_, _, args, kwargs = field.deconstruct()
kwargs['blank'] = True
kwargs['null'] = True
field = type(field)(*args, **kwargs)
field.__binder_stored_expr = self.expr

# Add the field
def add_field(**kwargs):
class_prepared.disconnect(add_field, sender=model)

model.add_to_class(name, field)

class_prepared.connect(add_field, sender=model, weak=False)

# Add triggers for deps
def add_triggers(**kwargs):
apps_ready.disconnect(add_triggers)

register_init(model, name, self.expr)
for dep in get_deps(model, self.expr):
register_dep(model, name, self.expr, dep)

apps_ready.connect(add_triggers, weak=False)


def update_queryset(queryset, name, expr):
updates = []
for obj in queryset.annotate(updated_value=expr):
setattr(obj, name, obj.updated_value)
updates.append(obj)
queryset.model.objects.bulk_update(updates, [name])


def register_init(model, name, expr):
def update_values(instance, **kwargs):
if instance.field_changed('id'):
update_queryset(model.objects.filter(id=instance.id), name, expr)

post_save.connect(update_values, sender=model, weak=False)


def register_dep(model, name, expr, dep):
def update_values(instance, **kwargs):
if instance.field_changed('id', *dep.fields):
ids = [getattr(instance, dep.rev_field)]
if instance.field_changed(dep.rev_field):
ids.append(instance.get_old_value(dep.rev_field))
update_queryset(model.objects.filter(id__in=ids), name, expr)

post_save.connect(update_values, sender=dep.model, weak=False)
4 changes: 4 additions & 0 deletions binder/stored/signal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from django.dispatch import Signal


apps_ready = Signal()
5 changes: 3 additions & 2 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django import setup
import django
from django.conf import settings
from django.core.management import call_command
import os
Expand Down Expand Up @@ -55,6 +55,7 @@
'binder.plugins.token_auth',
'tests',
'tests.testapp',
'binder.stored',
],
'MIGRATION_MODULES': {
'testapp': None,
Expand Down Expand Up @@ -111,7 +112,7 @@
}
})

setup()
django.setup()

# Do the dance to ensure the models are synched to the DB.
# This saves us from having to include migrations
Expand Down
17 changes: 17 additions & 0 deletions tests/test_stored.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from django.test import TestCase

from .testapp.models import Zoo, Animal


class StoredTest(TestCase):

def test_deps(self):
zoo = Zoo.objects.create(name='Zoo')

zoo.refresh_from_db()
self.assertEqual(zoo.stored_animal_count, 0)

for n in range(1, 11):
Animal.objects.create(zoo=zoo, name=f'Animal {n}')
zoo.refresh_from_db()
self.assertEqual(zoo.stored_animal_count, n)
9 changes: 8 additions & 1 deletion tests/testapp/models/zoo.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import os
import datetime

from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Count
from django.db.models.signals import post_delete

from binder.models import BinderModel, BinderImageField
from binder.stored import Stored
from binder.plugins.loaded_values import LoadedValuesMixin

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

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

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

stored_animal_count = Stored(Count('animals'))

def __str__(self):
return 'zoo %d: %s' % (self.pk, self.name)

Expand Down

0 comments on commit b25b215

Please sign in to comment.