Replace databases dependency with asyncpg, remove SQLAlchemy except schema.py

- 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
This commit is contained in:
copilot-swe-agent[bot]
2026-07-28 19:00:52 +00:00
committed by GitHub
parent 3606868b98
commit 4835aa7311
34 changed files with 396 additions and 270 deletions

View File

@@ -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))

View File

@@ -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(

View File

@@ -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},
)
)
)

View File

@@ -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},
)
)
)

View File

@@ -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)

View File

@@ -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))

View File

@@ -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:

View File

@@ -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]

View File

@@ -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:

View File

@@ -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(

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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))

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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:

View File

@@ -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:

View File

@@ -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__}")

View File

@@ -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](

View File

@@ -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',

View File

@@ -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)

View File

@@ -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")

View File

@@ -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)

View File

@@ -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")

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,