diff --git a/.gitignore b/.gitignore index 6d582b3..44e66be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ *.pyc docs/_build +/.eggs +/*.egg-info diff --git a/COPYING b/COPYING index ae62558..8eea954 100644 --- a/COPYING +++ b/COPYING @@ -1,3 +1,4 @@ +Copyright © 2016 Chris Lamb Copyright © 2010, 2011 UUMC Ltd. All rights reserved. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6691b6 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# django-cache-toolbox + +_Non-magical object caching for Django._ + +Another caching framework for Django that does not do any magic behind your +back, saving brain cycles when debugging as well as sticking to Django +principles. + +## Installation + +From [PyPI](https://pypi.org/project/django-cache-toolbox/): +``` +pip install django-cache-toolbox +``` + +## Basic Usage + +``` python +from cache_toolbox import cache_model, cache_relation +from django.db import models + +class Foo(models.Model): + ... + +class Bazz(models.Model): + foo = models.OneToOneField(Foo, related_name='bazz', primary_key=True) + ... + +# Prepare caching of a model +cache_model(Foo) + +# Prepare caching of a relation +cache_relation(Foo.bazz) + +# Fetch the cached version of a model +foo = Foo.get_cached(pk=42) + +# Load a cached relation +print(foo.bazz_cache) +``` + +See the module docstrings for further details. diff --git a/README.rst b/README.rst deleted file mode 100644 index cf8b23e..0000000 --- a/README.rst +++ /dev/null @@ -1,4 +0,0 @@ -django-cache-toolbox -============================ - -Documentation: http://code.playfire.com/django-cache-toolbox/ diff --git a/cache_toolbox/app_settings.py b/cache_toolbox/app_settings.py index cb19bd3..fe42215 100644 --- a/cache_toolbox/app_settings.py +++ b/cache_toolbox/app_settings.py @@ -3,6 +3,6 @@ # Default cache timeout CACHE_TOOLBOX_DEFAULT_TIMEOUT = getattr( settings, - 'CACHE_TOOLBOX_DEFAULT_TIMEOUT', + "CACHE_TOOLBOX_DEFAULT_TIMEOUT", 60 * 60 * 24 * 7, ) diff --git a/cache_toolbox/core.py b/cache_toolbox/core.py index 0a28663..86f5c82 100644 --- a/cache_toolbox/core.py +++ b/cache_toolbox/core.py @@ -8,16 +8,80 @@ """ +try: + import cPickle as pickle +except ImportError: + import pickle + from django.core.cache import cache -from django.db import DEFAULT_DB_ALIAS +from django.db import DEFAULT_DB_ALIAS, transaction from . import app_settings -def get_instance( - model, instance_or_pk, - timeout=None, using=None, create=False, defaults=None -): +CACHE_FORMAT_VERSION = 2 + + +def setattrdefault(obj, name, default): + try: + return getattr(obj, name) + except AttributeError: + setattr(obj, name, default) + return default + + +def get_related_name(descriptor): + return "%s_cache" % descriptor.related.field.related_query_name() + + +def get_related_cache_name(related_name: str) -> str: + return "_%s_cache" % related_name + + +def add_always_fetch_relation(descriptor): + setattrdefault( + descriptor.related.model, + "_cache_fetch_related", + [], + ).append(descriptor) + + +def serialise(instance): + data = {} + for field in instance._meta.fields: + # Harmless to save, but saves space in the dictionary - we already know + # the primary key when we lookup + if field.primary_key: + continue + + # We also don't want to save any virtual fields. + if not field.concrete: + continue + + data[field.attname] = getattr(instance, field.attname) + + # Encode through Pickle, since that allows overriding and covers (most) + # Python types we'd want to serialise. + return pickle.dumps(data, protocol=-1) + + +def deserialise(model, data, pk, using): + # Try and construct instance from dictionary + instance = model(pk=pk, **pickle.loads(data)) + + # Ensure instance knows that it already exists in the database, + # otherwise we will fail any uniqueness checks when saving the + # instance. + instance._state.adding = False + + # Specify database so that instance is setup correctly. We don't + # namespace cached objects by their origin database, however. + instance._state.db = using or DEFAULT_DB_ALIAS + + return instance + + +def get_instance(model, instance_or_pk, timeout=None, using=None): """ Returns the ``model`` instance with a primary key of ``instance_or_pk``. @@ -27,9 +91,6 @@ def get_instance( If omitted, the timeout value defaults to ``settings.CACHE_TOOLBOX_DEFAULT_TIMEOUT`` instead of 0 (zero). - If ``create`` is True, we are going to create the instance in case that it - was not found. - Example:: >>> get_instance(User, 1) # Cache miss @@ -40,59 +101,75 @@ def get_instance( True """ - pk = getattr(instance_or_pk, 'pk', instance_or_pk) - key = instance_key(model, instance_or_pk) - data = cache.get(key) + pk = getattr(instance_or_pk, "pk", instance_or_pk) - if data is not None: - try: - # Try and construct instance from dictionary - instance = model(pk=pk, **data) - - # Ensure instance knows that it already exists in the database, - # otherwise we will fail any uniqueness checks when saving the - # instance. - instance._state.adding = False + primary_model = model + descriptors = getattr(primary_model, "_cache_fetch_related", ()) + models = [model, *(d.related.field.model for d in descriptors)] + # Note: we're assuming that the relations are primary key foreign keys, and + # so all have the same primary key. This matches the assumption which + # `cache_relation` makes. + keys_to_models = {instance_key(model, instance_or_pk): model for model in models} - # Specify database so that instance is setup correctly. We don't - # namespace cached objects by their origin database, however. - instance._state.db = using or DEFAULT_DB_ALIAS + data_map = cache.get_many(tuple(keys_to_models.keys())) + instance_map = {} - return instance + if data_map.keys() == keys_to_models.keys(): + try: + for key, data in data_map.items(): + model = keys_to_models[key] + instance_map[key] = deserialise(model, data, pk, using) except: # Error when deserialising - remove from the cache; we will # fallback and return the underlying instance - cache.delete(key) + cache.delete_many(tuple(keys_to_models.keys())) - # Use the default manager so we are never filtered by a .get_query_set() - queryset = model._default_manager.using(using) - if create: - # It's possible that the related object didn't exist yet - instance, _ = queryset.get_or_create(pk=pk, defaults=defaults or {}) - else: - instance = queryset.get(pk=pk) + else: + key = instance_key(primary_model, instance_or_pk) + primary_instance = instance_map[key] - data = {} - for field in instance._meta.fields: - # Harmless to save, but saves space in the dictionary - we already know - # the primary key when we lookup - if field.primary_key: + for descriptor in descriptors: + related_instance = instance_map[ + instance_key( + descriptor.related.field.model, + instance_or_pk, + ) + ] + related_cache_name = get_related_cache_name( + get_related_name(descriptor), + ) + setattr(primary_instance, related_cache_name, related_instance) + + return primary_instance + + related_names = [d.related.field.related_query_name() for d in descriptors] + + # Use the default manager so we are never filtered by a .get_query_set() + queryset = primary_model._default_manager.using(using) + if related_names: + # NB: select_related without args selects all it can find, which we don't want. + queryset = queryset.select_related(*related_names) + primary_instance = queryset.get(pk=pk) + + instances = [ + primary_instance, + *(getattr(primary_instance, x, None) for x in related_names), + ] + + cache_data = {} + for instance in instances: + if instance is None: continue - if field.get_internal_type() == 'FileField': - # Avoid problems with serializing FileFields - # by only serializing the file name - file = getattr(instance, field.attname) - data[field.attname] = file.name - else: - data[field.attname] = getattr(instance, field.attname) + key = instance_key(instance._meta.model, instance) + cache_data[key] = serialise(instance) if timeout is None: timeout = app_settings.CACHE_TOOLBOX_DEFAULT_TIMEOUT - cache.set(key, data, timeout) + cache.set_many(cache_data, timeout) - return instance + return primary_instance def delete_instance(model, *instance_or_pk): @@ -100,7 +177,28 @@ def delete_instance(model, *instance_or_pk): Purges the cache keys for the instances of this model. """ - cache.delete_many([instance_key(model, x) for x in instance_or_pk]) + # Only clear the cache when the current transaction commits. + # While clearing the cache earlier than that is valid, it is insufficient + # to ensure cache consistency. There is a possible race between two + # transactions as follows: + # + # Transaction 1: modifies model (and thus clears cache) + # Transaction 2: queries cache, which misses, so it populates the cache + # from the database, picking up the unmodified model + # Transaction 1: commits, without further signal to the cache + # + # At this point the cache contains the _original_ value of the model, which + # is out of step with the database. + # To avoid this we delay clearing the cache until the transaction commits. + # While this does leave a small window after the transaction has committed + # but before the cache has cleared, that is better than leaving the cache + # incorrect until the model is next updated. + + transaction.on_commit( + lambda: cache.delete_many( + [instance_key(model, x) for x in instance_or_pk], + ) + ) def instance_key(model, instance_or_pk): @@ -108,8 +206,9 @@ def instance_key(model, instance_or_pk): Returns the cache key for this (model, instance) pair. """ - return '%s.%s:%d' % ( + return "cache.%d:%s.%s:%s" % ( + CACHE_FORMAT_VERSION, model._meta.app_label, - model._meta.module_name, - getattr(instance_or_pk, 'pk', instance_or_pk), + model._meta.model_name, + getattr(instance_or_pk, "pk", instance_or_pk), ) diff --git a/cache_toolbox/middleware.py b/cache_toolbox/middleware.py index 97f0bdb..346e28f 100644 --- a/cache_toolbox/middleware.py +++ b/cache_toolbox/middleware.py @@ -79,19 +79,23 @@ """ from django.contrib.auth import SESSION_KEY -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.auth.middleware import AuthenticationMiddleware from .model import cache_model + class CacheBackedAuthenticationMiddleware(AuthenticationMiddleware): - def __init__(self): - cache_model(User) + def __init__(self, get_response): + super(CacheBackedAuthenticationMiddleware, self).__init__(get_response) + cache_model(get_user_model()) def process_request(self, request): try: # Try and construct a User instance from data stored in the cache - request.user = User.get_cached(request.session[SESSION_KEY]) - except: + request.user = get_user_model().get_cached( + int(request.session[SESSION_KEY]) + ) + except Exception: # Fallback to constructing the User from the database. super(CacheBackedAuthenticationMiddleware, self).process_request(request) diff --git a/cache_toolbox/model.py b/cache_toolbox/model.py index 8ac8f0d..e45ae4f 100644 --- a/cache_toolbox/model.py +++ b/cache_toolbox/model.py @@ -58,8 +58,9 @@ class Foo(models.Model): from .core import get_instance, delete_instance + def cache_model(model, timeout=None): - if hasattr(model, 'get_cached'): + if hasattr(model, "get_cached"): # Already patched return diff --git a/cache_toolbox/relation.py b/cache_toolbox/relation.py index 93685e3..4a65d8a 100644 --- a/cache_toolbox/relation.py +++ b/cache_toolbox/relation.py @@ -22,16 +22,9 @@ class Foo(models.Model): name = models.CharField(max_length=20) - cache_relation(User.foo, create=True, defaults={}) + cache_relation(User.foo) (``primary_key`` being ``True`` is currently required.) - -With ``create=True`` we force the creation of an instance of `Foo` in case that -we are trying to access to user.foo_cache but ``user.foo`` doesn't exist yet. - -If ``create=True`` we are going to pass the default to the get_or_create -function. - :: >>> user = User.objects.get(pk=1) @@ -80,50 +73,93 @@ class Foo(models.Model): from django.db.models.signals import post_save, post_delete -from .core import get_instance, delete_instance +from .core import ( + get_instance, + delete_instance, + get_related_name, + get_related_cache_name, + add_always_fetch_relation, +) -def cache_relation(descriptor, timeout=None, create=False, defaults=None): +def cache_relation(descriptor, timeout=None, *, always_fetch=False): rel = descriptor.related - related_name = '%s_cache' % rel.field.related_query_name() + + if not rel.field.primary_key: + # This is an internal limitation due to the way that we construct our + # cache keys. + raise ValueError("Cached relations must be the primary key") + + if always_fetch: + add_always_fetch_relation(descriptor) + + related_name = get_related_name(descriptor) @property def get(self): # Always use the cached "real" instance if available - try: - return getattr(self, descriptor.cache_name) - except AttributeError: - pass + if descriptor.is_cached(self): + return descriptor.__get__(self) # Lookup cached instance + related_cache_name = get_related_cache_name(related_name) try: - return getattr(self, '_%s_cache' % related_name) + instance = getattr(self, related_cache_name) except AttributeError: + # no local cache pass + else: + if instance is None: + # we (locally) cached that there is no model + raise descriptor.RelatedObjectDoesNotExist( + "%s has no %s." + % ( + rel.model.__name__, + related_name, + ), + ) + return instance - instance = get_instance( - rel.model, self.pk, timeout, create=create, defaults=defaults - ) - - setattr(self, '_%s_cache' % related_name, instance) + try: + instance = get_instance( + rel.field.model, + # Note that we're using _our_ primary key here, rather than the + # primary key of the model being cached. This is ok since we + # know that its primary key is a foreign key to this model + # instance and therefore has the same value. + self.pk, + timeout, + using=self._state.db, + ) + setattr(self, related_cache_name, instance) + except rel.related_model.DoesNotExist: + setattr(self, related_cache_name, None) + raise descriptor.RelatedObjectDoesNotExist( + "%s has no %s." + % ( + rel.model.__name__, + related_name, + ), + ) return instance - setattr(rel.parent_model, related_name, get) + + setattr(rel.model, related_name, get) # Clearing cache def clear(self): - delete_instance(rel.model, self) + delete_instance(rel.related_model, self) @classmethod def clear_pk(cls, *instances_or_pk): - delete_instance(rel.model, *instances_or_pk) + delete_instance(rel.related_model, *instances_or_pk) def clear_cache(sender, instance, *args, **kwargs): - delete_instance(rel.model, instance) + delete_instance(rel.related_model, instance) - setattr(rel.parent_model, '%s_clear' % related_name, clear) - setattr(rel.parent_model, '%s_clear_pk' % related_name, clear_pk) + setattr(rel.model, "%s_clear" % related_name, clear) + setattr(rel.model, "%s_clear_pk" % related_name, clear_pk) - post_save.connect(clear_cache, sender=rel.model, weak=False) - post_delete.connect(clear_cache, sender=rel.model, weak=False) + post_save.connect(clear_cache, sender=rel.related_model, weak=False) + post_delete.connect(clear_cache, sender=rel.related_model, weak=False) diff --git a/cache_toolbox/templatetags/cache_toolbox.py b/cache_toolbox/templatetags/cache_toolbox.py index feea2af..437972f 100644 --- a/cache_toolbox/templatetags/cache_toolbox.py +++ b/cache_toolbox/templatetags/cache_toolbox.py @@ -1,18 +1,18 @@ from django import template from django.core.cache import cache from django.template import Node, TemplateSyntaxError, Variable -from django.template import resolve_variable register = template.Library() + class CacheNode(Node): def __init__(self, nodelist, expire_time, key): self.nodelist = nodelist self.expire_time = Variable(expire_time) - self.key = key + self.key = Variable(key) def render(self, context): - key = resolve_variable(self.key, context) + key = self.key.resolve(context) expire_time = int(self.expire_time.resolve(context)) value = cache.get(key) @@ -21,6 +21,7 @@ def render(self, context): cache.set(key, value, expire_time) return value + @register.tag def cachedeterministic(parser, token): """ @@ -35,20 +36,22 @@ def cachedeterministic(parser, token): {% endcachedeterministic %} """ - nodelist = parser.parse(('endcachedeterministic',)) + nodelist = parser.parse(("endcachedeterministic",)) parser.delete_first_token() tokens = token.contents.split() if len(tokens) != 3: raise TemplateSyntaxError(u"'%r' tag requires 2 arguments." % tokens[0]) return CacheNode(nodelist, tokens[1], tokens[2]) + class ShowIfCachedNode(Node): def __init__(self, key): - self.key = key + self.key = Variable(key) def render(self, context): - key = resolve_variable(self.key, context) - return cache.get(key) or '' + key = self.key.resolve(context) + return cache.get(key) or "" + @register.tag def showifcached(parser, token): diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 9b6d61b..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,80 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ - "run these through (pdf)latex." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index f8e18a5..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,16 +0,0 @@ -project = 'django-cache-toolbox' -version = '' -release = '' -copyright = '2010, 2011 UUMC Ltd.' - -html_logo = 'playfire.png' -html_theme = 'nature' -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] -html_title = "%s documentation" % project -master_doc = 'index' -exclude_trees = ['_build'] -templates_path = ['_templates'] -latex_documents = [ - ('index', '%s.tex' % project, html_title, u'Playfire', 'manual'), -] -intersphinx_mapping = {'http://docs.python.org/': None} diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index e7d90b0..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,5 +0,0 @@ -.. automodule:: cache_toolbox -.. automodule:: cache_toolbox.core -.. automodule:: cache_toolbox.model -.. automodule:: cache_toolbox.relation -.. automodule:: cache_toolbox.middleware diff --git a/docs/playfire.png b/docs/playfire.png deleted file mode 100644 index 330e9c5..0000000 Binary files a/docs/playfire.png and /dev/null differ diff --git a/runtests.py b/runtests.py new file mode 100755 index 0000000..7c9cc8a --- /dev/null +++ b/runtests.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys + +import django +from django.conf import settings +from django.test.utils import get_runner + +if __name__ == "__main__": + os.environ["DJANGO_SETTINGS_MODULE"] = "tests.test_settings" + django.setup() + TestRunner = get_runner(settings) + test_runner = TestRunner() + failures = test_runner.run_tests(sys.argv[1:]) + sys.exit(bool(failures)) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2be6836 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = True diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 6be3d7d..479a529 --- a/setup.py +++ b/setup.py @@ -1,16 +1,22 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 +import os.path from setuptools import setup, find_packages +my_dir = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(my_dir, "README.md")) as f: + long_description = f.read() + setup( - name='django-cache-toolbox', + name="django-cache-toolbox", description="Non-magical object caching for Django.", - version='0.1', - url='http://code.playfire.com/django-cache-toolbox', - - author='Playfire.com', - author_email='tech@playfire.com', - license='BSD', - - packages=find_packages(), + long_description=long_description, + long_description_content_type="text/markdown", + version='1.6.1', + url="https://chris-lamb.co.uk/projects/django-cache-toolbox", + author="Chris Lamb", + author_email="chris@chris-lamb.co.uk", + license="BSD", + packages=find_packages(exclude=("tests",)), + install_requires=("Django>=1.9",), ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 0000000..d0722a4 --- /dev/null +++ b/tests/models.py @@ -0,0 +1,41 @@ +from cache_toolbox import cache_model, cache_relation + +from django.db import models + + +class Foo(models.Model): + bar = models.TextField() + + +class Bazz(models.Model): + foo = models.OneToOneField( + Foo, + related_name="bazz", + on_delete=models.CASCADE, + primary_key=True, + ) + + value = models.IntegerField(null=True) + + +cache_model(Foo) +cache_relation(Foo.bazz) + + +class ToLoad(models.Model): + name = models.TextField() + + +class AlwaysRelated(models.Model): + to_load = models.OneToOneField( + ToLoad, + related_name="always_related", + on_delete=models.CASCADE, + primary_key=True, + ) + + value = models.IntegerField(null=True) + + +cache_model(ToLoad) +cache_relation(ToLoad.always_related, always_fetch=True) diff --git a/tests/test_cached_get.py b/tests/test_cached_get.py new file mode 100644 index 0000000..697306b --- /dev/null +++ b/tests/test_cached_get.py @@ -0,0 +1,34 @@ +from django.test import TransactionTestCase + +from .models import Foo + +# Use `TransactionTestCase` so that our `on_commit` actions happen when we expect. +class CachedGetTest(TransactionTestCase): + def setUp(self): + self.foo = Foo.objects.create(bar="bees") + self._populate_cache() + + def _populate_cache(self): + Foo.get_cached(self.foo.pk) + + def test_cached_get(self): + # Get from the cache + cached_object = Foo.get_cached(self.foo.pk) + + self.assertEqual(self.foo.bar, cached_object.bar) + + def test_cache_invalidated_on_update(self): + self.foo.bar = "quux" + self.foo.save() + + self._populate_cache() + + self.assertEqual(Foo.get_cached(self.foo.pk).bar, "quux") + + def test_cache_invalidated_on_delete(self): + pk = self.foo.pk + + self.foo.delete() + + with self.assertRaises(Foo.DoesNotExist): + Foo.get_cached(pk) diff --git a/tests/test_cached_get_related.py b/tests/test_cached_get_related.py new file mode 100644 index 0000000..fb766cc --- /dev/null +++ b/tests/test_cached_get_related.py @@ -0,0 +1,106 @@ +from cache_toolbox.core import delete_instance + +from django.core.cache import cache +from django.test import TransactionTestCase + +from .models import AlwaysRelated, ToLoad + + +# Use `TransactionTestCase` so that our `on_commit` actions happen when we expect. +class CachedGetRelatedTest(TransactionTestCase): + def setUp(self): + self.to_load = ToLoad.objects.create(name="bees") + self.always_related = AlwaysRelated.objects.create(to_load=self.to_load) + self._populate_cache() + + def _populate_cache(self): + ToLoad.get_cached(self.to_load.pk) + + def test_cached_get(self): + # Get from the cache + cached_object = ToLoad.get_cached(self.to_load.pk) + + # Validate that we're using the value we pre-loaded + cache.clear() + + with self.assertNumQueries(0): + self.assertEqual(self.to_load.name, cached_object.name) + self.assertEqual( + self.always_related, + cached_object.always_related_cache, + ) + + def test_cached_get_primary_absent_from_cache(self): + # Remove the instance from the cache, for example as a result of it + # being saved. + delete_instance(ToLoad, self.to_load) + + with self.assertNumQueries(1): + # Get from the cache - this will involve actually loading from the + # database. + cached_object = ToLoad.get_cached(self.to_load.pk) + + # Validate that we're using the value we pre-loaded + cache.clear() + + with self.assertNumQueries(0): + self.assertEqual(self.to_load.name, cached_object.name) + self.assertEqual( + self.always_related, + cached_object.always_related_cache, + ) + + def test_cached_get_relation_absent_from_cache(self): + # Remove the instance from the cache, for example as a result of it + # being saved. + delete_instance(AlwaysRelated, self.always_related) + + # Attempt to load, should fall back to the database due to the lack of + # the related instance in the cache results. + with self.assertNumQueries(1): + cached_object = ToLoad.get_cached(self.to_load.pk) + + self.assertEqual(self.to_load.name, cached_object.name) + + with self.assertNumQueries(0): + self.assertEqual(self.to_load.name, cached_object.name) + self.assertEqual( + self.always_related, + cached_object.always_related_cache, + ) + + def test_cached_get_no_relation(self): + self.always_related.delete() + + # Get from the cache + cached_object = ToLoad.get_cached(self.to_load.pk) + + self.assertEqual(self.to_load.name, cached_object.name) + + with self.assertNumQueries(0): + with self.assertRaises(AlwaysRelated.DoesNotExist): + cached_object.always_related_cache + + # Sanity check + with self.assertRaises(AlwaysRelated.DoesNotExist): + cached_object.always_related + + def test_cached_get_no_relation_no_cache(self): + self.always_related.delete() + cache.clear() + + # Attempt to load, should fall back to the database and should handle + # the lack of the related instance. + with self.assertNumQueries(1): + cached_object = ToLoad.get_cached(self.to_load.pk) + + self.assertEqual(self.to_load.name, cached_object.name) + + with self.assertNumQueries(0): + with self.assertRaises(AlwaysRelated.DoesNotExist): + cached_object.always_related_cache + + # Sanity check + with self.assertNumQueries(0): + with self.assertRaises(AlwaysRelated.DoesNotExist): + cached_object.always_related diff --git a/tests/test_cached_relation.py b/tests/test_cached_relation.py new file mode 100644 index 0000000..1572302 --- /dev/null +++ b/tests/test_cached_relation.py @@ -0,0 +1,143 @@ +from unittest import mock + +from cache_toolbox import cache_relation + +from django.core.cache import cache +from django.db import models +from django.test import TestCase + +from .models import Foo, Bazz + + +class Another(models.Model): + foo = models.OneToOneField( + Foo, + related_name="another", + on_delete=models.CASCADE, + ) + + +class CachedRelationTest(TestCase): + longMessage = True + + def setUp(self): + # Ensure we start with a clear cache for each test, i.e. tests can use + # the cache hygenically + cache.clear() + + def test_requires_primary_key(self): + with self.assertRaises(ValueError): + cache_relation(Foo.another) + + def test_cached_relation(self): + foo = Foo.objects.create(bar="bees") + + Bazz.objects.create(foo=foo, value=10) + + # Populate the cache + Foo.objects.get(pk=foo.pk).bazz_cache + + # Get from the cache + cached_object = Foo.objects.get(pk=foo.pk).bazz_cache + + self.assertEqual(cached_object.value, 10) + + self.assertTrue( + hasattr(foo, "bazz"), + "Foo should have 'bazz' attribute", + ) + + self.assertTrue( + hasattr(foo, "bazz_cache"), + "Foo should have 'bazz_cache' attribute", + ) + + def test_cached_relation_not_present_hasattr(self): + foo = Foo.objects.create(bar="bees_2") + + self.assertFalse( + hasattr(foo, "bazz_cache"), + "Foo should not have 'bazz_cache' attribute (empty cache)", + ) + + self.assertFalse( + hasattr(foo, "bazz_cache"), + "Foo should not have 'bazz_cache' attribute (warm cache; before natural access)", + ) + + # sanity check + self.assertFalse( + hasattr(foo, "bazz"), + "Foo should not have 'bazz' attribute", + ) + + self.assertFalse( + hasattr(foo, "bazz_cache"), + "Foo should not have 'bazz_cache' attribute (warm cache; after natural access)", + ) + + def test_cached_relation_not_present_exception(self): + foo = Foo.objects.create(bar="bees_3") + + with self.assertRaises(Bazz.DoesNotExist) as cm: + foo.bazz_cache + + self.assertIsInstance( + cm.exception, + AttributeError, + "Raised error must also be an AttributeError (we're expecting a 'RelatedObjectDoesNotExist')", + ) + + def test_cached_missing_relation_uses_select_related(self): + foo = Foo.objects.create(bar="bees") + + with self.assertNumQueries(1): + foo = Foo.objects.select_related("bazz").get(pk=foo.pk) + + with self.assertNumQueries(0): + with self.assertRaises(Bazz.DoesNotExist): + foo.bazz_cache + + def test_cached_missing_relation_cached_locally(self): + # Django will cache on the instance that foo.bazz doesn't exist, just + # the same as it would cache the Bazz instance if there was one. Mimic + # that behaviour in order to have comparable querying behaviour. + + foo = Foo.objects.create(bar="bees") + + with self.assertNumQueries(1): + foo = Foo.objects.get(pk=foo.pk) + + # Populate the (instance) cache + with self.assertNumQueries(1): + with self.assertRaises(Bazz.DoesNotExist): + foo.bazz_cache + + # Get from the (instance) cache + with self.assertNumQueries(0): + with self.assertRaises(Bazz.DoesNotExist): + foo.bazz_cache + + with self.assertNumQueries(1): + foo = Foo.objects.get(pk=foo.pk) + + # Prove that we haven't put anything into the remote cache + with self.assertNumQueries(1): + with self.assertRaises(Bazz.DoesNotExist): + foo.bazz_cache + + def test_get_instance_error_doesnt_have_side_effect_issues(self): + foo = Foo.objects.create(bar="bees") + + class DummyException(Exception): + pass + + # Validate that the underlying error is passed through, without any + # other errors happening... + with mock.patch("cache_toolbox.core.cache.get", side_effect=DummyException): + with self.assertRaises(DummyException): + foo.bazz_cache + + # ... and that we haven't put anything bad in the cache along the way + bazz = Bazz.objects.create(foo=foo) + self.assertEqual(bazz, foo.bazz_cache) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..51631c7 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,10 @@ +SECRET_KEY = "fake-key" +INSTALLED_APPS = [ + "tests", +] +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + }, +}