Repository: saleor/saleor

Issue:
App resolvers should use dataloaders
### What are you trying to achieve?

There are two unoptimized resolvers, which should use data loaders:

```
@staticmethod
def resolve_tokens(root: models.App, info):
    has_required_permission(root, info.context)
    return root.tokens.all()

@staticmethod
def resolve_webhooks(root: models.App, info):
    has_required_permission(root, info.context)
    return root.webhooks.all()
```

### Steps to reproduce the problem

1. Prepare a dataset with an App that has multiple tokens and webhooks attatched.
2. Query the API about apps, ask for `tokens` and `webhooks`.
3. See how much SQL queries are performed - each relation in resolvers will generate a separate SQL query.

### What did you expect to happen?

Each resolver performs single SQL query instead of performing relation lookup with extra SQL on each n+1 `App` object.

### Logs

_No response_

### Environment

Saleor version: detected on 3.9
OS and version: N/A



Discussion:

Resolving patch:
diff --git a/saleor/graphql/app/dataloaders.py b/saleor/graphql/app/dataloaders.py
--- a/saleor/graphql/app/dataloaders.py
+++ b/saleor/graphql/app/dataloaders.py
@@ -44,6 +44,19 @@ def batch_load(self, keys):
         return [extensions_map.get(app_id, []) for app_id in keys]
 
 
+class AppTokensByAppIdLoader(DataLoader):
+    context_key = "app_tokens_by_app_id"
+
+    def batch_load(self, keys):
+        tokens = AppToken.objects.using(self.database_connection_name).filter(
+            app_id__in=keys
+        )
+        tokens_by_app_map = defaultdict(list)
+        for token in tokens:
+            tokens_by_app_map[token.app_id].append(token)
+        return [tokens_by_app_map.get(app_id, []) for app_id in keys]
+
+
 class AppByTokenLoader(DataLoader):
     context_key = "app_by_token"
 
diff --git a/saleor/graphql/app/types.py b/saleor/graphql/app/types.py
--- a/saleor/graphql/app/types.py
+++ b/saleor/graphql/app/types.py
@@ -25,9 +25,15 @@
 from ..core.utils import from_global_id_or_error
 from ..meta.types import ObjectWithMetadata
 from ..utils import format_permissions_for_display, get_user_or_app_from_context
+from ..webhook.dataloaders import WebhooksByAppIdLoader
 from ..webhook.enums import WebhookEventTypeAsyncEnum, WebhookEventTypeSyncEnum
 from ..webhook.types import Webhook
-from .dataloaders import AppByIdLoader, AppExtensionByAppIdLoader, app_promise_callback
+from .dataloaders import (
+    AppByIdLoader,
+    AppExtensionByAppIdLoader,
+    AppTokensByAppIdLoader,
+    app_promise_callback,
+)
 from .enums import AppExtensionMountEnum, AppExtensionTargetEnum, AppTypeEnum
 from .resolvers import (
     resolve_access_token_for_app,
@@ -334,12 +340,12 @@ def resolve_permissions(root: models.App, _info):
     @staticmethod
     def resolve_tokens(root: models.App, info):
         has_required_permission(root, info.context)
-        return root.tokens.all()
+        return AppTokensByAppIdLoader(info.context).load(root.id)
 
     @staticmethod
     def resolve_webhooks(root: models.App, info):
         has_required_permission(root, info.context)
-        return root.webhooks.all()
+        return WebhooksByAppIdLoader(info.context).load(root.id)
 
     @staticmethod
     def resolve_access_token(root: models.App, info):
diff --git a/saleor/graphql/webhook/dataloaders.py b/saleor/graphql/webhook/dataloaders.py
--- a/saleor/graphql/webhook/dataloaders.py
+++ b/saleor/graphql/webhook/dataloaders.py
@@ -1,7 +1,7 @@
 from collections import defaultdict
 
 from ...core.models import EventPayload
-from ...webhook.models import WebhookEvent
+from ...webhook.models import Webhook, WebhookEvent
 from ..core.dataloaders import DataLoader
 
 
@@ -28,3 +28,16 @@ def batch_load(self, keys):
             webhook_events_map[event.webhook_id].append(event)
 
         return [webhook_events_map.get(webhook_id, []) for webhook_id in keys]
+
+
+class WebhooksByAppIdLoader(DataLoader):
+    context_key = "webhooks_by_app_id"
+
+    def batch_load(self, keys):
+        webhooks = Webhook.objects.using(self.database_connection_name).filter(
+            app_id__in=keys
+        )
+        webhooks_by_app_map = defaultdict(list)
+        for webhook in webhooks:
+            webhooks_by_app_map[webhook.app_id].append(webhook)
+        return [webhooks_by_app_map.get(app_id, []) for app_id in keys]


Test patch:
diff --git a/saleor/graphql/app/tests/benchmarks/test_apps.py b/saleor/graphql/app/tests/benchmarks/test_apps.py
--- a/saleor/graphql/app/tests/benchmarks/test_apps.py
+++ b/saleor/graphql/app/tests/benchmarks/test_apps.py
@@ -1,7 +1,8 @@
 import graphene
 import pytest
 
-from .....app.models import App
+from .....app.models import App, AppToken
+from .....webhook.models import Webhook
 from ....tests.utils import get_graphql_content
 
 
@@ -71,3 +72,68 @@ def test_apps_for_federation_query_count(
         )
         content = get_graphql_content(response)
         assert len(content["data"]["_entities"]) == 3
+
+
+@pytest.mark.django_db
+@pytest.mark.count_queries(autouse=False)
+def test_apps_with_tokens_and_webhooks(
+    staff_api_client,
+    permission_manage_apps,
+    django_assert_num_queries,
+    count_queries,
+):
+    apps = App.objects.bulk_create(
+        [
+            App(name="app 1"),
+            App(name="app 2"),
+            App(name="app 3"),
+        ]
+    )
+
+    webhooks = []
+    app_tokens = []
+    for app in apps:
+        for i in range(3):
+            webhooks.append(
+                Webhook(
+                    app=app,
+                    target_url=f"http://www.example.com/{i}",
+                    name=f"webhook{i}",
+                )
+            )
+            app_tokens.append(
+                AppToken(
+                    app=app, name=f"token{i}", auth_token=f"auth_token-{app.name}-{i}"
+                )
+            )
+    Webhook.objects.bulk_create(webhooks)
+    AppToken.objects.bulk_create(app_tokens)
+
+    query = """
+        query apps {
+            apps(first:100) {
+                edges {
+                    node {
+                        id
+                        tokens {
+                            name
+                            authToken
+                        }
+                        webhooks {
+                            id
+                            targetUrl
+                        }
+                    }
+                }
+            }
+        }
+    """
+
+    response = staff_api_client.post_graphql(
+        query,
+        {},
+        permissions=[permission_manage_apps],
+        check_no_permissions=False,
+    )
+    content = get_graphql_content(response)
+    assert len(content["data"]["apps"]["edges"]) == 3