Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Fix evironment metadata N+1 for environments list #2947

Merged
merged 7 commits into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion api/environments/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import json
from typing import Callable
from unittest import TestCase, mock

import pytest
from core.constants import STRING
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from pytest_lazyfixture import lazy_fixture
from rest_framework import status
Expand All @@ -12,9 +14,10 @@
from environments.identities.models import Identity
from environments.identities.traits.models import Trait
from environments.models import Environment, EnvironmentAPIKey, Webhook
from environments.permissions.constants import VIEW_ENVIRONMENT
from environments.permissions.models import UserEnvironmentPermission
from features.models import Feature, FeatureState
from metadata.models import Metadata
from metadata.models import Metadata, MetadataModelField
from organisations.models import Organisation, OrganisationRole
from projects.models import (
Project,
Expand Down Expand Up @@ -536,6 +539,53 @@ def test_create_environment_without_required_metadata_returns_400(
assert "Missing required metadata field" in response.json()["metadata"][0]


def test_view_environment_with_staff_track_query_count(
staff_client: FFAdminUser,
environment: Environment,
with_environment_permissions: Callable,
project: Project,
django_assert_num_queries: Callable,
environment_metadata_a: Metadata,
environment_metadata_b: Metadata,
required_a_environment_metadata_field: MetadataModelField,
environment_content_type: ContentType,
) -> None:
# Given
with_environment_permissions([VIEW_ENVIRONMENT])

url = reverse("api-v1:environments:environment-list")
data = {"project": project.id}

# When
expected_query_count = 7
with django_assert_num_queries(expected_query_count):
response = staff_client.get(url, data=data, content_type="application/json")

assert response.status_code == status.HTTP_200_OK

# Add an environment to make sure the query count is the same.
environment_2 = Environment.objects.create(
name="Second Environment", project=project
)
Metadata.objects.create(
object_id=environment_2.id,
content_type=environment_content_type,
model_field=required_a_environment_metadata_field,
field_value="10",
)

with_environment_permissions([VIEW_ENVIRONMENT], environment_id=environment_2.id)

# One additional query for an unrelated, unfixable N+1 issue.
expected_query_count += 1

# Then
with django_assert_num_queries(expected_query_count):
response = staff_client.get(url, data=data, content_type="application/json")

assert response.status_code == status.HTTP_200_OK


@pytest.mark.parametrize(
"client",
[lazy_fixture("admin_master_api_key_client"), lazy_fixture("admin_client")],
Expand Down
2 changes: 1 addition & 1 deletion api/environments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def get_queryset(self):
raise ValidationError("Invalid or missing value for project parameter.")

return self.request.user.get_permitted_environments(
"VIEW_ENVIRONMENT", project=project
"VIEW_ENVIRONMENT", project=project, prefetch_metadata=True
)

# Permission class handles validation of permissions for other actions
Expand Down
12 changes: 11 additions & 1 deletion api/permissions/permission_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def get_permitted_environments_for_user(
project: Project,
permission_key: str,
tag_ids: List[int] = None,
prefetch_metadata: bool = False,
) -> QuerySet[Environment]:
"""
Get all environments that the user has the given permissions for.
Expand All @@ -130,7 +131,16 @@ def get_permitted_environments_for_user(
)
filter_ = base_filter & Q(project=project)

return Environment.objects.filter(filter_).distinct().defer("description")
queryset = Environment.objects.filter(filter_)
if prefetch_metadata:
queryset = queryset.prefetch_related("metadata")

# Description is defered due to Oracle support where a
# query can't have a where clause if description is in
# the select parameters. This leads to an N+1 query for
# lists of environments when description is included, as
# each environment object re-queries the DB seperately.
return queryset.distinct().defer("description")


def get_permitted_environments_for_master_api_key(
Expand Down
8 changes: 6 additions & 2 deletions api/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,10 +272,14 @@ def is_project_admin(self, project: Project) -> bool:
return is_user_project_admin(self, project)

def get_permitted_environments(
self, permission_key: str, project: Project, tag_ids: typing.List[int] = None
self,
permission_key: str,
project: Project,
tag_ids: typing.List[int] = None,
prefetch_metadata: bool = False,
) -> QuerySet[Environment]:
return get_permitted_environments_for_user(
self, project, permission_key, tag_ids
self, project, permission_key, tag_ids, prefetch_metadata=prefetch_metadata
)

@staticmethod
Expand Down