Skip to content

Commit 33f4cca

Browse files
committed
⚡️(backend) reduce queries made in item access list viewset
The ItemAvvessViewset list endpoint is really complicated and half of the algo is made to cache the role the current user have on the tree. This code is now removed and replaced by a queryset annotation allowing to fetch the user_roles and then determine in the model the max role the user have on the tree. It reduces the number of sql query by 1 and ease the code comprehension
1 parent 55c1f45 commit 33f4cca

File tree

3 files changed

+73
-112
lines changed

3 files changed

+73
-112
lines changed

src/backend/core/api/viewsets.py

Lines changed: 13 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,21 +1186,21 @@ def list(self, request, *args, **kwargs):
11861186
ancestors_qs = models.Item.objects.filter(
11871187
path__ancestors=self.item.path, ancestors_deleted_at__isnull=True
11881188
)
1189-
ancestor_items = list(ancestors_qs.order_by("path"))
1190-
1191-
accesses_queryset = self.get_queryset().filter(item__in=ancestors_qs)
1189+
accesses_qs = self.get_queryset().filter(item__in=ancestors_qs)
11921190
if role not in PRIVILEGED_ROLES:
1193-
accesses_queryset = accesses_queryset.filter(role__in=PRIVILEGED_ROLES)
1191+
accesses_qs = accesses_qs.filter(role__in=PRIVILEGED_ROLES)
11941192

1195-
accesses = list(accesses_queryset.order_by("item__path", "created_at"))
1193+
accesses_qs = accesses_qs.annotate_user_roles(user)
1194+
accesses = list(accesses_qs.order_by("item__path", "created_at").iterator())
11961195

11971196
request_target_keys = {f"user:{user.id}"}
11981197
request_target_keys.update(
11991198
f"team:{team}" for team in getattr(user, "teams", [])
12001199
)
12011200

12021201
max_role_by_target = {}
1203-
user_roles_by_path = {}
1202+
# Keep in this dict the deepest access for each target
1203+
# This is the access that will be returned to the user
12041204
accesses_by_target = defaultdict(list)
12051205
for access in accesses:
12061206
previous = max_role_by_target.get(access.target_key)
@@ -1215,49 +1215,19 @@ def list(self, request, *args, **kwargs):
12151215
"item_id": access.item_id,
12161216
}
12171217

1218-
accesses_by_target[access.target_key].append(access)
1219-
1220-
if access.target_key in request_target_keys:
1221-
path = str(access.item.path)
1222-
user_roles_by_path[path] = models.RoleChoices.max(
1223-
user_roles_by_path.get(path), access.role
1224-
)
1225-
1226-
user_ancestor_role_by_path = {}
1227-
user_max_role_by_path = {}
1228-
for ancestor in ancestor_items:
1229-
path = str(ancestor.path)
1230-
parent_path = str(ancestor.path[:-1]) if ancestor.depth > 1 else ""
1231-
ancestor_role = user_max_role_by_path.get(parent_path)
1232-
user_ancestor_role_by_path[path] = ancestor_role
1233-
user_max_role_by_path[path] = models.RoleChoices.max(
1234-
ancestor_role, user_roles_by_path.get(path)
1235-
)
1218+
accesses_by_target[access.target_key] = access
12361219

1237-
selected_access_ids = {
1238-
max(
1239-
target_accesses,
1240-
key=lambda item_access: (
1241-
item_access.item.depth if item_access.item_id else 0,
1242-
item_access.created_at,
1243-
),
1244-
).pk
1245-
for target_accesses in accesses_by_target.values()
1246-
if target_accesses
1247-
}
1220+
# Reorder accesses by their corresponding item depth and creation date
1221+
selected_accesses = sorted(
1222+
accesses_by_target.values(),
1223+
key=lambda access: (access.item.depth, access.created_at),
1224+
)
12481225

12491226
# serialize and return the response
12501227
context = self.get_serializer_context()
12511228
serializer_class = self.get_serializer_class()
12521229
serialized_data = []
1253-
for access in accesses:
1254-
if access.pk not in selected_access_ids:
1255-
continue
1256-
path = str(access.item.path)
1257-
access.set_user_roles_tuple(
1258-
user_ancestor_role_by_path.get(path),
1259-
user_roles_by_path.get(path),
1260-
)
1230+
for access in selected_accesses:
12611231
serializer = serializer_class(access, context=context)
12621232
serialized_data.append(serializer.data)
12631233

