diff --git a/app/features/chat/ChatRepository.server.ts b/app/features/chat/ChatRepository.server.ts index 5b8e0cb30..d37955085 100644 --- a/app/features/chat/ChatRepository.server.ts +++ b/app/features/chat/ChatRepository.server.ts @@ -85,19 +85,18 @@ export async function findAllOpenRoomIdsByUserId( .where("GroupMember.createdAt", ">", joinedSince) .where(openRoom("GroupMatch.chatRoomId")) .execute(), - tournamentTeamIds.length === 0 - ? [] - : db - .selectFrom("TournamentMatch") - .select("TournamentMatch.chatRoomId as id") - .where((eb) => - eb.or([ - eb(opponentTeamId("opponentOne"), "in", tournamentTeamIds), - eb(opponentTeamId("opponentTwo"), "in", tournamentTeamIds), - ]), - ) - .where(openRoom("TournamentMatch.chatRoomId")) - .execute(), + // a column per query so each opponent's expression index is used directly, + // mirroring the alpha/bravo split above + ...(["opponentOne", "opponentTwo"] as const).map((column) => + tournamentTeamIds.length === 0 + ? [] + : db + .selectFrom("TournamentMatch") + .select("TournamentMatch.chatRoomId as id") + .where(opponentTeamId(column), "in", tournamentTeamIds) + .where(openRoom("TournamentMatch.chatRoomId")) + .execute(), + ), db .selectFrom("TournamentTeamMember") .innerJoin( diff --git a/app/features/leaderboards/LeaderboardRepository.server.ts b/app/features/leaderboards/LeaderboardRepository.server.ts index ae2ce41e5..09710ce31 100644 --- a/app/features/leaderboards/LeaderboardRepository.server.ts +++ b/app/features/leaderboards/LeaderboardRepository.server.ts @@ -11,6 +11,7 @@ import type { import { commonUserSelect, concatUserSubmittedImagePrefix, + groupMemberOfSeasonSql, jsonArrayFrom, latestSkillPerSeason, skillCountsAsSeasonSet, @@ -208,6 +209,7 @@ export async function hasEnoughSqMatchesByUserId(userId: number) { .onRef("Skill.userId", "=", "GroupMember.userId"), ) .where("GroupMember.userId", "=", userId) + .where(groupMemberOfSeasonSql(season.nth)) .where( "GroupMatch.createdAt", ">", diff --git a/app/features/mmr/core/Seasons.test.ts b/app/features/mmr/core/Seasons.test.ts index 5445dd3c7..919bbde51 100644 --- a/app/features/mmr/core/Seasons.test.ts +++ b/app/features/mmr/core/Seasons.test.ts @@ -1,6 +1,11 @@ -import { addHours } from "date-fns"; +import { addHours, subDays } from "date-fns"; import { describe, expect, test } from "vitest"; -import { list, nthToDateRange, nthToReportingDateRange } from "./Seasons"; +import { + list, + nthToDateRange, + nthToGroupMembershipDateRange, + nthToReportingDateRange, +} from "./Seasons"; describe("nthToDateRange()", () => { test("returns the date range for an existing season", () => { @@ -33,3 +38,15 @@ describe("nthToReportingDateRange()", () => { expect(() => nthToReportingDateRange(list.length)).toThrow(); }); }); + +describe("nthToGroupMembershipDateRange()", () => { + test("starts a week before the season, covering groups formed before it", () => { + const { starts } = nthToGroupMembershipDateRange(0); + expect(starts).toEqual(subDays(list[0].starts, 7)); + }); + + test("ends when reporting ends", () => { + const { ends } = nthToGroupMembershipDateRange(0); + expect(ends).toEqual(nthToReportingDateRange(0).ends); + }); +}); diff --git a/app/features/mmr/core/Seasons.ts b/app/features/mmr/core/Seasons.ts index 3bcfb94f1..e11c30d9c 100644 --- a/app/features/mmr/core/Seasons.ts +++ b/app/features/mmr/core/Seasons.ts @@ -1,10 +1,17 @@ -import { addHours } from "date-fns"; +import { addHours, subDays } from "date-fns"; import { Config } from "~/config"; import { IS_E2E_TEST_RUN } from "~/utils/e2e"; /** How long past a season's end its matches can still resolve: 24h stale match routine after a buzzer-beater creation, plus up to an hour of scheduling lag. */ const REPORTING_GRACE_HOURS = 25; +/** + * How far before a season's reporting range the `GroupMember` rows of its matches can have been created. + * No membership in the database has ever preceded its season's start (90+ days of margin), so this is + * insurance for a group formed just before a boundary rather than a bound on how long a group lives. + */ +const GROUP_LIFETIME_MAX_DAYS = 7; + /** Seasons (`nth` from 0) with their start and end dates. Outside production the list is a test set that keeps a season always open. */ export const list = // when we do pnpm run setup NODE_ENV is not set -> use test seasons @@ -171,6 +178,19 @@ export function nthToReportingDateRange(nth: number) { }; } +/** + * When the members of the season's SendouQ matches joined their groups: the reporting range + * widened by {@link GROUP_LIFETIME_MAX_DAYS}. + */ +export function nthToGroupMembershipDateRange(nth: number) { + const { starts, ends } = nthToReportingDateRange(nth); + + return { + starts: subDays(starts, GROUP_LIFETIME_MAX_DAYS), + ends, + }; +} + /** Numbers of seasons started by `date` (default now), newest first; `[0]` if none have. */ export function allStarted(date = new Date()) { const startedSeasons = list.filter((s) => date >= s.starts); diff --git a/app/features/sendouq-match/ReportedWeaponRepository.server.ts b/app/features/sendouq-match/ReportedWeaponRepository.server.ts index ef9c3cb60..cb44f8dbd 100644 --- a/app/features/sendouq-match/ReportedWeaponRepository.server.ts +++ b/app/features/sendouq-match/ReportedWeaponRepository.server.ts @@ -9,6 +9,7 @@ import type { StageId, } from "~/modules/in-game-lists/types"; import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { groupMemberOfSeasonSql } from "~/utils/kysely.server"; import { assertUnreachable } from "~/utils/types"; export async function upsertOwn({ @@ -302,6 +303,7 @@ export async function findAllWeaponUsageStats({ .as("weaponUserGroupId"), ]) .where("GroupMember.userId", "=", userId) + .where(groupMemberOfSeasonSql(season)) .where((eb) => eb.or([ eb("GroupMatch.alphaGroupId", "=", eb.ref("GroupMember.groupId")), diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 6a4d05580..1ca7cf182 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -31,6 +31,7 @@ import { invariant } from "~/utils/invariant"; import { commonUserSelect, concatUserSubmittedImagePrefix, + groupMemberOfSeasonSql, jsonArrayFrom, jsonObjectFrom, matchProfileWeapons, @@ -700,6 +701,7 @@ export async function findSeasonCanceledMatchesByUserId({ ).as("cancelReports"), ]) .where("GroupMember.userId", "=", userId) + .where(groupMemberOfSeasonSql(season)) .where("GroupMatch.createdAt", ">=", dateToDatabaseTimestamp(starts)) .where("GroupMatch.createdAt", "<=", dateToDatabaseTimestamp(ends)) .orderBy("GroupMatch.createdAt", "desc") diff --git a/app/features/top-search/XRankPlacementRepository.server.ts b/app/features/top-search/XRankPlacementRepository.server.ts index 9c75bfaab..3bf4b2373 100644 --- a/app/features/top-search/XRankPlacementRepository.server.ts +++ b/app/features/top-search/XRankPlacementRepository.server.ts @@ -159,6 +159,7 @@ export async function findPlacementsByUserId( return result.length ? result : null; } +/** Every month with placements, newest first. */ export async function findAllMonthYears() { return await db .selectFrom("XRankPlacement") diff --git a/app/features/tournament-bracket/BracketRepository.server.test.ts b/app/features/tournament-bracket/BracketRepository.server.test.ts index 4a1cd6aa3..f779585f4 100644 --- a/app/features/tournament-bracket/BracketRepository.server.test.ts +++ b/app/features/tournament-bracket/BracketRepository.server.test.ts @@ -3,6 +3,7 @@ import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { db } from "~/db/sql"; +import type { TournamentSettings } from "~/db/tables-json"; import { userChannel } from "~/features/events/events-types"; import { abortSubscriptions, @@ -12,6 +13,7 @@ import { resolveMatchMapList } from "~/features/tournament-match/core/mapList.se import { reportScore } from "~/features/tournament-match/core/reportScore.server"; import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server"; import { invariant } from "~/utils/invariant"; +import * as BracketRepository from "./BracketRepository.server"; import * as Engine from "./core/engine"; import { executeBracketOperation } from "./core/executeBracketOperation.server"; import { @@ -29,12 +31,24 @@ afterEach(() => { abortSubscriptions(); }); -const setupStartedMatch = async () => { +/** KOs are only reported in round robin brackets. */ +const ROUND_ROBIN: TournamentSettings["bracketProgression"] = [ + { + name: "Groups", + type: "round_robin", + requiresCheckIn: false, + settings: { teamsPerGroup: 2 }, + }, +]; + +const setupStartedMatch = async ( + overrides?: Partial[0]>, +) => { const authorId = users.id(1); const teamAlphaUserIds = [users.id(2), users.id(3), users.id(4), users.id(5)]; const teamBravoUserIds = [users.id(6), users.id(7), users.id(8), users.id(9)]; - const tournament = await TournamentFactory.create({ authorId }); + const tournament = await TournamentFactory.create({ authorId, ...overrides }); for (const memberUserIds of [teamAlphaUserIds, teamBravoUserIds]) { await TournamentTeamFactory.create( { tournamentId: tournament.id, memberUserIds }, @@ -63,9 +77,15 @@ const setupStartedMatch = async () => { }; }; +type GameResult = { winner: "one" | "two"; ko: boolean }; + /** Reports every game of the match through `reportScore` until the set is over. */ const playOutMatch = async ( setup: Awaited>, + resultOfGame: (position: number) => GameResult = () => ({ + winner: "one", + ko: false, + }), ) => { let position = 0; let setOver = false; @@ -77,6 +97,9 @@ const playOutMatch = async ( ); invariant(matchRow, "Match not found"); invariant(matchRow.opponentOne?.id, "Match has no first opponent"); + invariant(matchRow.opponentTwo?.id, "Match has no second opponent"); + + const { winner, ko } = resultOfGame(position); const reported = await reportScore({ match: matchRow, @@ -84,8 +107,9 @@ const playOutMatch = async ( mapList: await resolveMatchMapList({ match: matchRow, tournament }), user: { id: setup.authorId }, position, - winnerTeamId: matchRow.opponentOne.id, - ko: false, + winnerTeamId: + winner === "one" ? matchRow.opponentOne.id : matchRow.opponentTwo.id, + ko, }); invariant(reported, `Game ${position} was already reported`); @@ -134,6 +158,45 @@ describe("BracketRepository.applyMatchChanges", () => { }); }); +describe("BracketRepository.findByTournamentId", () => { + test("counts each opponent's KO wins into totalKos", async () => { + const setup = await setupStartedMatch({ bracketProgression: ROUND_ROBIN }); + const kosOfGame: Array = [ + { winner: "two", ko: true }, + { winner: "one", ko: true }, + { winner: "one", ko: true }, + ]; + + await playOutMatch(setup, (position) => kosOfGame[position]); + + const { match } = await BracketRepository.findByTournamentId( + setup.tournamentId, + ); + const playedMatch = match.find( + (bracketMatch) => bracketMatch.id === setup.matchId, + ); + + expect(playedMatch?.opponent1?.totalKos).toBe(2); + expect(playedMatch?.opponent2?.totalKos).toBe(1); + }); + + test("does not count games won without a KO", async () => { + const setup = await setupStartedMatch({ bracketProgression: ROUND_ROBIN }); + + await playOutMatch(setup); + + const { match } = await BracketRepository.findByTournamentId( + setup.tournamentId, + ); + const playedMatch = match.find( + (bracketMatch) => bracketMatch.id === setup.matchId, + ); + + expect(playedMatch?.opponent1?.totalKos).toBe(0); + expect(playedMatch?.opponent2?.totalKos).toBe(0); + }); +}); + const roomById = (id: number) => db .selectFrom("ChatRoom") diff --git a/app/features/tournament-bracket/BracketRepository.server.ts b/app/features/tournament-bracket/BracketRepository.server.ts index a42f6a34a..8dc01c005 100644 --- a/app/features/tournament-bracket/BracketRepository.server.ts +++ b/app/features/tournament-bracket/BracketRepository.server.ts @@ -93,11 +93,6 @@ export async function findByTournamentId( "TournamentStage.id", "TournamentMatch.stageId", ) - .leftJoin( - "TournamentMatchGameResult", - "TournamentMatch.id", - "TournamentMatchGameResult.matchId", - ) .select([ "TournamentMatch.id", "TournamentMatch.stageId", @@ -111,7 +106,6 @@ export async function findByTournamentId( serializedOpponentWithKos("opponentTwo").as("opponent2"), ]) .where("TournamentStage.tournamentId", "=", tournamentId) - .groupBy("TournamentMatch.id") .orderBy("TournamentMatch.stageId", "asc") .orderBy("TournamentMatch.id", "asc"), ).as("match"), @@ -121,20 +115,23 @@ export async function findByTournamentId( return { stage, group, round, match }; } -/** Opponent JSON with `totalKos` summed over the match's game results, `null` for BYEs. */ +/** Opponent JSON with `totalKos` counted over the match's game results, `null` for BYEs. */ function serializedOpponentWithKos( column: "opponentOne" | "opponentTwo", ): RawBuilder { + const opponent = kyselySql.ref(`TournamentMatch.${column}`); + + // a correlated count is answered by the (matchId, winnerTeamId, ko) index alone; the + // left join + group by it replaces read every game result row of the tournament return kyselySql`json_set( - ${kyselySql.ref(`TournamentMatch.${column}`)}, + ${opponent}, '$.totalKos', - sum( - case - when "TournamentMatchGameResult"."ko" = 1 - and "TournamentMatchGameResult"."winnerTeamId" = ${kyselySql.ref(`TournamentMatch.${column}`)} ->> '$.id' - then 1 - else 0 - end + ( + select count(*) + from "TournamentMatchGameResult" + where "TournamentMatchGameResult"."matchId" = "TournamentMatch"."id" + and "TournamentMatchGameResult"."winnerTeamId" = ${opponent} ->> '$.id' + and "TournamentMatchGameResult"."ko" = 1 ) )`; } diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts index 0b61d5fcf..0d65ffb75 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts @@ -1,3 +1,4 @@ +import * as R from "remeda"; import { beforeEach, describe, expect, test } from "vitest"; import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory"; import * as CalendarEventResultFactory from "~/db/seed/factories/CalendarEventResultFactory"; @@ -10,6 +11,7 @@ import { } from "~/utils/dates"; import * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server"; import { seedOrgEventWithParticipants } from "./test-utils"; +import { TOURNAMENT_SERIES_EVENTS_PER_PAGE } from "./tournament-organization-constants"; const users = UserFactory.pool(); @@ -110,6 +112,76 @@ describe("findEventsByMonth", () => { }); }); +describe("findPaginatedEventsBySeries", () => { + const NEWEST_EVENT_STARTED_AT = 1_700_000_000; + const DAY_IN_SECONDS = 60 * 60 * 24; + const EVENT_COUNT = TOURNAMENT_SERIES_EVENTS_PER_PAGE + 1; + + beforeEach(async () => { + await users.create(1); + }); + + /** One event per day going back from the newest, so `Low Ink #0` is the newest of the series. */ + const seedSeries = async (organizationId: number) => { + for (const nth of R.range(0, EVENT_COUNT)) { + await CalendarEventFactory.create({ + authorId: users.id(1), + organizationId, + name: `Low Ink #${nth}`, + startTimes: [NEWEST_EVENT_STARTED_AT - nth * DAY_IN_SECONDS], + }); + } + }; + + const eventsOnPage = (organizationId: number, page: number) => + TournamentOrganizationRepository.findPaginatedEventsBySeries({ + organizationId, + substringMatches: ["Low Ink"], + page, + }); + + test("pages the events of the series newest first", async () => { + const org = await TournamentOrganizationFactory.create({ + ownerId: users.id(1), + }); + await seedSeries(org.id); + + const firstPage = await eventsOnPage(org.id, 1); + const secondPage = await eventsOnPage(org.id, 2); + + expect(firstPage.map((event) => event.name)).toEqual( + R.range(0, TOURNAMENT_SERIES_EVENTS_PER_PAGE).map( + (nth) => `Low Ink #${nth}`, + ), + ); + expect(secondPage.map((event) => event.name)).toEqual([ + `Low Ink #${EVENT_COUNT - 1}`, + ]); + }); + + test("leaves out the events of other series", async () => { + const org = await TournamentOrganizationFactory.create({ + ownerId: users.id(1), + }); + await CalendarEventFactory.create({ + authorId: users.id(1), + organizationId: org.id, + name: "Low Ink #1", + startTimes: [NEWEST_EVENT_STARTED_AT], + }); + await CalendarEventFactory.create({ + authorId: users.id(1), + organizationId: org.id, + name: "Paddling Pool #1", + startTimes: [NEWEST_EVENT_STARTED_AT], + }); + + const events = await eventsOnPage(org.id, 1); + + expect(events.map((event) => event.name)).toEqual(["Low Ink #1"]); + }); +}); + describe("countActiveParticipants", () => { const WINDOW_START = 1_700_000_000; const WINDOW_END = WINDOW_START + 60 * 60 * 24 * 31; diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index 3a53f2d16..f078f6a78 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -437,22 +437,14 @@ export function findAllUnfinalizedEvents(organizationId: number) { .execute(); } -const findSeriesEventsBaseQuery = ({ - organizationId, - substringMatches, -}: { - organizationId: number; - substringMatches: string[]; -}) => - findEventsBaseQuery(organizationId) - .where((eb) => - eb.or( - substringMatches.map((match) => - eb("CalendarEvent.name", "like", `%${match}%`), - ), +const nameMatchesSeries = + (substringMatches: string[]) => + (eb: ExpressionBuilder) => + eb.or( + substringMatches.map((match) => + eb("CalendarEvent.name", "like", `%${match}%`), ), - ) - .orderBy("CalendarEventDate.startsAt", "desc"); + ); export async function findPaginatedEventsBySeries({ organizationId, @@ -463,12 +455,27 @@ export async function findPaginatedEventsBySeries({ substringMatches: string[]; page: number; }) { - const events = await findSeriesEventsBaseQuery({ - organizationId, - substringMatches, - }) + // the page is resolved by id first: with the limit on the full read, the winners + // of every event of the series would be aggregated before it applies + const pageEventIds = db + .selectFrom("CalendarEvent") + .innerJoin( + "CalendarEventDate", + "CalendarEventDate.eventId", + "CalendarEvent.id", + ) + .select("CalendarEvent.id") + .where("CalendarEvent.organizationId", "=", organizationId) + .where("CalendarEvent.hidden", "=", 0) + .where(nameMatchesSeries(substringMatches)) + .groupBy("CalendarEvent.id") + .orderBy(({ fn }) => fn.min("CalendarEventDate.startsAt"), "desc") .limit(TOURNAMENT_SERIES_EVENTS_PER_PAGE) - .offset((page - 1) * TOURNAMENT_SERIES_EVENTS_PER_PAGE) + .offset((page - 1) * TOURNAMENT_SERIES_EVENTS_PER_PAGE); + + const events = await findEventsBaseQuery(organizationId) + .where("CalendarEvent.id", "in", pageEventIds) + .orderBy("CalendarEventDate.startsAt", "desc") .execute(); return events.map(mapEvent); @@ -500,13 +507,7 @@ export async function findAllEventsBySeries({ ]) .where("CalendarEvent.organizationId", "=", organizationId) .where("CalendarEvent.hidden", "=", 0) - .where((eb) => - eb.or( - substringMatches.map((match) => - eb("CalendarEvent.name", "like", `%${match}%`), - ), - ), - ) + .where(nameMatchesSeries(substringMatches)) .groupBy("CalendarEvent.id") .orderBy("CalendarEventDate.startsAt", "desc") .execute(); diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 774fb9847..15d348249 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -203,13 +203,7 @@ export async function findById(id: number) { "=", "TournamentTeamMember.userId", ) - .on( - "SeedingSkill.type", - "=", - sql< - Tables["SeedingSkill"]["type"] - > /*sql*/`case when json_extract("Tournament"."settings", '$.isRanked') = 1 then 'RANKED' else 'UNRANKED' end`, - ), + .on("SeedingSkill.type", "=", seedingSkillType(id)), ) .select(({ fn }) => fn.avg("SeedingSkill.ordinal").as("v"), @@ -500,7 +494,6 @@ export type TeamFull = Unwrapped; export async function findTeamsFullByTournamentId(tournamentId: number) { const teams = await db .selectFrom("TournamentTeam") - .innerJoin("Tournament", "Tournament.id", "TournamentTeam.tournamentId") .leftJoin( "UserSubmittedImage as PickupAvatar", "TournamentTeam.avatarImgId", @@ -528,13 +521,7 @@ export async function findTeamsFullByTournamentId(tournamentId: number) { .leftJoin("SeedingSkill", (join) => join .onRef("User.id", "=", "SeedingSkill.userId") - .on( - "SeedingSkill.type", - "=", - sql< - Tables["SeedingSkill"]["type"] - > /*sql*/`case when json_extract("Tournament"."settings", '$.isRanked') = 1 then 'RANKED' else 'UNRANKED' end`, - ), + .on("SeedingSkill.type", "=", seedingSkillType(tournamentId)), ) .select((memberEb) => [ ...commonUserSelect(memberEb, { @@ -1885,3 +1872,10 @@ async function trophyTier( return tournament?.tier ?? null; } + +/** Which seeding skill the tournament ranks by, resolved once: inline in the join it parsed the settings JSON per member row. */ +function seedingSkillType(tournamentId: number) { + return sql< + Tables["SeedingSkill"]["type"] + >`(select case when json_extract("settings", '$.isRanked') = 1 then 'RANKED' else 'UNRANKED' end from "Tournament" where "id" = ${tournamentId})`; +} diff --git a/app/features/tournament/TournamentTeamRepository.server.test.ts b/app/features/tournament/TournamentTeamRepository.server.test.ts index 8788337a1..a4ff586aa 100644 --- a/app/features/tournament/TournamentTeamRepository.server.test.ts +++ b/app/features/tournament/TournamentTeamRepository.server.test.ts @@ -359,6 +359,71 @@ describe("TournamentTeamRepository", () => { }); }); + describe("findAllRegistrationsByUserIds", () => { + const WINDOW_STARTS_AT = 1_700_000_000; + const DAY_IN_SECONDS = 60 * 60 * 24; + const WINDOW_ENDS_AT = WINDOW_STARTS_AT + 7 * DAY_IN_SECONDS; + + const registerAt = async (startTime: number) => { + const tournament = await TournamentFactory.create({ + authorId: organizerId(), + startTimes: [startTime], + }); + await TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [ownerId(), memberId()], + }); + + return tournament.id; + }; + + const registrationsInWindow = (excludeTournamentId?: number) => + TournamentTeamRepository.findAllRegistrationsByUserIds({ + userIds: [memberId()], + startsAt: WINDOW_STARTS_AT, + endsAt: WINDOW_ENDS_AT, + excludeTournamentId, + }); + + test("leaves out the registrations starting outside the window", async () => { + await registerAt(WINDOW_STARTS_AT + DAY_IN_SECONDS); + await registerAt(WINDOW_STARTS_AT - DAY_IN_SECONDS); + await registerAt(WINDOW_ENDS_AT + DAY_IN_SECONDS); + + const registrations = await registrationsInWindow(); + + expect(registrations).toHaveLength(1); + expect(registrations[0].userId).toBe(memberId()); + expect(registrations[0].startsAt).toBe(WINDOW_STARTS_AT + DAY_IN_SECONDS); + }); + + test("leaves out the excluded tournament", async () => { + const excludedTournamentId = await registerAt( + WINDOW_STARTS_AT + DAY_IN_SECONDS, + ); + await registerAt(WINDOW_STARTS_AT + 2 * DAY_IN_SECONDS); + + const registrations = await registrationsInWindow(excludedTournamentId); + + expect(registrations).toHaveLength(1); + expect(registrations[0].startsAt).toBe( + WINDOW_STARTS_AT + 2 * DAY_IN_SECONDS, + ); + }); + + test("returns nothing when no user ids are given", async () => { + await registerAt(WINDOW_STARTS_AT + DAY_IN_SECONDS); + + expect( + await TournamentTeamRepository.findAllRegistrationsByUserIds({ + userIds: [], + startsAt: WINDOW_STARTS_AT, + endsAt: WINDOW_ENDS_AT, + }), + ).toEqual([]); + }); + }); + describe("findRecentlyPlayedMapsByIds", () => { test("leaves out the games of the match the maps are resolved for", async () => { // an in-progress set's map list is regenerated when its cache entry is lost, so counting its diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 1dcb649ef..f67187381 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -982,43 +982,48 @@ export function findAllRegistrationsByUserIds({ }) { if (userIds.length === 0) return Promise.resolve([]); - return db - .selectFrom("TournamentTeamMember") - .innerJoin( - "TournamentTeam", - "TournamentTeam.id", - "TournamentTeamMember.tournamentTeamId", - ) - .innerJoin("Tournament", "Tournament.id", "TournamentTeam.tournamentId") - .innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id") - .innerJoin( - "CalendarEventDate", - "CalendarEventDate.eventId", - "CalendarEvent.id", - ) - .select((eb) => [ - "TournamentTeamMember.userId", - "CalendarEvent.name", - "CalendarEvent.organizationId", - "CalendarEventDate.startsAt", - "Tournament.settings", - eb - .selectFrom("TournamentTeam as RegisteredTeam") - .select(({ fn }) => fn.countAll().as("count")) - .whereRef("RegisteredTeam.tournamentId", "=", "Tournament.id") - .where("RegisteredTeam.isPlaceholder", "=", 0) - .as("teamCount"), - ]) - .$narrowType<{ teamCount: NotNull }>() - .where("TournamentTeamMember.userId", "in", userIds) - .where("TournamentTeam.droppedOut", "=", 0) - .where("CalendarEvent.hidden", "=", 0) - .where("CalendarEventDate.startsAt", ">=", startsAt) - .where("CalendarEventDate.startsAt", "<=", endsAt) - .$if(typeof excludeTournamentId === "number", (qb) => - qb.where("Tournament.id", "!=", excludeTournamentId!), - ) - .execute(); + return ( + db + .selectFrom("CalendarEventDate") + // cross join pins the join order: the date window is indexed and far narrower + // than the users' registration histories the planner walks otherwise + .crossJoin("CalendarEvent") + .innerJoin("Tournament", "Tournament.id", "CalendarEvent.tournamentId") + .innerJoin( + "TournamentTeam", + "TournamentTeam.tournamentId", + "Tournament.id", + ) + .innerJoin( + "TournamentTeamMember", + "TournamentTeamMember.tournamentTeamId", + "TournamentTeam.id", + ) + .select((eb) => [ + "TournamentTeamMember.userId", + "CalendarEvent.name", + "CalendarEvent.organizationId", + "CalendarEventDate.startsAt", + "Tournament.settings", + eb + .selectFrom("TournamentTeam as RegisteredTeam") + .select(({ fn }) => fn.countAll().as("count")) + .whereRef("RegisteredTeam.tournamentId", "=", "Tournament.id") + .where("RegisteredTeam.isPlaceholder", "=", 0) + .as("teamCount"), + ]) + .$narrowType<{ teamCount: NotNull }>() + .whereRef("CalendarEvent.id", "=", "CalendarEventDate.eventId") + .where("TournamentTeamMember.userId", "in", userIds) + .where("TournamentTeam.droppedOut", "=", 0) + .where("CalendarEvent.hidden", "=", 0) + .where("CalendarEventDate.startsAt", ">=", startsAt) + .where("CalendarEventDate.startsAt", "<=", endsAt) + .$if(typeof excludeTournamentId === "number", (qb) => + qb.where("Tournament.id", "!=", excludeTournamentId!), + ) + .execute() + ); } /** Invite code of one team, the secret the tournament layout data does not carry. */ diff --git a/app/features/vods/VodRepository.server.test.ts b/app/features/vods/VodRepository.server.test.ts index 4757028fd..9a2f88862 100644 --- a/app/features/vods/VodRepository.server.test.ts +++ b/app/features/vods/VodRepository.server.test.ts @@ -126,6 +126,31 @@ describe("findVods", () => { }); }); +describe("countVods", () => { + beforeEach(async () => { + await users.create(5); + await seedVodsOfEveryFilter(); + }); + + test.each([ + ["by weapon", () => ({ weapon: 1000 as const }), 2], + ["by mode", () => ({ mode: "SZ" as const }), 2], + ["by stageId", () => ({ stageId: 1 as const }), 2], + ["by type", () => ({ type: "CAST" as const }), 1], + ["by user", () => ({ userId: users.id(1) }), 1], + ["without filters", () => ({}), 3], + ])( + "agrees with the rows findVods returns (%s)", + async (_why, filters, expected) => { + const rows = await VodRepository.findVods({ ...filters(), limit: 100 }); + const count = await VodRepository.countVods(filters()); + + expect(rows).toHaveLength(expected); + expect(count).toBe(rows.length); + }, + ); +}); + describe("findVodById", () => { beforeEach(async () => { await users.create(5); @@ -418,3 +443,27 @@ describe("deleteById", () => { expect(secondResult).not.toBeNull(); }); }); + +/** One vod per `countVods` filter case, each matching some of the filters but never all of them. */ +async function seedVodsOfEveryFilter() { + await VodFactory.create({ + submitterUserId: users.id(1), + type: "TOURNAMENT", + pov: { type: "USER", userId: users.id(1) }, + matches: [{ mode: "SZ", stageId: 1, startsAt: "0:00", weapons: [1000] }], + }); + await VodFactory.create({ + submitterUserId: users.id(1), + type: "CAST", + matches: [ + { mode: "SZ", stageId: 2, startsAt: "0:00", weapons: [2000] }, + { mode: "TC", stageId: 1, startsAt: "5:00", weapons: [1000] }, + ], + }); + await VodFactory.create({ + submitterUserId: users.id(2), + type: "SCRIM", + pov: { type: "USER", userId: users.id(2) }, + matches: [{ mode: "TC", stageId: 3, startsAt: "0:00", weapons: [0] }], + }); +} diff --git a/app/features/vods/VodRepository.server.ts b/app/features/vods/VodRepository.server.ts index ec30a64e3..0468b8314 100644 --- a/app/features/vods/VodRepository.server.ts +++ b/app/features/vods/VodRepository.server.ts @@ -1,4 +1,10 @@ -import { sql } from "kysely"; +import { + type Expression, + type ExpressionBuilder, + type SelectQueryBuilder, + type SqlBool, + sql, +} from "kysely"; import * as R from "remeda"; import { db } from "~/db/sql"; import type { Tables } from "~/db/tables"; @@ -30,31 +36,24 @@ export async function findByUserId(userId: Tables["User"]["id"], limit = 100) { return findVods({ userId, limit }); } -export async function findVods({ - weapon, - mode, - stageId, - type, - userId, - limit = VODS_PAGE_BATCH_SIZE, - offset = 0, -}: { +type VodFilters = { weapon?: MainWeaponId; mode?: ModeShort; stageId?: StageId; type?: Tables["Video"]["type"]; userId?: number; +}; + +/** Page of the vods matching the filters, newest first, with the weapons and players of their matching match rows. */ +export async function findVods({ + limit = VODS_PAGE_BATCH_SIZE, + offset = 0, + ...filters +}: VodFilters & { limit?: number; offset?: number; }) { - let query = db - .selectFrom("Video") - .leftJoin("VideoMatch", "VideoMatch.videoId", "Video.id") - .leftJoin( - "VideoMatchPlayer", - "VideoMatch.id", - "VideoMatchPlayer.videoMatchId", - ) + const result = await vodsWithMatches() .selectAll("Video") .select(({ fn, ref, eb }) => [ sql< @@ -72,32 +71,20 @@ export async function findVods({ .select((playerEb) => commonUserSelect(playerEb)) .whereRef("User.id", "=", "VideoMatchPlayer.playerUserId"), ).as("players"), - ]); - if (userId) { - query = query.where("VideoMatchPlayer.playerUserId", "=", userId); - } else { - if (type) { - query = query.where("Video.type", "=", type); - } - if (mode) { - query = query.where("VideoMatch.mode", "=", mode); - } - if (stageId) { - query = query.where("VideoMatch.stageId", "=", stageId); - } - } - if (weapon) { - query = query.where( - "VideoMatchPlayer.weaponSplId", + ]) + .where(vodFilters(filters)) + // the page is resolved by id first: with the limit on this read, the aggregates + // of every matching vod would be computed before it applies + .where( + "Video.id", "in", - weaponIdToArrayWithAlts(weapon), - ); - } - const result = await query + filteredVideoIds(filters) + .orderBy("Video.youtubePublishedAt", "desc") + .limit(limit) + .offset(offset), + ) .groupBy("Video.id") .orderBy("Video.youtubePublishedAt", "desc") - .limit(limit) - .offset(offset) .execute(); const vods = result.map((value) => { @@ -110,50 +97,13 @@ export async function findVods({ return vods; } -export async function countVods({ - weapon, - mode, - stageId, - type, - userId, -}: { - weapon?: MainWeaponId; - mode?: ModeShort; - stageId?: StageId; - type?: Tables["Video"]["type"]; - userId?: number; -}) { - let query = db - .selectFrom("Video") - .leftJoin("VideoMatch", "VideoMatch.videoId", "Video.id") - .leftJoin( - "VideoMatchPlayer", - "VideoMatch.id", - "VideoMatchPlayer.videoMatchId", - ) - .select(({ fn }) => fn.count("Video.id").distinct().as("count")); - if (userId) { - query = query.where("VideoMatchPlayer.playerUserId", "=", userId); - } else { - if (type) { - query = query.where("Video.type", "=", type); - } - if (mode) { - query = query.where("VideoMatch.mode", "=", mode); - } - if (stageId) { - query = query.where("VideoMatch.stageId", "=", stageId); - } - } - if (weapon) { - query = query.where( - "VideoMatchPlayer.weaponSplId", - "in", - weaponIdToArrayWithAlts(weapon), - ); - } +/** How many vods match the filters. */ +export async function countVods(filters: VodFilters) { + const result = await db + .selectFrom(filteredVideoIds(filters).as("filtered")) + .select(({ fn }) => fn.countAll().as("count")) + .executeTakeFirstOrThrow(); - const result = await query.executeTakeFirstOrThrow(); return result.count; } @@ -346,3 +296,91 @@ async function save( export function deleteById(id: number) { return db.deleteFrom("UnvalidatedVideo").where("id", "=", id).execute(); } + +const vodsWithMatches = () => + db + .selectFrom("Video") + .leftJoin("VideoMatch", "VideoMatch.videoId", "Video.id") + .leftJoin( + "VideoMatchPlayer", + "VideoMatch.id", + "VideoMatchPlayer.videoMatchId", + ); + +type VodsWithMatchesDB = + ReturnType extends SelectQueryBuilder< + infer JoinedDB, + any, + any + > + ? JoinedDB + : never; + +type VodsTables = "Video" | "VideoMatch" | "VideoMatchPlayer"; + +/** Conditions the filters put on the match rows. `userId` makes the vod's own filters moot: it is the user's vods regardless. */ +function vodFilters({ weapon, mode, stageId, type, userId }: VodFilters) { + return (eb: ExpressionBuilder) => { + const conditions: Expression[] = []; + if (userId) { + conditions.push(eb("VideoMatchPlayer.playerUserId", "=", userId)); + } else { + if (type) { + conditions.push(eb("Video.type", "=", type)); + } + if (mode) { + conditions.push(eb("VideoMatch.mode", "=", mode)); + } + if (stageId) { + conditions.push(eb("VideoMatch.stageId", "=", stageId)); + } + } + if (weapon) { + conditions.push( + eb( + "VideoMatchPlayer.weaponSplId", + "in", + weaponIdToArrayWithAlts(weapon), + ), + ); + } + + return eb.and(conditions); + }; +} + +/** + * Ids of the vods matching the filters, joined only as far as a filter reads: inner joins + * let a player level filter start from the player rows' index instead of walking every vod. + */ +function filteredVideoIds(filters: VodFilters) { + const { type, userId, mode, stageId, weapon } = filters; + const filtersPlayers = Boolean(userId || weapon); + const filtersMatches = !filtersPlayers && Boolean(mode || stageId); + + return db + .selectFrom("Video") + .select("Video.id") + .distinct() + .$if(Boolean(type) && !userId, (qb) => qb.where("Video.type", "=", type!)) + .$if(filtersPlayers, (qb) => + qb + .innerJoin("VideoMatch", "VideoMatch.videoId", "Video.id") + .innerJoin( + "VideoMatchPlayer", + "VideoMatch.id", + "VideoMatchPlayer.videoMatchId", + ) + .where(vodFilters(filters)), + ) + .$if(filtersMatches, (qb) => + qb + .innerJoin("VideoMatch", "VideoMatch.videoId", "Video.id") + .leftJoin( + "VideoMatchPlayer", + "VideoMatch.id", + "VideoMatchPlayer.videoMatchId", + ) + .where(vodFilters(filters)), + ); +} diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts index 8394cf55a..589b97550 100644 --- a/app/utils/kysely.server.ts +++ b/app/utils/kysely.server.ts @@ -4,6 +4,7 @@ import { type Expression, type ExpressionBuilder, type RawBuilder, + type SqlBool, sql, } from "kysely"; import type { @@ -19,6 +20,8 @@ import { } from "~/db/json-selections"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; +import * as Seasons from "~/features/mmr/core/Seasons"; +import { dateToDatabaseTimestamp } from "./dates"; import { IS_E2E_TEST_RUN } from "./e2e"; import { safeNumberParse } from "./number"; @@ -51,6 +54,16 @@ export function peakXpOverallSql() { return sql`"SplatoonPlayer"."peakXp" ->> '$.overall'`; } +/** + * Filter keeping only the `GroupMember` rows that can belong to season `nth`'s matches. + * `"GroupMember"` must be in scope at the call site. + */ +export function groupMemberOfSeasonSql(nth: number) { + const { starts, ends } = Seasons.nthToGroupMembershipDateRange(nth); + + return sql`"GroupMember"."createdAt" between ${dateToDatabaseTimestamp(starts)} and ${dateToDatabaseTimestamp(ends)}`; +} + type CommonUserSelectOptions = { alias?: string; prefix?: string; diff --git a/changelog/2026-09-08-faster-db-queries.md b/changelog/2026-09-08-faster-db-queries.md new file mode 100644 index 000000000..4f750d8a8 --- /dev/null +++ b/changelog/2026-09-08-faster-db-queries.md @@ -0,0 +1,4 @@ +--- +type: feature +--- +Faster page loads across the site (db optimization) \ No newline at end of file diff --git a/migrations/20260908152750-query-indexes.ts b/migrations/20260908152750-query-indexes.ts new file mode 100644 index 000000000..70b210ce8 --- /dev/null +++ b/migrations/20260908152750-query-indexes.ts @@ -0,0 +1,45 @@ +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + // date window reads (calendar, running tournaments, availability) scanned every event + await trx.schema + .createIndex("calendar_event_date_starts_at") + .on("CalendarEventDate") + .column("startsAt") + .execute(); + + // the month list skips through this instead of reading every placement + await trx.schema + .createIndex("xrank_placement_year_month") + .on("XRankPlacement") + .columns(["year", "month"]) + .execute(); + + // covers the per match KO count of the bracket read, replacing the matchId only index + await trx.schema + .dropIndex("tournament_match_game_result_match_id") + .execute(); + await trx.schema + .createIndex("tournament_match_game_result_match_id_winner_team_id_ko") + .on("TournamentMatchGameResult") + .columns(["matchId", "winnerTeamId", "ko"]) + .execute(); + + // the vods weapon filter walked every player row + await trx.schema + .createIndex("video_match_player_weapon_spl_id") + .on("VideoMatchPlayer") + .column("weaponSplId") + .execute(); + + // almost no event has a trophy, so to the planner the full index looked as bad as scanning every event + await trx.schema.dropIndex("calendar_event_trophy_id").execute(); + await trx.schema + .createIndex("calendar_event_trophy_id") + .on("CalendarEvent") + .column("trophyId") + .where("trophyId", "is not", null) + .execute(); + }); +}