Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/modules/api/src/games.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,19 @@ pub async fn list_games(
let limit = query.limit.unwrap_or(10);
let cursor = query.cursor.clone();

// Map result_side string to enum if needed, or pass as string for service to handle.
// For simplicity, we pass Option<String> filters to service.

match GameService::list_games(
db.get_ref(),
cursor,
limit,
query.player_id,
query.opponent_id,
query.variant.clone(),
query.result_side.clone(),
query.from_date,
query.to_date,
status_enum,
)
.await
Expand Down
2 changes: 2 additions & 0 deletions backend/modules/db/migrations/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod m20250605_090000_add_game_search_indexes;
mod m20260127_create_refresh_tokens_table;
mod m20260127_180000_add_game_imported_flag;
mod m20250324_add_elo_rating_to_player;
mod m20260428_100000_add_historical_search_indexes;


pub struct Migrator;
Expand All @@ -26,6 +27,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260127_create_refresh_tokens_table::Migration),
Box::new(m20260127_180000_add_game_imported_flag::Migration),
Box::new(m20250324_add_elo_rating_to_player::Migration),
Box::new(m20260428_100000_add_historical_search_indexes::Migration),
]
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 1. Index on `result` column for fast filtering of game outcomes
manager
.create_index(
Index::create()
.name("idx_games_result")
.table((Smdb, Game::Table))
.col(Game::Result)
.to_owned(),
)
.await?;

// 2. Composite index for head-to-head history: (white_player, black_player, created_at DESC)
// This is extremely useful for searching games between two specific players.
manager
.get_connection()
.execute_unprepared(
r#"CREATE INDEX "idx_games_head_to_head" ON "smdb"."game" ("white_player", "black_player", "created_at" DESC)"#
)
.await?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 2b. Mirrored composite index for head-to-head history: (black_player, white_player, created_at DESC)
// This ensures queries in both color directions are fully optimized.
manager
.get_connection()
.execute_unprepared(
r#"CREATE INDEX "idx_games_head_to_head_mirrored" ON "smdb"."game" ("black_player", "white_player", "created_at" DESC)"#
)
.await?;

// 3. Index on `variant` for variant-specific historical search
manager
.create_index(
Index::create()
.name("idx_games_variant_search")
.table((Smdb, Game::Table))
.col(Game::Variant)
.to_owned(),
)
.await?;

Ok(())
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_index(Index::drop().name("idx_games_result").table((Smdb, Game::Table)).to_owned())
.await?;

manager
.get_connection()
.execute_unprepared(r#"DROP INDEX IF EXISTS "smdb"."idx_games_head_to_head""#)
.await?;

manager
.get_connection()
.execute_unprepared(r#"DROP INDEX IF EXISTS "smdb"."idx_games_head_to_head_mirrored""#)
.await?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

manager
.drop_index(Index::drop().name("idx_games_variant_search").table((Smdb, Game::Table)).to_owned())
.await?;

Ok(())
}
}

#[derive(DeriveIden)]
enum Game {
Table,
Result,
WhitePlayer,
BlackPlayer,
Variant,
CreatedAt,
}