src/backend/core/models.py

Lines changed: 52 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,37 @@ def __str__(self):
11031103
return f"{self.user!s} favorite on item {self.item!s}"
11041104

11051105

1106+
class ItemAccessQuerySet(models.QuerySet):
1107+
"""Custom queryset for ItemAccess model with additional methods."""
1108+
1109+
def annotate_user_roles(self, user):
1110+
"""
1111+
Annotate ItemAccess queryset with the roles of the current user
1112+
on the item or its ancestors.
1113+
"""
1114+
output_field = ArrayField(base_field=models.CharField())
1115+
1116+
if user.is_authenticated:
1117+
user_roles_subquery = ItemAccess.objects.filter(
1118+
models.Q(user=user) | models.Q(team__in=user.teams),
1119+
item__path__ancestors=models.OuterRef("item__path"),
1120+
).values_list("role", flat=True)
1121+
1122+
return self.annotate(
1123+
user_roles=models.Func(
1124+
user_roles_subquery, function="ARRAY", output_field=output_field
1125+
)
1126+
)
1127+
1128+
return self.annotate(
1129+
user_roles=models.Value([], output_field=output_field),
1130+
)
1131+
1132+
1133+
class ItemAccessManager(models.Manager.from_queryset(ItemAccessQuerySet)):
1134+
"""Manager for ItemAccess model."""
1135+
1136+
11061137
class ItemAccess(BaseModel):
11071138
"""Relation model to give access to an item for a user or a team with a role."""
11081139

@@ -1122,6 +1153,8 @@ class ItemAccess(BaseModel):
11221153
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
11231154
)
11241155

1156+
objects = ItemAccessManager()
1157+
11251158
class Meta:
11261159
db_table = "drive_item_access"
11271160
ordering = ("-created_at",)
@@ -1166,65 +1199,6 @@ def target_key(self):
11661199
"""Get a unique key for the actor targeted by the access, without possible conflict."""
11671200
return f"user:{self.user_id!s}" if self.user_id else f"team:{self.team:s}"
11681201

