Skip to content

Commit

Permalink
Auto prefetching for vanilla usecases (#632)
Browse files Browse the repository at this point in the history
* Simple auto prefetching for vanilla usecases

* Added auto prefetch docs

* Add cache eviction

* Add auto prefetch tests

* Ensure only active for User objects

* Add Group prefetch support and improve tests
  • Loading branch information
qeternity authored and ad-m committed Sep 4, 2019
1 parent fbbdf4e commit 2246cd8
Show file tree
Hide file tree
Showing 5 changed files with 210 additions and 1 deletion.
14 changes: 14 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,17 @@ polymorphic models and the regular model ``ContentType`` for non-polymorphic
classes.

Defaults to ``"guardian.ctypes.get_default_content_type"``.

GUARDIAN_AUTO_PREFETCH
-------------------------

.. versionadded:: 2.x.x

For vanilla deployments using standard ``ContentType`` interfaces and default
``UserObjectPermission`` or ``GroupObjectPermission`` models, Guardian can automatically
prefetch all User permissions for all object types. This can be useful when manual prefetching
is not feasible due to a large number of model types resulting in O(n) queries. This setting may
not be compatible with non-standard deployments, and should only be used when non-prefetched
invocations would result in a large number of queries or when latency is particularly important.

Defaults to ``False``.
1 change: 1 addition & 0 deletions guardian/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ def monkey_patch_user():
lambda self, perm, obj: UserObjectPermission.objects.assign_perm(perm, self, obj))
setattr(User, 'del_obj_perm',
lambda self, perm, obj: UserObjectPermission.objects.remove_perm(perm, self, obj))
setattr(User, 'evict_obj_perm_cache', lambda self: delattr(self, '_guardian_perms_cache'))
2 changes: 2 additions & 0 deletions guardian/conf/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

GET_CONTENT_TYPE = getattr(settings, 'GUARDIAN_GET_CONTENT_TYPE', 'guardian.ctypes.get_default_content_type')

AUTO_PREFETCH = getattr(settings, 'GUARDIAN_AUTO_PREFETCH', False)


def check_configuration():
if RENDER_403 and RAISE_403:
Expand Down
40 changes: 40 additions & 0 deletions guardian/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from django.contrib.auth.models import Permission
from django.db.models.query import QuerySet
from django.utils.encoding import force_text

from guardian.conf import settings as guardian_settings
from guardian.ctypes import get_content_type
from guardian.utils import get_group_obj_perms_model, get_identity, get_user_obj_perms_model

