From 4835aa73115fbe31ed6e573b16993e10c3bfd73a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:00:52 +0000 Subject: [PATCH] Replace databases dependency with asyncpg, remove SQLAlchemy except schema.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite database.py with asyncpg pool-based DatabasePool class - Support named params (:param) → positional ($N) conversion - Transaction context manager using contextvars for connection reuse - Replace all SQLAlchemy query building with raw SQL in routes and sql modules - Update utils/db.py to use raw SQL strings instead of SQLAlchemy Select - Update utils/db_init.py to use local engine creation for DDL only - Update all test files to use string table names and raw SQL - Remove databases and sqlalchemy-stubs from dependencies - Keep sqlalchemy only for schema.py definitions and alembic migrations --- backend/bracket/database.py | 150 +++++++++++++++++- backend/bracket/routes/auth.py | 5 +- backend/bracket/routes/courts.py | 29 ++-- backend/bracket/routes/players.py | 14 +- backend/bracket/routes/teams.py | 72 +++++---- backend/bracket/routes/tournaments.py | 5 +- backend/bracket/routes/util.py | 12 +- backend/bracket/sql/clubs.py | 6 +- backend/bracket/sql/courts.py | 4 +- backend/bracket/sql/matches.py | 4 +- backend/bracket/sql/players.py | 24 +-- backend/bracket/sql/rankings.py | 8 +- backend/bracket/sql/stage_item_inputs.py | 6 +- backend/bracket/sql/stage_items.py | 2 +- backend/bracket/sql/stages.py | 4 +- backend/bracket/sql/teams.py | 4 +- backend/bracket/sql/tournaments.py | 6 +- backend/bracket/sql/users.py | 13 +- backend/bracket/utils/db.py | 33 ++-- backend/bracket/utils/db_init.py | 74 +++++---- backend/pyproject.toml | 3 - .../integration_tests/api/courts_test.py | 9 +- .../integration_tests/api/matches_test.py | 15 +- .../integration_tests/api/players_test.py | 11 +- .../integration_tests/api/rankings_test.py | 4 +- .../api/rescheduling_matches_test.py | 3 +- .../integration_tests/api/rounds_test.py | 9 +- .../integration_tests/api/stage_items_test.py | 11 +- .../integration_tests/api/stages_test.py | 17 +- .../tests/integration_tests/api/teams_test.py | 13 +- .../integration_tests/api/tournaments_test.py | 11 +- .../tests/integration_tests/api/users_test.py | 3 +- backend/tests/integration_tests/conftest.py | 17 +- backend/tests/integration_tests/sql.py | 65 +++----- 34 files changed, 396 insertions(+), 270 deletions(-) diff --git a/backend/bracket/database.py b/backend/bracket/database.py index fc65b76f..135b3002 100644 --- a/backend/bracket/database.py +++ b/backend/bracket/database.py @@ -1,7 +1,9 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from contextvars import ContextVar from typing import Any -import sqlalchemy -from databases import Database +import asyncpg from heliclockter import datetime_utc from bracket.config import config @@ -12,7 +14,7 @@ def datetime_decoder(value: str) -> datetime_utc: return datetime_utc.fromisoformat(value) -async def asyncpg_init(connection: Any) -> None: +async def _init_connection(connection: asyncpg.Connection) -> None: # type: ignore[type-arg] for timestamp_type in ("timestamp", "timestamptz"): await connection.set_type_codec( timestamp_type, @@ -22,6 +24,144 @@ async def asyncpg_init(connection: Any) -> None: ) -database = Database(str(config.pg_dsn), init=asyncpg_init) +def _convert_named_params(query: str, values: dict[str, Any]) -> tuple[str, list[Any]]: + """Convert :param style parameters to $1, $2, ... style for asyncpg.""" + params: list[Any] = [] + result = query + # Sort keys by length (longest first) to avoid partial replacements + sorted_keys = sorted(values.keys(), key=len, reverse=True) + # First pass: replace all :param with unique placeholders + placeholders: dict[str, str] = {} + for key in sorted_keys: + placeholder = f"\x00PARAM_{key}\x00" + placeholders[key] = placeholder + result = result.replace(f":{key}", placeholder) + # Second pass: replace placeholders with $N + for key in sorted_keys: + placeholder = placeholders[key] + if placeholder in result: + params.append(values[key]) + idx = len(params) + result = result.replace(placeholder, f"${idx}", 1) + # Handle multiple occurrences of same param + while placeholder in result: + params.append(values[key]) + idx = len(params) + result = result.replace(placeholder, f"${idx}", 1) + return result, params -engine = sqlalchemy.create_engine(str(config.pg_dsn)) + +# Context variable to track the current transaction connection +_transaction_connection: ContextVar[asyncpg.Connection | None] = ContextVar( # type: ignore[type-arg] + "_transaction_connection", default=None +) + + +class _Transaction: + """Context manager for database transactions.""" + + def __init__(self, pool: asyncpg.Pool) -> None: # type: ignore[type-arg] + self._pool = pool + self._connection: asyncpg.Connection | None = None # type: ignore[type-arg] + self._transaction: asyncpg.connection.transaction.Transaction | None = None + self._token: Any = None + + async def __aenter__(self) -> "_Transaction": + self._connection = await self._pool.acquire() + self._transaction = self._connection.transaction() + await self._transaction.start() + self._token = _transaction_connection.set(self._connection) + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + assert self._connection is not None + assert self._transaction is not None + assert self._token is not None + try: + if exc_type is not None: + await self._transaction.rollback() + else: + await self._transaction.commit() + finally: + _transaction_connection.reset(self._token) + await self._pool.release(self._connection) + + +class DatabasePool: + """asyncpg-based database wrapper providing an interface compatible with + the old `databases.Database` usage in this codebase.""" + + def __init__(self, dsn: str) -> None: + self._dsn = dsn + self._pool: asyncpg.Pool | None = None # type: ignore[type-arg] + + async def connect(self) -> None: + self._pool = await asyncpg.create_pool(self._dsn, init=_init_connection) + + async def disconnect(self) -> None: + if self._pool is not None: + await self._pool.close() + self._pool = None + + @property + def pool(self) -> asyncpg.Pool: # type: ignore[type-arg] + assert self._pool is not None, "Database pool is not initialized. Call connect() first." + return self._pool + + def _get_conn(self) -> asyncpg.Connection | asyncpg.Pool: # type: ignore[type-arg] + """Return the transaction connection if inside a transaction, otherwise the pool.""" + conn = _transaction_connection.get() + if conn is not None: + return conn + return self.pool + + async def fetch_one( + self, query: str, values: dict[str, Any] | None = None + ) -> asyncpg.Record | None: + conn = self._get_conn() + if values: + converted_query, params = _convert_named_params(query, values) + return await conn.fetchrow(converted_query, *params) + return await conn.fetchrow(query) + + async def fetch_all( + self, query: str, values: dict[str, Any] | None = None + ) -> list[asyncpg.Record]: + conn = self._get_conn() + if values: + converted_query, params = _convert_named_params(query, values) + return await conn.fetch(converted_query, *params) + return await conn.fetch(query) + + async def fetch_val( + self, query: str, values: dict[str, Any] | None = None, column: int = 0 + ) -> Any: + conn = self._get_conn() + if values: + converted_query, params = _convert_named_params(query, values) + return await conn.fetchval(converted_query, *params, column=column) + return await conn.fetchval(query, column=column) + + async def execute(self, query: str, values: dict[str, Any] | None = None) -> str: + conn = self._get_conn() + if values: + converted_query, params = _convert_named_params(query, values) + return await conn.execute(converted_query, *params) + return await conn.execute(query) + + def transaction(self) -> _Transaction: + return _Transaction(self.pool) + + @asynccontextmanager + async def acquire(self) -> AsyncIterator[asyncpg.Connection]: # type: ignore[type-arg] + async with self.pool.acquire() as conn: + yield conn + + async def __aenter__(self) -> "DatabasePool": + return self + + async def __aexit__(self, *args: Any) -> None: + pass + + +database = DatabasePool(str(config.pg_dsn)) diff --git a/backend/bracket/routes/auth.py b/backend/bracket/routes/auth.py index b23c5986..856bb1e2 100644 --- a/backend/bracket/routes/auth.py +++ b/backend/bracket/routes/auth.py @@ -12,7 +12,6 @@ from bracket.config import config from bracket.database import database from bracket.models.db.tournament import Tournament from bracket.models.db.user import UserInDB, UserPublic -from bracket.schema import tournaments from bracket.sql.tournaments import sql_get_tournament_by_endpoint_name from bracket.sql.users import get_user, get_user_access_to_club, get_user_access_to_tournament from bracket.utils.db import fetch_all_parsed @@ -140,7 +139,9 @@ async def user_authenticated_or_public_dashboard( pass tournaments_fetched = await fetch_all_parsed( - database, Tournament, tournaments.select().where(tournaments.c.id == tournament_id) + database, Tournament, + "SELECT * FROM tournaments WHERE id = :tournament_id", + {"tournament_id": tournament_id}, ) if len(tournaments_fetched) < 1 or not tournaments_fetched[0].dashboard_public: raise HTTPException( diff --git a/backend/bracket/routes/courts.py b/backend/bracket/routes/courts.py index 0d177b38..086f03b0 100644 --- a/backend/bracket/routes/courts.py +++ b/backend/bracket/routes/courts.py @@ -14,7 +14,6 @@ from bracket.routes.auth import ( ) from bracket.routes.models import CourtsResponse, SingleCourtResponse, SuccessResponse from bracket.routes.util import disallow_archived_tournament -from bracket.schema import courts from bracket.sql.courts import get_all_courts_in_tournament, sql_delete_court, update_court from bracket.sql.stages import get_full_tournament_details from bracket.utils.db import fetch_one_parsed @@ -50,9 +49,8 @@ async def update_court_by_id( await fetch_one_parsed( database, Court, - courts.select().where( - (courts.c.id == court_id) & (courts.c.tournament_id == tournament_id) - ), + "SELECT * FROM courts WHERE id = :court_id AND tournament_id = :tournament_id", + {"court_id": court_id, "tournament_id": tournament_id}, ) ) ) @@ -94,22 +92,25 @@ async def create_court( existing_courts = await get_all_courts_in_tournament(tournament_id) check_requirement(existing_courts, user, "max_courts") - last_record_id = await database.execute( - query=courts.insert(), - values=CourtToInsert( - **court_body.model_dump(), - created=datetime_utc.now(), - tournament_id=tournament_id, - ).model_dump(), + insertable = CourtToInsert( + **court_body.model_dump(), + created=datetime_utc.now(), + tournament_id=tournament_id, + ) + values = insertable.model_dump() + columns = ", ".join(values.keys()) + placeholders = ", ".join(f":{k}" for k in values.keys()) + last_record_id = await database.fetch_val( + query=f"INSERT INTO courts ({columns}) VALUES ({placeholders}) RETURNING id", + values=values, ) return SingleCourtResponse( data=assert_some( await fetch_one_parsed( database, Court, - courts.select().where( - courts.c.id == last_record_id and courts.c.tournament_id == tournament_id - ), + "SELECT * FROM courts WHERE id = :court_id AND tournament_id = :tournament_id", + {"court_id": last_record_id, "tournament_id": tournament_id}, ) ) ) diff --git a/backend/bracket/routes/players.py b/backend/bracket/routes/players.py index 38c3bc88..7eb9e000 100644 --- a/backend/bracket/routes/players.py +++ b/backend/bracket/routes/players.py @@ -14,7 +14,6 @@ from bracket.routes.models import ( SuccessResponse, ) from bracket.routes.util import disallow_archived_tournament -from bracket.schema import players from bracket.sql.players import ( get_all_players_in_tournament, get_player_count, @@ -54,20 +53,19 @@ async def update_player_by_id( _: UserPublic = Depends(user_authenticated_for_tournament), __: Tournament = Depends(disallow_archived_tournament), ) -> SinglePlayerResponse: + values = player_body.model_dump() + set_clause = ", ".join(f"{k} = :{k}" for k in values) await database.execute( - query=players.update().where( - (players.c.id == player_id) & (players.c.tournament_id == tournament_id) - ), - values=player_body.model_dump(), + query=f"UPDATE players SET {set_clause} WHERE id = :player_id AND tournament_id = :tournament_id", + values={**values, "player_id": player_id, "tournament_id": tournament_id}, ) return SinglePlayerResponse( data=assert_some( await fetch_one_parsed( database, Player, - players.select().where( - (players.c.id == player_id) & (players.c.tournament_id == tournament_id) - ), + "SELECT * FROM players WHERE id = :player_id AND tournament_id = :tournament_id", + {"player_id": player_id, "tournament_id": tournament_id}, ) ) ) diff --git a/backend/bracket/routes/teams.py b/backend/bracket/routes/teams.py index 84b81a9c..e75ca4ce 100644 --- a/backend/bracket/routes/teams.py +++ b/backend/bracket/routes/teams.py @@ -36,7 +36,6 @@ from bracket.routes.util import ( team_dependency, team_with_players_dependency, ) -from bracket.schema import players_x_teams, teams from bracket.sql.players import get_all_players_in_tournament, insert_player from bracket.sql.teams import ( get_team_by_id, @@ -64,17 +63,21 @@ async def update_team_members( for player_id in player_ids: if player_id not in team.player_ids: await database.execute( - query=players_x_teams.insert(), + query="INSERT INTO players_x_teams (team_id, player_id) VALUES (:team_id, :player_id)", values={"team_id": team_id, "player_id": player_id}, ) # Remove old members from the team - await database.execute( - query=players_x_teams.delete().where( - (players_x_teams.c.player_id.not_in(player_ids)) # type: ignore[attr-defined] - & (players_x_teams.c.team_id == team_id) - ), - ) + if player_ids: + await database.execute( + query="DELETE FROM players_x_teams WHERE team_id = :team_id AND player_id != ALL(:player_ids)", + values={"team_id": team_id, "player_ids": list(player_ids)}, + ) + else: + await database.execute( + query="DELETE FROM players_x_teams WHERE team_id = :team_id", + values={"team_id": team_id}, + ) @router.get("/tournaments/{tournament_id}/teams", response_model=TeamsWithPlayersResponse) @@ -101,11 +104,11 @@ async def update_team_by_id( ) -> SingleTeamResponse: await check_foreign_keys_belong_to_tournament(team_body, tournament_id) + values = team_body.model_dump(exclude={"player_ids"}) + set_clause = ", ".join(f"{k} = :{k}" for k in values) await database.execute( - query=teams.update().where( - (teams.c.id == team.id) & (teams.c.tournament_id == tournament_id) - ), - values=team_body.model_dump(exclude={"player_ids"}), + query=f"UPDATE teams SET {set_clause} WHERE id = :team_id AND tournament_id = :tournament_id", + values={**values, "team_id": team.id, "tournament_id": tournament_id}, ) await update_team_members(team.id, tournament_id, team_body.player_ids) @@ -114,9 +117,8 @@ async def update_team_by_id( await fetch_one_parsed( database, Team, - teams.select().where( - (teams.c.id == team.id) & (teams.c.tournament_id == tournament_id) - ), + "SELECT * FROM teams WHERE id = :team_id AND tournament_id = :tournament_id", + {"team_id": team.id, "tournament_id": tournament_id}, ) ) ) @@ -154,8 +156,8 @@ async def update_team_logo( logger.error(f"Could not remove logo that should still exist: {old_logo_path}\n{exc}") await database.execute( - teams.update().where(teams.c.id == team.id), - values={"logo_path": filename}, + "UPDATE teams SET logo_path = :logo_path WHERE id = :team_id", + values={"logo_path": filename, "team_id": team.id}, ) return SingleTeamResponse(data=assert_some(await get_team_by_id(team.id, tournament_id))) @@ -191,13 +193,17 @@ async def create_team( existing_teams = await get_teams_with_members(tournament_id) check_requirement(existing_teams, user, "max_teams") - last_record_id = await database.execute( - query=teams.insert(), - values=TeamInsertable( - **team_to_insert.model_dump(exclude={"player_ids"}), - created=datetime_utc.now(), - tournament_id=tournament_id, - ).model_dump(), + insertable = TeamInsertable( + **team_to_insert.model_dump(exclude={"player_ids"}), + created=datetime_utc.now(), + tournament_id=tournament_id, + ) + values = insertable.model_dump() + columns = ", ".join(values.keys()) + placeholders = ", ".join(f":{k}" for k in values.keys()) + last_record_id = await database.fetch_val( + query=f"INSERT INTO teams ({columns}) VALUES ({placeholders}) RETURNING id", + values=values, ) await update_team_members(last_record_id, tournament_id, team_to_insert.player_ids) @@ -229,14 +235,18 @@ async def create_multiple_teams( async with database.transaction(): for team_name, players in teams_and_players: + insertable = TeamInsertable( + name=team_name, + active=team_body.active, + created=datetime_utc.now(), + tournament_id=tournament_id, + ) + values = insertable.model_dump() + columns = ", ".join(values.keys()) + placeholders = ", ".join(f":{k}" for k in values.keys()) await database.execute( - query=teams.insert(), - values=TeamInsertable( - name=team_name, - active=team_body.active, - created=datetime_utc.now(), - tournament_id=tournament_id, - ).model_dump(), + query=f"INSERT INTO teams ({columns}) VALUES ({placeholders})", + values=values, ) for player in players: player_body = PlayerBody(name=player, active=team_body.active) diff --git a/backend/bracket/routes/tournaments.py b/backend/bracket/routes/tournaments.py index 62ea4c61..521526a7 100644 --- a/backend/bracket/routes/tournaments.py +++ b/backend/bracket/routes/tournaments.py @@ -27,7 +27,6 @@ from bracket.routes.auth import ( ) from bracket.routes.models import SuccessResponse, TournamentResponse, TournamentsResponse from bracket.routes.util import disallow_archived_tournament -from bracket.schema import tournaments from bracket.sql.rankings import ( get_all_rankings_in_tournament, sql_create_ranking, @@ -211,7 +210,7 @@ async def upload_logo( logger.error(f"Could not remove logo that should still exist: {old_logo_path}\n{exc}") await database.execute( - tournaments.update().where(tournaments.c.id == tournament_id), - values={"logo_path": filename}, + "UPDATE tournaments SET logo_path = :logo_path WHERE id = :tournament_id", + values={"logo_path": filename, "tournament_id": tournament_id}, ) return TournamentResponse(data=await sql_get_tournament(tournament_id)) diff --git a/backend/bracket/routes/util.py b/backend/bracket/routes/util.py index 978764d0..547cca46 100644 --- a/backend/bracket/routes/util.py +++ b/backend/bracket/routes/util.py @@ -7,7 +7,6 @@ from bracket.models.db.round import Round from bracket.models.db.team import FullTeamWithPlayers, Team from bracket.models.db.tournament import Tournament, TournamentStatus from bracket.models.db.util import RoundWithMatches, StageItemWithRounds, StageWithStageItems -from bracket.schema import matches, rounds, teams from bracket.sql.rounds import get_round_by_id from bracket.sql.stage_items import get_stage_item from bracket.sql.stages import get_full_tournament_details @@ -21,7 +20,8 @@ async def round_dependency(tournament_id: TournamentId, round_id: RoundId) -> Ro round_ = await fetch_one_parsed( database, Round, - rounds.select().where(rounds.c.id == round_id and matches.c.tournament_id == tournament_id), + "SELECT * FROM rounds WHERE id = :round_id", + {"round_id": round_id}, ) if round_ is None: @@ -63,9 +63,8 @@ async def match_dependency(tournament_id: TournamentId, match_id: MatchId) -> Ma match = await fetch_one_parsed( database, Match, - matches.select().where( - matches.c.id == match_id and matches.c.tournament_id == tournament_id - ), + "SELECT * FROM matches WHERE id = :match_id", + {"match_id": match_id}, ) if match is None: @@ -81,7 +80,8 @@ async def team_dependency(tournament_id: TournamentId, team_id: TeamId) -> Team: team = await fetch_one_parsed( database, Team, - teams.select().where(teams.c.id == team_id and teams.c.tournament_id == tournament_id), + "SELECT * FROM teams WHERE id = :team_id AND tournament_id = :tournament_id", + {"team_id": team_id, "tournament_id": tournament_id}, ) if team is None: diff --git a/backend/bracket/sql/clubs.py b/backend/bracket/sql/clubs.py index 13463319..93a0e46c 100644 --- a/backend/bracket/sql/clubs.py +++ b/backend/bracket/sql/clubs.py @@ -26,7 +26,7 @@ async def create_club(club: ClubCreateBody, user_id: UserId) -> Club: if result is None: raise ValueError("Could not create club") - club_created = Club.model_validate(dict(result._mapping)) + club_created = Club.model_validate(dict(result)) await sql_give_user_access_to_club(user_id, club_created.id) @@ -41,7 +41,7 @@ async def sql_update_club(club_id: ClubId, club: ClubUpdateBody) -> Club | None: RETURNING * """ result = await database.fetch_one(query=query, values={"name": club.name, "club_id": club_id}) - return Club.model_validate(result) if result is not None else None + return Club.model_validate(dict(result)) if result is not None else None async def sql_delete_club(club_id: ClubId) -> None: @@ -59,4 +59,4 @@ async def get_clubs_for_user_id(user_id: UserId) -> list[Club]: WHERE uxc.user_id = :user_id """ results = await database.fetch_all(query=query, values={"user_id": user_id}) - return [Club.model_validate(dict(result._mapping)) for result in results] + return [Club.model_validate(dict(result)) for result in results] diff --git a/backend/bracket/sql/courts.py b/backend/bracket/sql/courts.py index 251ad78a..1966ce53 100644 --- a/backend/bracket/sql/courts.py +++ b/backend/bracket/sql/courts.py @@ -11,7 +11,7 @@ async def get_all_courts_in_tournament(tournament_id: TournamentId) -> list[Cour ORDER BY name """ result = await database.fetch_all(query=query, values={"tournament_id": tournament_id}) - return [Court.model_validate(dict(x._mapping)) for x in result] + return [Court.model_validate(dict(x)) for x in result] async def update_court( @@ -27,7 +27,7 @@ async def update_court( query=query, values={"tournament_id": tournament_id, "court_id": court_id, "name": court_body.name}, ) - return [Court.model_validate(dict(x._mapping)) for x in result] + return [Court.model_validate(dict(x)) for x in result] async def sql_delete_court(tournament_id: TournamentId, court_id: CourtId) -> None: diff --git a/backend/bracket/sql/matches.py b/backend/bracket/sql/matches.py index 39adecc5..7774fe7d 100644 --- a/backend/bracket/sql/matches.py +++ b/backend/bracket/sql/matches.py @@ -79,7 +79,7 @@ async def sql_create_match(match: MatchCreateBody) -> Match: if result is None: raise ValueError("Could not create stage") - return Match.model_validate(dict(result._mapping)) + return Match.model_validate(dict(result)) async def sql_update_match(match_id: MatchId, match: MatchBody, tournament: Tournament) -> None: @@ -223,7 +223,7 @@ async def sql_get_match(match_id: MatchId) -> Match: if result is None: raise ValueError("Could not create stage") - return Match.model_validate(dict(result._mapping)) + return Match.model_validate(dict(result)) async def clear_scores_for_matches_in_stage_item( diff --git a/backend/bracket/sql/players.py b/backend/bracket/sql/players.py index 4e8177cc..9721330e 100644 --- a/backend/bracket/sql/players.py +++ b/backend/bracket/sql/players.py @@ -5,7 +5,6 @@ from heliclockter import datetime_utc from bracket.database import database from bracket.logic.ranking.statistics import START_ELO from bracket.models.db.player import Player, PlayerBody, PlayerToInsert -from bracket.schema import players from bracket.utils.id_types import PlayerId, TournamentId from bracket.utils.pagination import PaginationPlayers from bracket.utils.types import dict_without_none @@ -45,7 +44,7 @@ async def get_all_players_in_tournament( ), ) - return [Player.model_validate(x) for x in result] + return [Player.model_validate(dict(x)) for x in result] async def get_player_by_id(player_id: PlayerId, tournament_id: TournamentId) -> Player | None: @@ -58,7 +57,7 @@ async def get_player_by_id(player_id: PlayerId, tournament_id: TournamentId) -> result = await database.fetch_one( query=query, values={"player_id": player_id, "tournament_id": tournament_id} ) - return Player.model_validate(result) if result is not None else None + return Player.model_validate(dict(result)) if result is not None else None async def get_player_count( @@ -89,13 +88,16 @@ async def sql_delete_players_of_tournament(tournament_id: TournamentId) -> None: async def insert_player(player_body: PlayerBody, tournament_id: TournamentId) -> None: + values = PlayerToInsert( + **player_body.model_dump(), + created=datetime_utc.now(), + tournament_id=tournament_id, + elo_score=START_ELO, + swiss_score=Decimal("0.0"), + ).model_dump() + columns = ", ".join(values.keys()) + placeholders = ", ".join(f":{k}" for k in values.keys()) await database.execute( - query=players.insert(), - values=PlayerToInsert( - **player_body.model_dump(), - created=datetime_utc.now(), - tournament_id=tournament_id, - elo_score=START_ELO, - swiss_score=Decimal("0.0"), - ).model_dump(), + query=f"INSERT INTO players ({columns}) VALUES ({placeholders})", + values=values, ) diff --git a/backend/bracket/sql/rankings.py b/backend/bracket/sql/rankings.py index b7ff59c4..3b9d499a 100644 --- a/backend/bracket/sql/rankings.py +++ b/backend/bracket/sql/rankings.py @@ -11,7 +11,7 @@ async def get_all_rankings_in_tournament(tournament_id: TournamentId) -> list[Ra ORDER BY position """ result = await database.fetch_all(query=query, values={"tournament_id": tournament_id}) - return [Ranking.model_validate(dict(x._mapping)) for x in result] + return [Ranking.model_validate(dict(x)) for x in result] async def get_default_rankings_in_tournament(tournament_id: TournamentId) -> Ranking: @@ -24,7 +24,7 @@ async def get_default_rankings_in_tournament(tournament_id: TournamentId) -> Ran """ result = await database.fetch_one(query=query, values={"tournament_id": tournament_id}) assert result is not None, "No default ranking found" - return Ranking.model_validate(dict(result._mapping)) + return Ranking.model_validate(dict(result)) async def get_ranking_for_stage_item( @@ -40,7 +40,7 @@ async def get_ranking_for_stage_item( result = await database.fetch_one( query=query, values={"tournament_id": tournament_id, "stage_item_id": stage_item_id} ) - return Ranking.model_validate(dict(result._mapping)) if result else None + return Ranking.model_validate(dict(result)) if result else None async def sql_update_ranking( @@ -68,7 +68,7 @@ async def sql_update_ranking( "position": ranking_body.position, }, ) - return [Ranking.model_validate(dict(x._mapping)) for x in result] + return [Ranking.model_validate(dict(x)) for x in result] async def sql_delete_ranking(tournament_id: TournamentId, ranking_id: RankingId) -> None: diff --git a/backend/bracket/sql/stage_item_inputs.py b/backend/bracket/sql/stage_item_inputs.py index 2f20c309..9989ca39 100644 --- a/backend/bracket/sql/stage_item_inputs.py +++ b/backend/bracket/sql/stage_item_inputs.py @@ -30,11 +30,11 @@ async def get_stage_item_input_by_id( return None if result["team_id"] is not None: - data = dict(result._mapping) + data = dict(result) data["team"] = await get_team_by_id(data["team_id"], tournament_id) return StageItemInputFinal.model_validate(data) - return TypeAdapter(StageItemInput).validate_python(result) + return TypeAdapter(StageItemInput).validate_python(dict(result)) async def get_stage_item_input_ids_by_ranking_id(ranking_id: RankingId) -> list[StageItemId]: @@ -131,4 +131,4 @@ async def sql_create_stage_item_input( if result is None: raise ValueError("Could not create stage") - return StageItemInputBase.model_validate(dict(result._mapping)) + return StageItemInputBase.model_validate(dict(result)) diff --git a/backend/bracket/sql/stage_items.py b/backend/bracket/sql/stage_items.py index e18437db..65d9cf73 100644 --- a/backend/bracket/sql/stage_items.py +++ b/backend/bracket/sql/stage_items.py @@ -34,7 +34,7 @@ async def sql_create_stage_item( if result is None: raise ValueError("Could not create stage") - return StageItem.model_validate(dict(result._mapping)) + return StageItem.model_validate(dict(result)) async def sql_create_stage_item_with_inputs( diff --git a/backend/bracket/sql/stages.py b/backend/bracket/sql/stages.py index d8301fb0..df74b225 100644 --- a/backend/bracket/sql/stages.py +++ b/backend/bracket/sql/stages.py @@ -111,7 +111,7 @@ async def get_full_tournament_details( } ) result = await database.fetch_all(query=query, values=values) - return [StageWithStageItems.model_validate(dict(x._mapping)) for x in result] + return [StageWithStageItems.model_validate(dict(x)) for x in result] async def sql_delete_stage(tournament_id: TournamentId, stage_id: StageId) -> None: @@ -146,7 +146,7 @@ async def sql_create_stage(tournament_id: TournamentId) -> Stage: if result is None: raise ValueError("Could not create stage") - return Stage.model_validate(dict(result._mapping)) + return Stage.model_validate(dict(result)) async def get_next_stage_in_tournament( diff --git a/backend/bracket/sql/teams.py b/backend/bracket/sql/teams.py index 28a47c3b..3e1da103 100644 --- a/backend/bracket/sql/teams.py +++ b/backend/bracket/sql/teams.py @@ -21,7 +21,7 @@ async def get_teams_by_id(team_ids: set[TeamId], tournament_id: TournamentId) -> result = await database.fetch_all( query=query, values={"team_ids": team_ids, "tournament_id": tournament_id} ) - return [Team.model_validate(team) for team in result] + return [Team.model_validate(dict(team)) for team in result] async def get_team_by_id(team_id: TeamId, tournament_id: TournamentId) -> Team | None: @@ -71,7 +71,7 @@ async def get_teams_with_members( } ) result = await database.fetch_all(query=query, values=values) - return [FullTeamWithPlayers.model_validate(x) for x in result] + return [FullTeamWithPlayers.model_validate(dict(x)) for x in result] async def get_team_count( diff --git a/backend/bracket/sql/tournaments.py b/backend/bracket/sql/tournaments.py index 87c55d88..3b2069d2 100644 --- a/backend/bracket/sql/tournaments.py +++ b/backend/bracket/sql/tournaments.py @@ -18,7 +18,7 @@ async def sql_get_tournament(tournament_id: TournamentId) -> Tournament: """ result = await database.fetch_one(query=query, values={"tournament_id": tournament_id}) assert result is not None - return Tournament.model_validate(result) + return Tournament.model_validate(dict(result)) async def sql_get_tournament_by_endpoint_name(endpoint_name: str) -> Tournament | None: @@ -29,7 +29,7 @@ async def sql_get_tournament_by_endpoint_name(endpoint_name: str) -> Tournament AND dashboard_public IS TRUE """ result = await database.fetch_one(query=query, values={"endpoint_name": endpoint_name}) - return Tournament.model_validate(result) if result is not None else None + return Tournament.model_validate(dict(result)) if result is not None else None async def sql_get_tournaments( @@ -55,7 +55,7 @@ async def sql_get_tournaments( query += "AND status = 'ARCHIVED'" result = await database.fetch_all(query=query, values=params) - return [Tournament.model_validate(x) for x in result] + return [Tournament.model_validate(dict(x)) for x in result] async def sql_delete_tournament(tournament_id: TournamentId) -> None: diff --git a/backend/bracket/sql/users.py b/backend/bracket/sql/users.py index b2f87bc5..dafcb988 100644 --- a/backend/bracket/sql/users.py +++ b/backend/bracket/sql/users.py @@ -2,7 +2,6 @@ from bracket.database import database from bracket.logic.tournaments import sql_delete_tournament_completely from bracket.models.db.account import UserAccountType from bracket.models.db.user import User, UserInDB, UserInsertable, UserPublic, UserToUpdate -from bracket.schema import users from bracket.sql.clubs import get_clubs_for_user_id, sql_delete_club from bracket.sql.tournaments import sql_get_tournaments from bracket.utils.db import fetch_one_parsed @@ -73,7 +72,7 @@ async def get_user_by_id(user_id: UserId) -> UserPublic | None: WHERE id = :user_id """ result = await database.fetch_one(query=query, values={"user_id": user_id}) - return UserPublic.model_validate(dict(result._mapping)) if result is not None else None + return UserPublic.model_validate(dict(result)) if result is not None else None async def get_expired_demo_users() -> list[UserPublic]: @@ -84,7 +83,7 @@ async def get_expired_demo_users() -> list[UserPublic]: AND created <= NOW() - INTERVAL '30 minutes' """ result = await database.fetch_all(query=query) - return [UserPublic.model_validate(demo_user) for demo_user in result] + return [UserPublic.model_validate(dict(demo_user)) for demo_user in result] async def create_user(user: UserInsertable) -> User: @@ -103,7 +102,7 @@ async def create_user(user: UserInsertable) -> User: "account_type": user.account_type.value, }, ) - return User.model_validate(dict(assert_some(result)._mapping)) + return User.model_validate(dict(assert_some(result))) async def delete_user(user_id: UserId) -> None: @@ -125,7 +124,11 @@ async def check_whether_email_is_in_use(email: str) -> bool: async def get_user(email: str) -> UserInDB | None: - return await fetch_one_parsed(database, UserInDB, users.select().where(users.c.email == email)) + return await fetch_one_parsed( + database, UserInDB, + "SELECT * FROM users WHERE email = :email", + {"email": email}, + ) async def delete_user_and_owned_clubs(user_id: UserId) -> None: diff --git a/backend/bracket/utils/db.py b/backend/bracket/utils/db.py index 7f7b2361..80105781 100644 --- a/backend/bracket/utils/db.py +++ b/backend/bracket/utils/db.py @@ -1,49 +1,46 @@ -from databases import Database from pydantic import BaseModel -from sqlalchemy import Table -from sqlalchemy.sql import Select from bracket.config import Environment, environment +from bracket.database import DatabasePool from bracket.utils.conversion import to_string_mapping from bracket.utils.logging import logger from bracket.utils.types import assert_some async def fetch_one_parsed[BaseModelT: BaseModel]( - database: Database, model: type[BaseModelT], query: Select + database: DatabasePool, model: type[BaseModelT], query: str, values: dict | None = None ) -> BaseModelT | None: - record = await database.fetch_one(query) - return model.model_validate(dict(record._mapping)) if record is not None else None + record = await database.fetch_one(query, values) + return model.model_validate(dict(record)) if record is not None else None async def fetch_one_parsed_certain[BaseModelT: BaseModel]( - database: Database, model: type[BaseModelT], query: Select + database: DatabasePool, model: type[BaseModelT], query: str, values: dict | None = None ) -> BaseModelT: - return assert_some(await fetch_one_parsed(database, model, query)) + return assert_some(await fetch_one_parsed(database, model, query, values)) async def fetch_all_parsed[BaseModelT: BaseModel]( - database: Database, model: type[BaseModelT], query: Select + database: DatabasePool, model: type[BaseModelT], query: str, values: dict | None = None ) -> list[BaseModelT]: - records = await database.fetch_all(query) - return [model.model_validate(dict(record._mapping)) for record in records] + records = await database.fetch_all(query, values) + return [model.model_validate(dict(record)) for record in records] async def insert_generic[BaseModelT: BaseModel]( - database: Database, data_model: BaseModelT, table: Table, return_type: type[BaseModelT] + database: DatabasePool, data_model: BaseModelT, table_name: str, return_type: type[BaseModelT] ) -> tuple[int, BaseModelT]: assert environment is not Environment.PRODUCTION, "Below code can allow SQL injection" try: mapping = to_string_mapping(data_model) values = ", ".join([f"'{x}'" for x in mapping.values()]) query = ( - f"INSERT INTO {table.name} ({', '.join(mapping.keys())}) VALUES ({values}) RETURNING *" + f"INSERT INTO {table_name} ({', '.join(mapping.keys())}) VALUES ({values}) RETURNING *" ) - last_record_id: int = await database.execute(query) - row_inserted = await fetch_one_parsed( - database, return_type, table.select().where(table.c.id == last_record_id) - ) - assert isinstance(row_inserted, return_type), f"Unexpected type: {row_inserted}" + result = await database.fetch_one(query) + assert result is not None, f"Could not insert {type(data_model).__name__}" + last_record_id: int = result["id"] + row_inserted = return_type.model_validate(dict(result)) return last_record_id, row_inserted except Exception: logger.exception(f"Could not insert {type(data_model).__name__}") diff --git a/backend/bracket/utils/db_init.py b/backend/bracket/utils/db_init.py index 45d09d97..b614dd6f 100644 --- a/backend/bracket/utils/db_init.py +++ b/backend/bracket/utils/db_init.py @@ -1,11 +1,11 @@ import random -from typing import TYPE_CHECKING, Any +from typing import Any from heliclockter import datetime_utc from pydantic import BaseModel from bracket.config import Environment, config, environment -from bracket.database import database, engine +from bracket.database import database from bracket.logic.ranking.calculation import ( recalculate_ranking_for_stage_item, ) @@ -31,22 +31,7 @@ from bracket.models.db.team import TeamInsertable from bracket.models.db.tournament import TournamentInsertable from bracket.models.db.user import UserInsertable from bracket.models.db.user_x_club import UserXClubInsertable, UserXClubRelation -from bracket.schema import ( - clubs, - courts, - matches, - metadata, - players, - players_x_teams, - rankings, - rounds, - stage_items, - stages, - teams, - tournaments, - users, - users_x_clubs, -) +from bracket.schema import metadata from bracket.sql.matches import sql_update_match from bracket.sql.stage_items import get_stage_item, sql_create_stage_item_with_inputs from bracket.sql.stages import get_full_tournament_details @@ -92,9 +77,6 @@ from bracket.utils.logging import logger from bracket.utils.security import hash_password from bracket.utils.types import assert_some -if TYPE_CHECKING: - from sqlalchemy import Table - async def create_admin_user() -> UserId: assert config.admin_email @@ -121,7 +103,7 @@ async def init_db_when_empty() -> UserId | None: environment is Environment.DEVELOPMENT and await get_user(config.admin_email) is None ): logger.warning("Empty db detected, creating tables...") - metadata.create_all(engine) + _create_all_tables() alembic_stamp_head() logger.warning("Empty db detected, creating admin user...") @@ -130,6 +112,22 @@ async def init_db_when_empty() -> UserId | None: return None +def _create_all_tables() -> None: + import sqlalchemy + + engine = sqlalchemy.create_engine(str(config.pg_dsn)) + metadata.create_all(engine) + engine.dispose() + + +def _drop_all_tables() -> None: + import sqlalchemy + + engine = sqlalchemy.create_engine(str(config.pg_dsn)) + metadata.drop_all(engine) + engine.dispose() + + async def sql_create_dev_db() -> UserId: # TODO: refactor into smaller functions # pylint: disable=too-many-statements @@ -137,25 +135,25 @@ async def sql_create_dev_db() -> UserId: logger.warning("Initializing database with dummy records") await database.connect() - metadata.drop_all(engine) - metadata.create_all(engine) + _drop_all_tables() + _create_all_tables() real_user_id = await init_db_when_empty() alembic_stamp_head() - table_lookup: dict[type, Table] = { - UserInsertable: users, - ClubInsertable: clubs, - StageInsertable: stages, - TeamInsertable: teams, - UserXClubInsertable: users_x_clubs, - PlayerXTeamInsertable: players_x_teams, - PlayerInsertable: players, - RoundInsertable: rounds, - Match: matches, - TournamentInsertable: tournaments, - CourtInsertable: courts, - StageItemInsertable: stage_items, - RankingInsertable: rankings, + table_lookup: dict[type, str] = { + UserInsertable: "users", + ClubInsertable: "clubs", + StageInsertable: "stages", + TeamInsertable: "teams", + UserXClubInsertable: "users_x_clubs", + PlayerXTeamInsertable: "players_x_teams", + PlayerInsertable: "players", + RoundInsertable: "rounds", + Match: "matches", + TournamentInsertable: "tournaments", + CourtInsertable: "courts", + StageItemInsertable: "stage_items", + RankingInsertable: "rankings", } async def insert_dummy[BaseModelT: BaseModel]( diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3f5ef602..9bbe630e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,7 +10,6 @@ dependencies = [ "asyncpg==0.31.0", "bcrypt==5.0.0", "click==8.4.0", - "databases[asyncpg]==0.9.0", "fastapi==0.138.0", "fastapi-sso==0.21.0", "gunicorn==26.0.0", @@ -24,7 +23,6 @@ dependencies = [ "python-multipart==0.0.31", "sentry-sdk==2.63.0", "sqlalchemy==2.0.44", - "sqlalchemy-stubs==0.4", "starlette==1.3.1", "types-aiofiles==25.1.0.20251011", "types-passlib==1.7.7.20241221", @@ -40,7 +38,6 @@ junit_family = 'xunit2' asyncio_mode = 'auto' filterwarnings = [ 'error', - 'ignore:The SelectBase.c and SelectBase.columns attributes are deprecated.*:DeprecationWarning', 'ignore:pkg_resources is deprecated as an API.*:DeprecationWarning', 'ignore:Deprecated call to `pkg_resources.declare_namespace(.*)`.*:DeprecationWarning', 'ignore:.*:pytest.PytestDeprecationWarning', diff --git a/backend/tests/integration_tests/api/courts_test.py b/backend/tests/integration_tests/api/courts_test.py index 41b62b36..aa45e94e 100644 --- a/backend/tests/integration_tests/api/courts_test.py +++ b/backend/tests/integration_tests/api/courts_test.py @@ -2,7 +2,6 @@ import pytest from bracket.database import database from bracket.models.db.court import Court -from bracket.schema import courts from bracket.utils.db import fetch_one_parsed_certain from bracket.utils.dummy_records import DUMMY_COURT1, DUMMY_MOCK_TIME, DUMMY_TEAM1 from bracket.utils.http import HTTPMethod @@ -40,7 +39,7 @@ async def test_create_court( body = {"name": "Some new name", "active": True} response = await send_tournament_request(HTTPMethod.POST, "courts", auth_context, json=body) assert response["data"]["name"] == body["name"] - await assert_row_count_and_clear(courts, 1) + await assert_row_count_and_clear("courts", 1) @pytest.mark.asyncio(loop_scope="session") @@ -59,7 +58,7 @@ async def test_delete_court( ) == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(courts, 0) + await assert_row_count_and_clear("courts", 0) @pytest.mark.asyncio(loop_scope="session") @@ -77,9 +76,9 @@ async def test_update_court( HTTPMethod.PUT, f"courts/{court_inserted.id}", auth_context, json=body ) updated_court = await fetch_one_parsed_certain( - database, Court, query=courts.select().where(courts.c.id == court_inserted.id) + database, Court, "SELECT * FROM courts WHERE id = :id", {"id": court_inserted.id} ) assert updated_court.name == body["name"] assert response["data"]["name"] == body["name"] - await assert_row_count_and_clear(courts, 1) + await assert_row_count_and_clear("courts", 1) diff --git a/backend/tests/integration_tests/api/matches_test.py b/backend/tests/integration_tests/api/matches_test.py index 7468e330..6e88c7ee 100644 --- a/backend/tests/integration_tests/api/matches_test.py +++ b/backend/tests/integration_tests/api/matches_test.py @@ -8,7 +8,6 @@ from bracket.models.db.stage_item import StageType from bracket.models.db.stage_item_inputs import ( StageItemInputInsertable, ) -from bracket.schema import matches from bracket.utils.db import fetch_one_parsed_certain from bracket.utils.dummy_records import ( DUMMY_COURT1, @@ -82,7 +81,7 @@ async def test_create_match( ) assert response["data"]["id"], response - await assert_row_count_and_clear(matches, 1) + await assert_row_count_and_clear("matches", 1) @pytest.mark.asyncio(loop_scope="session") @@ -149,7 +148,7 @@ async def test_delete_match( ) == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(matches, 0) + await assert_row_count_and_clear("matches", 0) @pytest.mark.asyncio(loop_scope="session") @@ -223,13 +222,14 @@ async def test_update_match( updated_match = await fetch_one_parsed_certain( database, Match, - query=matches.select().where(matches.c.id == match_inserted.id), + "SELECT * FROM matches WHERE id = :id", + {"id": match_inserted.id}, ) assert updated_match.stage_item_input1_score == body["stage_item_input1_score"] assert updated_match.stage_item_input2_score == body["stage_item_input2_score"] assert updated_match.court_id == body["court_id"] - await assert_row_count_and_clear(matches, 1) + await assert_row_count_and_clear("matches", 1) @pytest.mark.asyncio(loop_scope="session") @@ -304,12 +304,13 @@ async def test_update_endpoint_custom_duration_margin( updated_match = await fetch_one_parsed_certain( database, Match, - query=matches.select().where(matches.c.id == match_inserted.id), + "SELECT * FROM matches WHERE id = :id", + {"id": match_inserted.id}, ) assert updated_match.custom_duration_minutes == body["custom_duration_minutes"] assert updated_match.custom_margin_minutes == body["custom_margin_minutes"] - await assert_row_count_and_clear(matches, 1) + await assert_row_count_and_clear("matches", 1) @pytest.mark.asyncio(loop_scope="session") diff --git a/backend/tests/integration_tests/api/players_test.py b/backend/tests/integration_tests/api/players_test.py index fc669beb..5fd2f33e 100644 --- a/backend/tests/integration_tests/api/players_test.py +++ b/backend/tests/integration_tests/api/players_test.py @@ -2,7 +2,6 @@ import pytest from bracket.database import database from bracket.models.db.player import Player -from bracket.schema import players from bracket.utils.db import fetch_one_parsed_certain from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_PLAYER1, DUMMY_TEAM1 from bracket.utils.http import HTTPMethod @@ -49,7 +48,7 @@ async def test_create_player( body = {"name": "Some new name", "active": True} response = await send_tournament_request(HTTPMethod.POST, "players", auth_context, json=body) assert response["success"] is True - await assert_row_count_and_clear(players, 1) + await assert_row_count_and_clear("players", 1) @pytest.mark.asyncio(loop_scope="session") @@ -61,7 +60,7 @@ async def test_create_players( HTTPMethod.POST, "players_multi", auth_context, json=body ) assert response["success"] is True - await assert_row_count_and_clear(players, 2) + await assert_row_count_and_clear("players", 2) @pytest.mark.asyncio(loop_scope="session") @@ -80,7 +79,7 @@ async def test_delete_player( ) == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(players, 0) + await assert_row_count_and_clear("players", 0) @pytest.mark.asyncio(loop_scope="session") @@ -98,9 +97,9 @@ async def test_update_player( HTTPMethod.PUT, f"players/{player_inserted.id}", auth_context, json=body ) updated_player = await fetch_one_parsed_certain( - database, Player, query=players.select().where(players.c.id == player_inserted.id) + database, Player, "SELECT * FROM players WHERE id = :id", {"id": player_inserted.id} ) assert updated_player.name == body["name"] assert response["data"]["name"] == body["name"] - await assert_row_count_and_clear(players, 1) + await assert_row_count_and_clear("players", 1) diff --git a/backend/tests/integration_tests/api/rankings_test.py b/backend/tests/integration_tests/api/rankings_test.py index 18ab8cdc..905045c9 100644 --- a/backend/tests/integration_tests/api/rankings_test.py +++ b/backend/tests/integration_tests/api/rankings_test.py @@ -5,7 +5,6 @@ import pytest from bracket.database import database from bracket.models.db.ranking import Ranking -from bracket.schema import rankings from bracket.sql.rankings import ( get_all_rankings_in_tournament, sql_delete_ranking, @@ -95,7 +94,8 @@ async def test_update_ranking( updated_ranking = await fetch_one_parsed_certain( database, Ranking, - query=rankings.select().where(rankings.c.id == ranking_inserted.id), + "SELECT * FROM rankings WHERE id = :id", + {"id": ranking_inserted.id}, ) assert response["success"] is True assert updated_ranking.win_points == Decimal("7.5") diff --git a/backend/tests/integration_tests/api/rescheduling_matches_test.py b/backend/tests/integration_tests/api/rescheduling_matches_test.py index 6899d72c..c9b61eb6 100644 --- a/backend/tests/integration_tests/api/rescheduling_matches_test.py +++ b/backend/tests/integration_tests/api/rescheduling_matches_test.py @@ -2,7 +2,6 @@ import pytest from bracket.models.db.match import MatchRescheduleBody from bracket.models.db.stage_item_inputs import StageItemInputInsertable -from bracket.schema import matches from bracket.sql.matches import sql_get_match from bracket.utils.dummy_records import ( DUMMY_COURT1, @@ -100,7 +99,7 @@ async def test_reschedule_match( == SUCCESS_RESPONSE ) match = await sql_get_match(match_inserted.id) - await assert_row_count_and_clear(matches, 0) + await assert_row_count_and_clear("matches", 0) assert match.court_id == body.new_court_id assert match.position_in_schedule == 0 diff --git a/backend/tests/integration_tests/api/rounds_test.py b/backend/tests/integration_tests/api/rounds_test.py index fe7b8e30..3bf1011c 100644 --- a/backend/tests/integration_tests/api/rounds_test.py +++ b/backend/tests/integration_tests/api/rounds_test.py @@ -3,7 +3,6 @@ import pytest from bracket.database import database from bracket.models.db.round import Round from bracket.models.db.stage_item import StageType -from bracket.schema import rounds from bracket.utils.db import fetch_one_parsed_certain from bracket.utils.dummy_records import DUMMY_ROUND1, DUMMY_STAGE1, DUMMY_STAGE_ITEM1, DUMMY_TEAM1 from bracket.utils.http import HTTPMethod @@ -46,7 +45,7 @@ async def test_create_round( ) == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(rounds, 1) + await assert_row_count_and_clear("rounds", 1) @pytest.mark.asyncio(loop_scope="session") @@ -73,7 +72,7 @@ async def test_delete_round( ) == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(rounds, 0) + await assert_row_count_and_clear("rounds", 0) @pytest.mark.asyncio(loop_scope="session") @@ -102,9 +101,9 @@ async def test_update_round( == SUCCESS_RESPONSE ) updated_round = await fetch_one_parsed_certain( - database, Round, query=rounds.select().where(rounds.c.id == round_inserted.id) + database, Round, "SELECT * FROM rounds WHERE id = :id", {"id": round_inserted.id} ) assert updated_round.name == body["name"] assert updated_round.is_draft == body["is_draft"] - await assert_row_count_and_clear(rounds, 1) + await assert_row_count_and_clear("rounds", 1) diff --git a/backend/tests/integration_tests/api/stage_items_test.py b/backend/tests/integration_tests/api/stage_items_test.py index 79873e2f..ab37bbc4 100644 --- a/backend/tests/integration_tests/api/stage_items_test.py +++ b/backend/tests/integration_tests/api/stage_items_test.py @@ -2,7 +2,6 @@ import pytest from bracket.models.db.stage_item import StageType from bracket.models.db.stage_item_inputs import StageItemInputCreateBodyFinal -from bracket.schema import matches, rounds, stage_items, stages from bracket.sql.stage_items import get_stage_item from bracket.utils.dummy_records import ( DUMMY_STAGE1, @@ -58,10 +57,10 @@ async def test_create_stage_item( ) == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(matches, 1) - await assert_row_count_and_clear(rounds, 1) - await assert_row_count_and_clear(stage_items, 1) - await assert_row_count_and_clear(stages, 1) + await assert_row_count_and_clear("matches", 1) + await assert_row_count_and_clear("rounds", 1) + await assert_row_count_and_clear("stage_items", 1) + await assert_row_count_and_clear("stages", 1) @pytest.mark.asyncio(loop_scope="session") @@ -85,7 +84,7 @@ async def test_delete_stage_item( ) == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(stages, 0) + await assert_row_count_and_clear("stages", 0) @pytest.mark.asyncio(loop_scope="session") diff --git a/backend/tests/integration_tests/api/stages_test.py b/backend/tests/integration_tests/api/stages_test.py index 2433fcb9..d928fbdb 100644 --- a/backend/tests/integration_tests/api/stages_test.py +++ b/backend/tests/integration_tests/api/stages_test.py @@ -1,6 +1,5 @@ import pytest -from bracket.schema import rounds, stage_items, stages from bracket.sql.stages import get_full_tournament_details from bracket.utils.dummy_records import ( DUMMY_MOCK_TIME, @@ -103,9 +102,9 @@ async def test_create_stage( ) == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(rounds, 1) - await assert_row_count_and_clear(stage_items, 1) - await assert_row_count_and_clear(stages, 1) + await assert_row_count_and_clear("rounds", 1) + await assert_row_count_and_clear("stage_items", 1) + await assert_row_count_and_clear("stages", 1) @pytest.mark.asyncio(loop_scope="session") @@ -124,7 +123,7 @@ async def test_delete_stage( ) == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(stages, 0) + await assert_row_count_and_clear("stages", 0) @pytest.mark.asyncio(loop_scope="session") @@ -153,8 +152,8 @@ async def test_update_stage( assert len(updated_stage.stage_items) == 1 assert updated_stage.name == body["name"] - await assert_row_count_and_clear(stage_items, 1) - await assert_row_count_and_clear(stages, 1) + await assert_row_count_and_clear("stage_items", 1) + await assert_row_count_and_clear("stages", 1) @pytest.mark.asyncio(loop_scope="session") @@ -177,8 +176,8 @@ async def test_activate_stage( == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(stage_items, 1) - await assert_row_count_and_clear(stages, 1) + await assert_row_count_and_clear("stage_items", 1) + await assert_row_count_and_clear("stages", 1) @pytest.mark.asyncio(loop_scope="session") diff --git a/backend/tests/integration_tests/api/teams_test.py b/backend/tests/integration_tests/api/teams_test.py index a55a30da..9713822f 100644 --- a/backend/tests/integration_tests/api/teams_test.py +++ b/backend/tests/integration_tests/api/teams_test.py @@ -4,7 +4,6 @@ import pytest from bracket.database import database from bracket.models.db.team import Team -from bracket.schema import players, teams from bracket.utils.db import fetch_one_parsed_certain from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_TEAM1 from bracket.utils.http import HTTPMethod @@ -50,7 +49,7 @@ async def test_create_team( body = {"name": "Some new name", "active": True, "player_ids": []} response = await send_tournament_request(HTTPMethod.POST, "teams", auth_context, None, body) assert response["data"]["name"] == body["name"] - await assert_row_count_and_clear(teams, 1) + await assert_row_count_and_clear("teams", 1) @pytest.mark.asyncio(loop_scope="session") @@ -62,8 +61,8 @@ async def test_create_teams( HTTPMethod.POST, "teams_multi", auth_context, None, body ) assert response["success"] is True - await assert_row_count_and_clear(teams, 2) - await assert_row_count_and_clear(players, 3) + await assert_row_count_and_clear("teams", 2) + await assert_row_count_and_clear("players", 3) @pytest.mark.asyncio(loop_scope="session") @@ -79,7 +78,7 @@ async def test_delete_team( ) == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(teams, 0) + await assert_row_count_and_clear("teams", 0) @pytest.mark.asyncio(loop_scope="session") @@ -94,12 +93,12 @@ async def test_update_team( HTTPMethod.PUT, f"teams/{team_inserted.id}", auth_context, None, body ) updated_team = await fetch_one_parsed_certain( - database, Team, query=teams.select().where(teams.c.id == team_inserted.id) + database, Team, "SELECT * FROM teams WHERE id = :id", {"id": team_inserted.id} ) assert updated_team.name == body["name"] assert response["data"]["name"] == body["name"] - await assert_row_count_and_clear(teams, 1) + await assert_row_count_and_clear("teams", 1) @pytest.mark.asyncio(loop_scope="session") diff --git a/backend/tests/integration_tests/api/tournaments_test.py b/backend/tests/integration_tests/api/tournaments_test.py index 6ee6a27f..80476997 100644 --- a/backend/tests/integration_tests/api/tournaments_test.py +++ b/backend/tests/integration_tests/api/tournaments_test.py @@ -6,7 +6,6 @@ import pytest from bracket.database import database from bracket.logic.tournaments import sql_delete_tournament_completely from bracket.models.db.tournament import Tournament, TournamentStatus -from bracket.schema import tournaments from bracket.sql.tournaments import sql_delete_tournament, sql_get_tournament_by_endpoint_name from bracket.utils.db import fetch_one_parsed_certain from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_TOURNAMENT @@ -138,7 +137,8 @@ async def test_update_tournament( updated_tournament = await fetch_one_parsed_certain( database, Tournament, - query=tournaments.select().where(tournaments.c.id == auth_context.tournament.id), + "SELECT * FROM tournaments WHERE id = :id", + {"id": auth_context.tournament.id}, ) assert updated_tournament.name == body["name"] assert updated_tournament.dashboard_public == body["dashboard_public"] @@ -148,13 +148,14 @@ async def test_update_tournament( async def test_archive_and_unarchive_tournament( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext ) -> None: - query = tournaments.select().where(tournaments.c.id == auth_context.tournament.id) + query = "SELECT * FROM tournaments WHERE id = :id" + values = {"id": auth_context.tournament.id} body = {"status": "ARCHIVED"} assert ( await send_tournament_request(HTTPMethod.POST, "change-status", auth_context, json=body) == SUCCESS_RESPONSE ) - updated_tournament = await fetch_one_parsed_certain(database, Tournament, query) + updated_tournament = await fetch_one_parsed_certain(database, Tournament, query, values) assert updated_tournament.status is TournamentStatus.ARCHIVED assert updated_tournament.dashboard_public is False @@ -169,7 +170,7 @@ async def test_archive_and_unarchive_tournament( await send_tournament_request(HTTPMethod.POST, "change-status", auth_context, json=body) == SUCCESS_RESPONSE ) - updated_tournament = await fetch_one_parsed_certain(database, Tournament, query) + updated_tournament = await fetch_one_parsed_certain(database, Tournament, query, values) assert updated_tournament.status is TournamentStatus.OPEN assert updated_tournament.dashboard_public is False diff --git a/backend/tests/integration_tests/api/users_test.py b/backend/tests/integration_tests/api/users_test.py index 4ea000ed..90545a0f 100644 --- a/backend/tests/integration_tests/api/users_test.py +++ b/backend/tests/integration_tests/api/users_test.py @@ -7,7 +7,6 @@ from heliclockter import datetime_utc from bracket.database import database from bracket.models.db.account import UserAccountType from bracket.models.db.user import User, UserInsertable -from bracket.schema import users from bracket.sql.users import create_user, delete_user, get_user_by_id from bracket.utils.db import fetch_one_parsed_certain from bracket.utils.http import HTTPMethod @@ -118,7 +117,7 @@ async def test_update_user_password( json=body, ) updated_user = await fetch_one_parsed_certain( - database, User, query=users.select().where(users.c.id == user_created.id) + database, User, "SELECT * FROM users WHERE id = :id", {"id": user_created.id} ) assert response.get("success") is True, response diff --git a/backend/tests/integration_tests/conftest.py b/backend/tests/integration_tests/conftest.py index a2e22f17..239b91db 100644 --- a/backend/tests/integration_tests/conftest.py +++ b/backend/tests/integration_tests/conftest.py @@ -5,16 +5,15 @@ from time import sleep import pytest import pytest_asyncio -from databases import Database -from bracket.database import database, engine -from bracket.schema import metadata +from bracket.database import DatabasePool, database +from bracket.utils.db_init import _create_all_tables, _drop_all_tables from tests.integration_tests.models import AuthContext from tests.integration_tests.sql import inserted_auth_context @pytest_asyncio.fixture(scope="session", autouse=True) -async def reinit_database(worker_id: str) -> AsyncIterator[Database]: +async def reinit_database(worker_id: str) -> AsyncIterator[DatabasePool]: """ Creates the test database on the first test run in the session. @@ -25,8 +24,8 @@ async def reinit_database(worker_id: str) -> AsyncIterator[Database]: await database.connect() if worker_id == "master": - metadata.drop_all(engine) - metadata.create_all(engine) + _drop_all_tables() + _create_all_tables() try: yield database @@ -42,8 +41,8 @@ async def reinit_database(worker_id: str) -> AsyncIterator[Database]: with open(lock_path, mode="w") as file: file.write("") - metadata.drop_all(engine) - metadata.create_all(engine) + _drop_all_tables() + _create_all_tables() finally: os.remove(lock_path) else: @@ -59,6 +58,6 @@ async def reinit_database(worker_id: str) -> AsyncIterator[Database]: @pytest.fixture(scope="session") -async def auth_context(reinit_database: Database) -> AsyncIterator[AuthContext]: +async def auth_context(reinit_database: DatabasePool) -> AsyncIterator[AuthContext]: async with reinit_database, inserted_auth_context() as auth_context: yield auth_context diff --git a/backend/tests/integration_tests/sql.py b/backend/tests/integration_tests/sql.py index a8ebe578..6197a37a 100644 --- a/backend/tests/integration_tests/sql.py +++ b/backend/tests/integration_tests/sql.py @@ -3,7 +3,6 @@ from contextlib import asynccontextmanager from typing import cast from pydantic import BaseModel -from sqlalchemy import Table from bracket.database import database from bracket.models.db.club import Club, ClubInsertable @@ -25,22 +24,6 @@ from bracket.models.db.team import Team, TeamInsertable from bracket.models.db.tournament import Tournament, TournamentInsertable from bracket.models.db.user import UserBase, UserInDB from bracket.models.db.user_x_club import UserXClub, UserXClubInsertable, UserXClubRelation -from bracket.schema import ( - clubs, - courts, - matches, - players, - players_x_teams, - rankings, - rounds, - stage_item_inputs, - stage_items, - stages, - teams, - tournaments, - users, - users_x_clubs, -) from bracket.sql.teams import get_teams_by_id from bracket.utils.db import insert_generic from bracket.utils.dummy_records import DUMMY_CLUB, DUMMY_RANKING1, DUMMY_TOURNAMENT @@ -49,62 +32,66 @@ from tests.integration_tests.mocks import get_mock_token, get_mock_user from tests.integration_tests.models import AuthContext -async def assert_row_count_and_clear(table: Table, expected_rows: int) -> None: - # assert len(await database.fetch_all(query=table.select())) == expected_rows - await database.execute(query=table.delete()) +async def assert_row_count_and_clear(table_name: str, expected_rows: int) -> None: + await database.execute(query=f"DELETE FROM {table_name}") @asynccontextmanager async def inserted_generic[BaseModelT: BaseModel]( - data_model: BaseModelT, table: Table, return_type: type[BaseModelT] + data_model: BaseModelT, table_name: str, return_type: type[BaseModelT] ) -> AsyncIterator[BaseModelT]: - last_record_id, row_inserted = await insert_generic(database, data_model, table, return_type) + last_record_id, row_inserted = await insert_generic( + database, data_model, table_name, return_type + ) try: yield row_inserted finally: - await database.execute(query=table.delete().where(table.c.id == last_record_id)) + await database.execute( + query=f"DELETE FROM {table_name} WHERE id = :id", + values={"id": last_record_id}, + ) @asynccontextmanager async def inserted_user(user: UserBase) -> AsyncIterator[UserInDB]: - async with inserted_generic(user, users, UserInDB) as row_inserted: + async with inserted_generic(user, "users", UserInDB) as row_inserted: yield cast("UserInDB", row_inserted) @asynccontextmanager async def inserted_club(club: ClubInsertable) -> AsyncIterator[Club]: - async with inserted_generic(club, clubs, Club) as row_inserted: + async with inserted_generic(club, "clubs", Club) as row_inserted: yield cast("Club", row_inserted) @asynccontextmanager async def inserted_tournament(tournament: TournamentInsertable) -> AsyncIterator[Tournament]: - async with inserted_generic(tournament, tournaments, Tournament) as row_inserted: + async with inserted_generic(tournament, "tournaments", Tournament) as row_inserted: yield cast("Tournament", row_inserted) @asynccontextmanager async def inserted_team(team: TeamInsertable) -> AsyncIterator[Team]: - async with inserted_generic(team, teams, Team) as row_inserted: + async with inserted_generic(team, "teams", Team) as row_inserted: yield cast("Team", row_inserted) @asynccontextmanager async def inserted_court(court: CourtInsertable) -> AsyncIterator[Court]: - async with inserted_generic(court, courts, Court) as row_inserted: + async with inserted_generic(court, "courts", Court) as row_inserted: yield cast("Court", row_inserted) @asynccontextmanager async def inserted_ranking(ranking: RankingInsertable) -> AsyncIterator[Ranking]: - async with inserted_generic(ranking, rankings, Ranking) as row_inserted: + async with inserted_generic(ranking, "rankings", Ranking) as row_inserted: yield cast("Ranking", row_inserted) @asynccontextmanager async def inserted_player(player: PlayerInsertable) -> AsyncIterator[Player]: - async with inserted_generic(player, players, Player) as row_inserted: + async with inserted_generic(player, "players", Player) as row_inserted: yield cast("Player", row_inserted) @@ -112,10 +99,10 @@ async def inserted_player(player: PlayerInsertable) -> AsyncIterator[Player]: async def inserted_player_in_team( player: PlayerInsertable, team_id: TeamId ) -> AsyncIterator[Player]: - async with inserted_generic(player, players, Player) as row_inserted: + async with inserted_generic(player, "players", Player) as row_inserted: async with inserted_generic( PlayerXTeamInsertable(player_id=cast("Player", row_inserted).id, team_id=team_id), - players_x_teams, + "players_x_teams", PlayerXTeamInsertable, ): yield cast("Player", row_inserted) @@ -123,13 +110,13 @@ async def inserted_player_in_team( @asynccontextmanager async def inserted_stage(stage: StageInsertable) -> AsyncIterator[Stage]: - async with inserted_generic(stage, stages, Stage) as row_inserted: + async with inserted_generic(stage, "stages", Stage) as row_inserted: yield cast("Stage", row_inserted) @asynccontextmanager async def inserted_stage_item(stage_item: StageItemInsertable) -> AsyncIterator[StageItem]: - async with inserted_generic(stage_item, stage_items, StageItem) as row_inserted: + async with inserted_generic(stage_item, "stage_items", StageItem) as row_inserted: yield StageItem(**row_inserted.model_dump()) @@ -139,7 +126,7 @@ async def inserted_stage_item_input( ) -> AsyncIterator[StageItemInputFinal | StageItemInputEmpty]: async with inserted_generic( stage_item_input, - stage_item_inputs, + "stage_item_inputs", StageItemInputBase, # pyrefly: ignore[bad-argument-type] ) as row_inserted: if stage_item_input.team_id is not None: @@ -155,19 +142,19 @@ async def inserted_stage_item_input( @asynccontextmanager async def inserted_round(round_: RoundInsertable) -> AsyncIterator[Round]: - async with inserted_generic(round_, rounds, Round) as row_inserted: + async with inserted_generic(round_, "rounds", Round) as row_inserted: yield cast("Round", row_inserted) @asynccontextmanager async def inserted_match(match: MatchInsertable) -> AsyncIterator[Match]: - async with inserted_generic(match, matches, Match) as row_inserted: + async with inserted_generic(match, "matches", Match) as row_inserted: yield cast("Match", row_inserted) @asynccontextmanager async def inserted_user_x_club(user_x_club: UserXClubInsertable) -> AsyncIterator[UserXClub]: - async with inserted_generic(user_x_club, users_x_clubs, UserXClub) as row_inserted: + async with inserted_generic(user_x_club, "users_x_clubs", UserXClub) as row_inserted: yield cast("UserXClub", row_inserted) @@ -192,7 +179,7 @@ async def inserted_auth_context() -> AsyncIterator[AuthContext]: ) as user_x_club_inserted, ): yield AuthContext( - headers={"Authorization": f"Bearer {get_mock_token(mock_user.email)}"}, + headers={"Authorization": f"******"}, user=user_inserted, club=club_inserted, tournament=tournament_inserted,