1169-
def set_user_roles_tuple(self, ancestors_role, current_role):
1170-
"""
1171-
Set a precomputed (ancestor_role, current_role) tuple for this instance.
1172-
1173-
This avoids querying the database in `get_roles_tuple()` and is useful
1174-
when roles are already known, such as in bulk serialization.
1175-
1176-
Args:
1177-
ancestor_role (str | None): Highest role on any ancestor document.
1178-
current_role (str | None): Role on the current document.
1179-
"""
1180-
# pylint: disable=attribute-defined-outside-init
1181-
self._prefetched_user_roles_tuple = (ancestors_role, current_role)
1182-
1183-
def get_user_roles_tuple(self, user):
1184-
"""
1185-
Return a tuple of:
1186-
- the highest role the user has on any ancestor of the item
1187-
- the role the user has on the current item
1188-
1189-
If roles have been explicitly set using `set_user_roles_tuple()`,
1190-
those will be returned instead of querying the database.
1191-
1192-
This allows viewsets or serializers to precompute roles for performance
1193-
when handling multiple items at once.
1194-
1195-
Args:
1196-
user (User): The user whose roles are being evaluated.
1197-
1198-
Returns:
1199-
tuple[str | None, str | None]: (max_ancestor_role, current_document_role)
1200-
"""
1201-
if not user.is_authenticated:
1202-
return None, None
1203-
1204-
try:
1205-
return self._prefetched_user_roles_tuple
1206-
except AttributeError:
1207-
pass
1208-
1209-
ancestors = (
1210-
self.item.ancestors() | Item.objects.filter(pk=self.item_id)
1211-
).filter(ancestors_deleted_at__isnull=True)
1212-
1213-
access_tuples = ItemAccess.objects.filter(
1214-
models.Q(user=user) | models.Q(team__in=user.teams),
1215-
item__in=ancestors,
1216-
).values_list("item_id", "role")
1217-
1218-
ancestors_roles = []
1219-
current_role = None
1220-
for item_id, role in access_tuples:
1221-
if item_id == self.item_id:
1222-
current_role = role
1223-
else:
1224-
ancestors_roles.append(role)
1225-
1226-
return RoleChoices.max(*ancestors_roles), current_role
1227-
12281202
def _compute_max_ancestors_role(self):
12291203
"""
12301204
Compute the max ancestors role for this instance.
@@ -1281,16 +1255,30 @@ def max_ancestors_role_item_id(self, max_ancestors_role_item_id):
12811255
"""Cache the max_ancestors_role_item_id."""
12821256
self._max_ancestors_role_item_id = max_ancestors_role_item_id
12831257

1258+
def get_role(self, user):
1259+
"""Return the role a user has on an item related to this access.."""
1260+
if not user.is_authenticated:
1261+
return None
1262+
1263+
try:
1264+
roles = self.user_roles or []
1265+
except AttributeError:
1266+
roles = ItemAccess.objects.filter(
1267+
models.Q(user=user) | models.Q(team__in=user.teams),
1268+
item__path__ancestors=self.item.path,
1269+
).values_list("role", flat=True)
1270+
1271+
return RoleChoices.max(*roles)
1272+
12841273
def get_abilities(self, user, is_explicit=True):
12851274
"""
12861275
Compute and return abilities for a given user on the item access.
12871276
"""
1288-
ancestors_role, current_role = self.get_user_roles_tuple(user)
1289-
role = RoleChoices.max(ancestors_role, current_role)
1290-
is_owner_or_admin = role in PRIVILEGED_ROLES
1277+
user_role = self.get_role(user)
1278+
is_owner_or_admin = user_role in PRIVILEGED_ROLES
12911279

12921280
if self.role == RoleChoices.OWNER:
1293-
can_delete = role == RoleChoices.OWNER and (
1281+
can_delete = user_role == RoleChoices.OWNER and (
12941282
# check if item is not root trying to avoid an extra query
12951283
self.item.depth > 1
12961284
or ItemAccess.objects.filter(
@@ -1306,7 +1294,7 @@ def get_abilities(self, user, is_explicit=True):
13061294
set_role_to.extend(
13071295
[RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN]
13081296
)
1309-
if role == RoleChoices.OWNER:
1297+
if user_role == RoleChoices.OWNER:
13101298
set_role_to.append(RoleChoices.OWNER)
13111299

13121300
# Filter out roles that would be lower than the one the user already has

src/backend/core/tests/items/test_api_item_accesses.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ def test_api_item_accesses_list_authenticated_related_non_privileged(
145145
other_access = factories.UserItemAccessFactory(user=user)
146146
factories.UserItemAccessFactory(item=other_access.item)
147147

148-
with django_assert_num_queries(4):
148+
with django_assert_num_queries(3):
149149
response = client.get(f"/api/v1.0/items/{item.id!s}/accesses/")
150150

151151
assert response.status_code == 200
@@ -253,7 +253,7 @@ def test_api_item_accesses_list_authenticated_related_privileged(
253253
other_access = factories.UserItemAccessFactory(user=user)
254254
factories.UserItemAccessFactory(item=other_access.item)
255255

256-
with django_assert_num_queries(4):
256+
with django_assert_num_queries(3):
257257
response = client.get(f"/api/v1.0/items/{item.id!s}/accesses/")
258258

259259
assert response.status_code == 200
@@ -1492,7 +1492,7 @@ def test_api_item_accesses_delete_owners_last_owner_child_team(
14921492
## Realistic case.
14931493

14941494

1495-
def test_api_item_accesses_explicit():
1495+
def test_api_item_accesses_explicit(django_assert_num_queries):
14961496
"""
14971497
test case with a combination of explicit accesses and inherited accesses.
14981498
An explicit access id added on the root item with a "weak" role (editor)
@@ -1517,7 +1517,8 @@ def test_api_item_accesses_explicit():
15171517

15181518
assert item.get_role(user) == "owner"
15191519

1520-
response = client.get(f"/api/v1.0/items/{item.id!s}/accesses/")
1520+
with django_assert_num_queries(3):
1521+
response = client.get(f"/api/v1.0/items/{item.id!s}/accesses/")
15211522
assert response.status_code == 200
15221523
content = response.json()
15231524
assert content == [
@@ -1616,7 +1617,9 @@ def test_api_item_accesses_explicit():
16161617
factories.UserItemAccessFactory(item=parent, user=user, role="administrator")
16171618

16181619
response = client.get(f"/api/v1.0/items/{item.id!s}/accesses/")
1619-
assert response.status_code == 200
1620+
with django_assert_num_queries(3):
1621+
response = client.get(f"/api/v1.0/items/{item.id!s}/accesses/")
1622+
16201623
content = response.json()
16211624
assert content == [
16221625
{

0 commit comments

Comments
 (0)