Expand Down Expand Up @@ -144,9 +146,16 @@ def get_perms(self, obj):
"""
if self.user and not self.user.is_active:
return []

if guardian_settings.AUTO_PREFETCH:
self._prefetch_cache()

ctype = get_content_type(obj)
key = self.get_local_cache_key(obj)
if key not in self._obj_perms_cache:
# If auto-prefetching enabled, do not hit database
if guardian_settings.AUTO_PREFETCH:
return []
if self.user and self.user.is_superuser:
perms = list(chain(*Permission.objects
.filter(content_type=ctype)
Expand Down Expand Up @@ -255,3 +264,34 @@ def prefetch_perms(self, objects):
self._obj_perms_cache[key].append(perm.permission.codename)

return True

@staticmethod
def _init_obj_prefetch_cache(obj, *querysets):
cache = {}
for qs in querysets:
perms = qs.select_related('permission__codename').values_list('content_type_id', 'object_pk',
'permission__codename')
for p in perms:
if p[:2] not in cache:
cache[p[:2]] = []
cache[p[:2]] += [p[2], ]
obj._guardian_perms_cache = cache
return obj, cache

def _prefetch_cache(self):
from guardian.models import UserObjectPermission, GroupObjectPermission
if self.user:
obj = self.user
querysets = [
UserObjectPermission.objects.filter(user=obj),
GroupObjectPermission.objects.filter(group__user=obj)
]
else:
obj = self.group
querysets = [
GroupObjectPermission.objects.filter(group=obj),
]

if not hasattr(obj, '_guardian_perms_cache'):
obj, cache = self._init_obj_prefetch_cache(obj, *querysets)
self._obj_perms_cache = cache
154 changes: 153 additions & 1 deletion guardian/testapp/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from guardian.shortcuts import assign_perm
from guardian.management import create_anonymous_user

from guardian.testapp.models import Project
from guardian.testapp.models import Project, ProjectUserObjectPermission, ProjectGroupObjectPermission

auth_app = django_apps.get_app_config('auth')
User = get_user_model()
Expand Down Expand Up @@ -441,3 +441,155 @@ def test_prefetch_group_perms_direct_rel(self):
self.assertEqual(len(connection.queries), query_count)
finally:
settings.DEBUG = False

def test_autoprefetch_user_perms(self):
settings.DEBUG = True
guardian_settings.AUTO_PREFETCH = True
ProjectUserObjectPermission.enabled = False
ProjectGroupObjectPermission.enabled = False
try:
from django.db import connection

ContentType.objects.clear_cache()
group1 = Group.objects.create(name='group1')
group2 = Group.objects.create(name='group2')
user = User.objects.create(username='active_user', is_active=True)
assign_groups = [self.group, group1]
for group in assign_groups:
assign_perm("change_group", user, group)
checker = ObjectPermissionChecker(user)

pre_query_count = len(connection.queries)
# Fill cache
checker._prefetch_cache()
query_count = len(connection.queries)

# Ensure two queries have been made
self.assertEqual(query_count - pre_query_count, 2)

# Check user has cache attribute
self.assertTrue(hasattr(user, '_guardian_perms_cache'))

# Checking cache is filled
self.assertEqual(
len(checker._obj_perms_cache),
len(assign_groups)
)

# Checking shouldn't spawn any queries
self.assertTrue(checker.has_perm("change_group", self.group))
self.assertEqual(len(connection.queries), query_count)

# Checking for other permission but for Group object again
# shouldn't spawn any query too
self.assertFalse(checker.has_perm("delete_group", self.group))
self.assertEqual(len(connection.queries), query_count)

# Checking for same model but other instance shouldn't spawn any queries
self.assertTrue(checker.has_perm("change_group", group1))
self.assertEqual(len(connection.queries), query_count)

# Checking for same model but other instance shouldn't spawn any queries
# Even though User doesn't have perms on Group2, we still should
# not hit DB
self.assertFalse(checker.has_perm("change_group", group2))
self.assertEqual(len(connection.queries), query_count)
finally:
settings.DEBUG = False
guardian_settings.AUTO_PREFETCH = False
ProjectUserObjectPermission.enabled = True
ProjectGroupObjectPermission.enabled = True

def test_autoprefetch_superuser_perms(self):
settings.DEBUG = True
guardian_settings.AUTO_PREFETCH = True
ProjectUserObjectPermission.enabled = False
ProjectGroupObjectPermission.enabled = False
try:
from django.db import connection

ContentType.objects.clear_cache()
group1 = Group.objects.create(name='group1')
user = User.objects.create(username='active_superuser',
is_superuser=True, is_active=True)
assign_perm("change_group", user, self.group)
checker = ObjectPermissionChecker(user)

# Fill cache
checker._prefetch_cache()
query_count = len(connection.queries)

# Check user has cache attribute
self.assertTrue(hasattr(user, '_guardian_perms_cache'))

# Checking shouldn't spawn any queries
self.assertTrue(checker.has_perm("change_group", self.group))
self.assertEqual(len(connection.queries), query_count)

# Checking for other permission but for Group object again
# shouldn't spawn any query too
self.assertTrue(checker.has_perm("delete_group", self.group))
self.assertEqual(len(connection.queries), query_count)

# Checking for same model but other instance shouldn't spawn any queries
self.assertTrue(checker.has_perm("change_group", group1))
self.assertEqual(len(connection.queries), query_count)
finally:
settings.DEBUG = False
guardian_settings.AUTO_PREFETCH = False
ProjectUserObjectPermission.enabled = True
ProjectGroupObjectPermission.enabled = True

def test_autoprefetch_group_perms(self):
settings.DEBUG = True
guardian_settings.AUTO_PREFETCH = True
ProjectUserObjectPermission.enabled = False
ProjectGroupObjectPermission.enabled = False
try:
from django.db import connection

ContentType.objects.clear_cache()
group = Group.objects.create(name='new-group')
projects = \
[Project.objects.create(name='Project%s' % i)
for i in range(3)]
assign_perm("change_project", group, projects[0])
assign_perm("change_project", group, projects[1])

checker = ObjectPermissionChecker(group)

pre_query_count = len(connection.queries)
# Fill cache
checker._prefetch_cache()
query_count = len(connection.queries)

# Ensure only one query has been made
self.assertEqual(query_count - pre_query_count, 1)

# Check group has cache attribute
self.assertTrue(hasattr(group, '_guardian_perms_cache'))

# Checking shouldn't spawn any queries
self.assertTrue(checker.has_perm("change_project", projects[0]))
self.assertEqual(len(connection.queries), query_count)

# Checking for other permission but for Group object again
# shouldn't spawn any query too
self.assertFalse(checker.has_perm("delete_project", projects[0]))
self.assertEqual(len(connection.queries), query_count)

# Checking for same model but other instance shouldn't spawn any
# queries
self.assertTrue(checker.has_perm("change_project", projects[1]))
self.assertEqual(len(connection.queries), query_count)

# Checking for same model but other instance shouldn't spawn any queries
# Even though User doesn't have perms on projects[2], we still
# should not hit DB
self.assertFalse(checker.has_perm("change_project", projects[2]))
self.assertEqual(len(connection.queries), query_count)
finally:
settings.DEBUG = False
guardian_settings.AUTO_PREFETCH = False
ProjectUserObjectPermission.enabled = True
ProjectGroupObjectPermission.enabled = True

0 comments on commit 2246cd8

Please sign in to comment.