Skip to content

Commit 121f1ce

Browse files
fix N+1 query in "installation_repositories" event handlers (#97)
1 parent 01e2bcf commit 121f1ce

File tree

7 files changed

+161
-30
lines changed

7 files changed

+161
-30
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
2222

2323
- Added `@gh.mention` decorator for handling GitHub mentions in comments. Supports filtering by username pattern (exact match or regex) and scope (issues, PRs, or commits).
2424

25+
### Fixed
26+
27+
- Fixed N+1 query pattern in `installation_repositories` event handlers by fetching all existing repository IDs in a single query instead of checking each repository individually.
28+
2529
## [0.7.0]
2630

2731
### Added

src/django_github_app/events/ainstallation.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,4 @@ async def async_installation_data(event: sansio.Event, gh: GitHubAPI, *args, **k
4343
async def async_installation_repositories(
4444
event: sansio.Event, gh: GitHubAPI, *args, **kwargs
4545
):
46-
removed = [repo["id"] for repo in event.data["repositories_removed"]]
47-
added = [
48-
Repository(
49-
installation=await Installation.objects.aget_from_event(event),
50-
repository_id=repo["id"],
51-
repository_node_id=repo["node_id"],
52-
full_name=repo["full_name"],
53-
)
54-
for repo in event.data["repositories_added"]
55-
if not await Repository.objects.filter(repository_id=repo["id"]).aexists()
56-
]
57-
58-
await Repository.objects.filter(repository_id__in=removed).adelete()
59-
await Repository.objects.abulk_create(added)
46+
await Repository.objects.async_repositories_from_event(event)

src/django_github_app/events/installation.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,4 @@ def sync_installation_data(event: sansio.Event, gh: GitHubAPI, *args, **kwargs):
3939

4040
@gh.event("installation_repositories")
4141
def sync_installation_repositories(event: sansio.Event, gh: GitHubAPI, *args, **kwargs):
42-
removed = [repo["id"] for repo in event.data["repositories_removed"]]
43-
added = [
44-
Repository(
45-
installation=Installation.objects.get_from_event(event),
46-
repository_id=repo["id"],
47-
repository_node_id=repo["node_id"],
48-
full_name=repo["full_name"],
49-
)
50-
for repo in event.data["repositories_added"]
51-
if not Repository.objects.filter(repository_id=repo["id"]).exists()
52-
]
53-
54-
Repository.objects.filter(repository_id__in=removed).delete()
55-
Repository.objects.bulk_create(added)
42+
Repository.objects.sync_repositories_from_event(event)

src/django_github_app/models.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from enum import Enum
55
from typing import Any
66

7+
from asgiref.sync import sync_to_async
78
from django.db import models
9+
from django.db import transaction
810
from django.utils import timezone
911
from gidgethub import abc
1012
from gidgethub import sansio
@@ -211,6 +213,45 @@ async def aget_from_event(self, event: sansio.Event):
211213
except Repository.DoesNotExist:
212214
return None
213215

216+
def sync_repositories_from_event(self, event: sansio.Event):
217+
if event.event != "installation_repositories":
218+
raise ValueError(
219+
f"Expected 'installation_repositories' event, got '{event.event}'"
220+
)
221+
222+
installation = Installation.objects.get_from_event(event)
223+
224+
repositories_added = event.data["repositories_added"]
225+
repositories_removed = event.data["repositories_removed"]
226+
227+
existing_repo_ids = set(
228+
self.filter(
229+
repository_id__in=[repo["id"] for repo in repositories_added]
230+
).values_list("repository_id", flat=True)
231+
)
232+
added = [
233+
Repository(
234+
installation=installation,
235+
repository_id=repo["id"],
236+
repository_node_id=repo["node_id"],
237+
full_name=repo["full_name"],
238+
)
239+
for repo in repositories_added
240+
if repo["id"] not in existing_repo_ids
241+
]
242+
243+
removed = [repo["id"] for repo in repositories_removed]
244+
245+
with transaction.atomic():
246+
self.bulk_create(added)
247+
self.filter(repository_id__in=removed).delete()
248+
249+
async def async_repositories_from_event(self, event: sansio.Event):
250+
# Django's `transaction` is not async compatible yet, so we have the sync
251+
# version called using `sync_to_async` -- an inversion from the rest of the library
252+
# only defining async methods
253+
await sync_to_async(self.sync_repositories_from_event)(event)
254+
214255
create_from_gh_data = async_to_sync_method(acreate_from_gh_data)
215256
get_from_event = async_to_sync_method(aget_from_event)
216257

tests/events/test_ainstallation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ async def test_async_installation_repositories(ainstallation, create_event):
134134
}
135135
],
136136
}
137-
event = create_event("installation", delivery_id="1234", **data)
137+
event = create_event("installation_repositories", delivery_id="1234", **data)
138138

