Skip to content

Commit

Permalink
Handle foreign key constraint errors (#483)
Browse files Browse the repository at this point in the history
  • Loading branch information
evroon committed Feb 17, 2024
1 parent 31537a6 commit 7666e3a
Show file tree
Hide file tree
Showing 4 changed files with 92 additions and 19 deletions.
8 changes: 7 additions & 1 deletion backend/bracket/routes/clubs.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncpg # type: ignore[import-untyped]
from fastapi import APIRouter, Depends

from bracket.logic.subscriptions import check_requirement
Expand All @@ -6,6 +7,7 @@
from bracket.routes.auth import user_authenticated, user_authenticated_for_club
from bracket.routes.models import ClubResponse, ClubsResponse, SuccessResponse
from bracket.sql.clubs import create_club, get_clubs_for_user_id, sql_delete_club, sql_update_club
from bracket.utils.errors import ForeignKey, check_foreign_key_violation
from bracket.utils.types import assert_some

router = APIRouter()
Expand All @@ -29,7 +31,11 @@ async def create_new_club(
async def delete_club(
club_id: int, _: UserPublic = Depends(user_authenticated_for_club)
) -> SuccessResponse:
await sql_delete_club(club_id)
try:
await sql_delete_club(club_id)
except asyncpg.exceptions.ForeignKeyViolationError as exc:
check_foreign_key_violation(exc, {ForeignKey.tournaments_club_id_fkey})

return SuccessResponse()


Expand Down
33 changes: 23 additions & 10 deletions backend/bracket/routes/tournaments.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@
)
from bracket.sql.users import get_user_access_to_club, get_which_clubs_has_user_access_to
from bracket.utils.db import fetch_one_parsed_certain
from bracket.utils.errors import check_constraint_and_raise_http_exception
from bracket.utils.errors import (
ForeignKey,
UniqueIndex,
check_foreign_key_violation,
check_unique_constraint_violation,
)
from bracket.utils.types import assert_some

router = APIRouter()
Expand Down Expand Up @@ -92,7 +97,7 @@ async def update_tournament_by_id(
values=tournament_body.model_dump(),
)
except asyncpg.exceptions.UniqueViolationError as exc:
check_constraint_and_raise_http_exception(exc)
check_unique_constraint_violation(exc, {UniqueIndex.ix_tournaments_dashboard_endpoint})

await update_start_times_of_matches(tournament_id)
return SuccessResponse()
Expand All @@ -102,7 +107,11 @@ async def update_tournament_by_id(
async def delete_tournament(
tournament_id: int, _: UserPublic = Depends(user_authenticated_for_tournament)
) -> SuccessResponse:
await sql_delete_tournament(tournament_id)
try:
await sql_delete_tournament(tournament_id)
except asyncpg.exceptions.ForeignKeyViolationError as exc:
check_foreign_key_violation(exc, {ForeignKey.stages_tournament_id_fkey})

return SuccessResponse()


Expand All @@ -123,13 +132,17 @@ async def create_tournament(
headers={"WWW-Authenticate": "Bearer"},
)

await database.execute(
query=tournaments.insert(),
values=TournamentToInsert(
**tournament_to_insert.model_dump(),
created=datetime_utc.now(),
).model_dump(),
)
try:
await database.execute(
query=tournaments.insert(),
values=TournamentToInsert(
**tournament_to_insert.model_dump(),
created=datetime_utc.now(),
).model_dump(),
)
except asyncpg.exceptions.UniqueViolationError as exc:
check_unique_constraint_violation(exc, {UniqueIndex.ix_tournaments_dashboard_endpoint})

return SuccessResponse()


Expand Down
52 changes: 44 additions & 8 deletions backend/bracket/utils/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,58 @@ class UniqueIndex(EnumAutoStr):
ix_users_email = auto()


class ForeignKey(EnumAutoStr):
stages_tournament_id_fkey = auto()
tournaments_club_id_fkey = auto()


unique_index_violation_error_lookup = {
UniqueIndex.ix_tournaments_dashboard_endpoint: "This dashboard link is already taken",
UniqueIndex.ix_users_email: "This email is already taken",
}


def check_constraint_and_raise_http_exception(
exc: asyncpg.exceptions.UniqueViolationError,
foreign_key_violation_error_lookup = {
ForeignKey.stages_tournament_id_fkey: "This tournament still has stages, delete those first",
ForeignKey.tournaments_club_id_fkey: "This club still has tournaments, delete those first",
}


def check_unique_constraint_violation(
exc: asyncpg.exceptions.UniqueViolationError, expected_violations: set[UniqueIndex]
) -> NoReturn:
constraint_name = exc.as_dict()["constraint_name"]
assert constraint_name, "UniqueViolationError occurred but no constraint_name defined"
assert constraint_name in UniqueIndex.values(), "Unknown UniqueViolationError occurred"
constraint = UniqueIndex(constraint_name)

if (
constraint not in unique_index_violation_error_lookup
or constraint not in expected_violations
):
raise exc

raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=unique_index_violation_error_lookup[constraint],
)


def check_foreign_key_violation(
exc: asyncpg.exceptions.ForeignKeyViolationError, expected_violations: set[ForeignKey]
) -> NoReturn:
constraint_name = exc.as_dict()["constraint_name"]
assert constraint_name, "ForeignKeyViolationError occurred but no constraint_name defined"
assert constraint_name in ForeignKey.values(), "Unknown ForeignKeyViolationError occurred"
constraint = ForeignKey(constraint_name)

if constraint_name in unique_index_violation_error_lookup:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=unique_index_violation_error_lookup[constraint_name],
)
if (
constraint not in foreign_key_violation_error_lookup
or constraint not in expected_violations
):
raise exc

raise exc
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=foreign_key_violation_error_lookup[constraint],
)
18 changes: 18 additions & 0 deletions backend/tests/integration_tests/index_lookup_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from bracket.database import database
from bracket.utils.errors import (
foreign_key_violation_error_lookup,
unique_index_violation_error_lookup,
)

Expand All @@ -23,3 +24,20 @@ async def test_all_unique_indices_in_lookup() -> None:

expected_indices = {ix.name for ix in unique_index_violation_error_lookup.keys()}
assert indices == expected_indices


async def test_known_foreign_keys_in_lookup() -> None:
query = """
SELECT conrelid::regclass AS table_name,
conname AS foreign_key
FROM pg_constraint
WHERE contype = 'f'
AND connamespace = 'public'::regnamespace
ORDER BY conrelid::regclass::text, contype DESC;
"""
result = await database.fetch_all(query)
indices = {ix.foreign_key for ix in result} # type: ignore[attr-defined]

for foreign_key in foreign_key_violation_error_lookup.keys():
msg = f"Unexpected foreign key in lookup: {foreign_key.value}"
assert foreign_key.name in indices, msg

0 comments on commit 7666e3a

Please sign in to comment.