#[derive(DeriveIden)]
struct Smdb;
15 changes: 15 additions & 0 deletions backend/modules/dto/src/games.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ pub struct ListGamesQuery {

#[schema(value_type = Option<String>, format = "uuid", example = "123e4567-e89b-12d3-a456-426614174000")]
pub player_id: Option<Uuid>,

#[schema(value_type = Option<String>, format = "uuid", example = "123e4567-e89b-12d3-a456-426614174001")]
pub opponent_id: Option<Uuid>,

#[schema(example = "standard")]
pub variant: Option<String>,

#[schema(example = "white_wins")]
pub result_side: Option<String>,

#[schema(value_type = Option<String>, format = "date-time")]
pub from_date: Option<DateTime<Utc>>,

#[schema(value_type = Option<String>, format = "date-time")]
pub to_date: Option<DateTime<Utc>>,

#[schema(default = 1, example = 1)]
/// Deprecated: Use cursor-based pagination
Expand Down
116 changes: 86 additions & 30 deletions backend/modules/service/src/games.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,22 +170,90 @@ impl GameService {
cursor: Option<String>,
limit: u64,
player_id: Option<Uuid>,
opponent_id: Option<Uuid>,
variant: Option<String>,
result_side: Option<String>,
from_date: Option<DateTime<Utc>>,
to_date: Option<DateTime<Utc>>,
status: Option<GameStatus>,
) -> Result<(Vec<game::Model>, Option<String>), DbErr> {
let mut query = Game::find();

// 1. Apply Filtering
if let Some(pid) = player_id {
// Filter by player (white OR black)
// effective union of indexes logic would be nice, but OR is simpler to write here.
// "idx_games_white_player_created_at_id" and "idx_games_black_player_created_at_id"
// Postgres creates a BitmapOr for these two indexes usually.
if let Some(oid) = opponent_id {
// Filter by head-to-head (me vs opponent OR opponent vs me)
let condition = Condition::any()
.add(
Condition::all()
.add(game::Column::WhitePlayer.eq(pid))
.add(game::Column::BlackPlayer.eq(oid))
)
.add(
Condition::all()
.add(game::Column::WhitePlayer.eq(oid))
.add(game::Column::BlackPlayer.eq(pid))
);
query = query.filter(condition);
} else {
// Filter by player (white OR black)
let condition = Condition::any()
.add(game::Column::WhitePlayer.eq(pid))
.add(game::Column::BlackPlayer.eq(pid));
query = query.filter(condition);
}
} else if let Some(oid) = opponent_id {
// Filter by opponent only (either white or black)
let condition = Condition::any()
.add(game::Column::WhitePlayer.eq(pid))
.add(game::Column::BlackPlayer.eq(pid));
.add(game::Column::WhitePlayer.eq(oid))
.add(game::Column::BlackPlayer.eq(oid));
query = query.filter(condition);
}

if let Some(v) = variant {
// Map variant string to enum
let v_enum = match v.to_lowercase().as_str() {
"standard" => db_entity::game::GameVariant::Standard,
"chess960" => db_entity::game::GameVariant::Chess960,
"three_check" => db_entity::game::GameVariant::ThreeCheck,
"blitz" => db_entity::game::GameVariant::Blitz,
"rapid" => db_entity::game::GameVariant::Rapid,
"classical" => db_entity::game::GameVariant::Classical,
_ => return Err(DbErr::Custom(format!("Invalid game variant: {}", v))),
};
query = query.filter(game::Column::Variant.eq(v_enum));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if let Some(r) = result_side {
// Map result string to enum or filter by NULL for ongoing
match r.to_lowercase().as_str() {
"white_wins" => {
query = query.filter(game::Column::Result.eq(db_entity::game::ResultSide::WhiteWins));
}
"black_wins" => {
query = query.filter(game::Column::Result.eq(db_entity::game::ResultSide::BlackWins));
}
"draw" => {
query = query.filter(game::Column::Result.eq(db_entity::game::ResultSide::Draw));
}
"abandoned" => {
query = query.filter(game::Column::Result.eq(db_entity::game::ResultSide::Abandoned));
}
"ongoing" => {
query = query.filter(game::Column::Result.is_null());
}
_ => return Err(DbErr::Custom(format!("Invalid result side: {}", r))),
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if let Some(from) = from_date {
query = query.filter(game::Column::CreatedAt.gte(from));
}

if let Some(to) = to_date {
query = query.filter(game::Column::CreatedAt.lte(to));
}

if let Some(s) = status {
match s {
GameStatus::Waiting | GameStatus::InProgress => {
Expand All @@ -209,28 +277,6 @@ impl GameService {

if let Some(cursor_str) = cursor {
if let Ok((last_created_at, last_id)) = Self::decode_cursor(&cursor_str) {
// created_at < last_created_at OR (created_at = last_created_at AND id < last_id)
// SeaORM tuple comparison: (col1, col2) < (val1, val2)
// query = query.filter(
// Condition::any()
// .add(game::Column::CreatedAt.lt(last_created_at))
// .add(
// Condition::all()
// .add(game::Column::CreatedAt.eq(last_created_at))
// .add(game::Column::Id.lt(last_id))
// )
// );
// Actually, SeaORM supports tuple comparison conveniently?
// Not directly in the builder API widely in all versions, but the composite condition above is correct for (A, B) < (a, b) logic.
// However, tuple comparison `(A, B) < (a, b)` logic is standard SQL but SeaORM DSL is explicit.

// Constructing: (created_at, id) < (last_created_at, last_id)
// Equivalent to: created_at < last_created_at OR (created_at = last_created_at AND id < last_id) (for DESC, DESC)
// WAIT! For DESC sort, "next page" means values SMALLER than cursor?
// Yes. Sorting DESC means newest first. Cursor is at some point. We want older stuff.
// So we want `created_at < cursor.created_at`.
// If created_at == cursor.created_at, then `id < cursor.id` (assuming ID also DESC).

let condition = Condition::any()
.add(game::Column::CreatedAt.lt(last_created_at))
.add(
Expand Down Expand Up @@ -345,7 +391,12 @@ mod tests {
None,
10,
Some(player_id),
None
None,
None,
None,
None,
None,
None,
).await;

// Get transaction log to verify SQL
Expand Down Expand Up @@ -397,7 +448,12 @@ mod tests {
Some(cursor),
10,
None,
None
None,
None,
None,
None,
None,
None,
).await;

let transaction_log = db.into_transaction_log();
Expand Down
Loading