139139
assert await Repository.objects.filter(
140140
repository_id=data["repositories_removed"][0]["id"]

tests/events/test_installation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def test_sync_installation_repositories(installation, create_event):
131131
}
132132
],
133133
}
134-
event = create_event("installation", delivery_id="1234", **data)
134+
event = create_event("installation_repositories", delivery_id="1234", **data)
135135

136136
assert Repository.objects.filter(
137137
repository_id=data["repositories_removed"][0]["id"]

tests/test_models.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,118 @@ def test_get_from_event(self, repository, create_event):
438438
assert repo.full_name == data["repository"]["full_name"]
439439
assert repo.installation_id == repository.installation.id
440440

441+
def test_sync_repositories_from_event(self, installation, create_event):
442+
existing_repo = baker.make(
443+
"django_github_app.Repository",
444+
installation=installation,
445+
repository_id=seq.next(),
446+
repository_node_id="existing_node",
447+
full_name="owner/existing",
448+
)
449+
repo_to_remove = baker.make(
450+
"django_github_app.Repository",
451+
installation=installation,
452+
repository_id=seq.next(),
453+
repository_node_id="remove_node",
454+
full_name="owner/to_remove",
455+
)
456+
457+
event = create_event(
458+
"installation_repositories",
459+
installation={"id": installation.installation_id},
460+
repositories_added=[
461+
{
462+
"id": existing_repo.repository_id,
463+
"node_id": "existing_node",
464+
"full_name": "owner/existing",
465+
},
466+
{
467+
"id": seq.next(),
468+
"node_id": "new_node",
469+
"full_name": "owner/new",
470+
},
471+
],
472+
repositories_removed=[
473+
{"id": repo_to_remove.repository_id},
474+
],
475+
)
476+
477+
Repository.objects.sync_repositories_from_event(event)
478+
479+
assert Repository.objects.filter(
480+
repository_id=existing_repo.repository_id
481+
).exists()
482+
assert not Repository.objects.filter(
483+
repository_id=repo_to_remove.repository_id
484+
).exists()
485+
assert Repository.objects.filter(full_name="owner/new").exists()
486+
assert Repository.objects.filter(installation=installation).count() == 2
487+
488+
@pytest.mark.asyncio
489+
async def test_async_repositories_from_event(self, ainstallation, create_event):
490+
existing_repo = await sync_to_async(baker.make)(
491+
"django_github_app.Repository",
492+
installation=ainstallation,
493+
repository_id=seq.next(),
494+
repository_node_id="existing_node",
495+
full_name="owner/existing",
496+
)
497+
repo_to_remove = await sync_to_async(baker.make)(
498+
"django_github_app.Repository",
499+
installation=ainstallation,
500+
repository_id=seq.next(),
501+
repository_node_id="remove_node",
502+
full_name="owner/to_remove",
503+
)
504+
505+
event = create_event(
506+
"installation_repositories",
507+
installation={"id": ainstallation.installation_id},
508+
repositories_added=[
509+
{
510+
"id": existing_repo.repository_id,
511+
"node_id": "existing_node",
512+
"full_name": "owner/existing",
513+
},
514+
{
515+
"id": seq.next(),
516+
"node_id": "new_node",
517+
"full_name": "owner/new",
518+
},
519+
],
520+
repositories_removed=[
521+
{"id": repo_to_remove.repository_id},
522+
],
523+
)
524+
525+
await Repository.objects.async_repositories_from_event(event)
526+
527+
assert await Repository.objects.filter(
528+
repository_id=existing_repo.repository_id
529+
).aexists()
530+
assert not await Repository.objects.filter(
531+
repository_id=repo_to_remove.repository_id
532+
).aexists()
533+
assert await Repository.objects.filter(full_name="owner/new").aexists()
534+
assert await Repository.objects.filter(installation=ainstallation).acount() == 2
535+
536+
def test_sync_repositories_from_event_wrong_event_type(self, create_event):
537+
event = create_event("push")
538+
539+
with pytest.raises(
540+
ValueError, match="Expected 'installation_repositories' event"
541+
):
542+
Repository.objects.sync_repositories_from_event(event)
543+
544+
@pytest.mark.asyncio
545+
async def test_async_repositories_from_event_wrong_event_type(self, create_event):
546+
event = create_event("push")
547+
548+
with pytest.raises(
549+
ValueError, match="Expected 'installation_repositories' event"
550+
):
551+
await Repository.objects.async_repositories_from_event(event)
552+
441553

442554
class TestRepository:
443555
def test_get_gh_client(self, repository):

0 commit comments

Comments
 (0)