Denormalize peak XP to SplatoonPlayer table (#2741)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kalle
2026-01-19 22:15:20 +02:00
committed by GitHub
parent 6515ed3b93
commit 400f5ce301
15 changed files with 135 additions and 21 deletions

View File

@@ -429,6 +429,8 @@ export interface SplatoonPlayer {
id: GeneratedAlways<number>;
splId: string;
userId: number | null;
/** Players best XP across both divisions. Denormalized for performance. */
peakXp: number | null;
}
export interface TaggedArt {

View File

@@ -24,6 +24,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await db
.selectFrom("User")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.leftJoin("SplatoonPlayer", "SplatoonPlayer.userId", "User.id")
.select(({ eb }) => [
"User.id",
"User.country",
@@ -55,17 +56,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
.groupBy(["BadgeOwner.badgeId", "BadgeOwner.userId"])
.whereRef("BadgeOwner.userId", "=", "User.id"),
).as("badges"),
jsonArrayFrom(
eb
.selectFrom("SplatoonPlayer")
.innerJoin(
"XRankPlacement",
"XRankPlacement.playerId",
"SplatoonPlayer.id",
)
.select(["XRankPlacement.power"])
.whereRef("SplatoonPlayer.userId", "=", "User.id"),
).as("xRankPlacements"),
"SplatoonPlayer.peakXp",
jsonArrayFrom(
eb
.selectFrom("TeamMemberWithSecondary")
@@ -117,13 +108,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
tier: skill.tier,
}
: null,
peakXp:
user.xRankPlacements.length > 0
? user.xRankPlacements.reduce((acc, cur) => {
if (!cur.power) return acc;
return Math.max(acc, cur.power);
}, 0)
: null,
peakXp: user.peakXp,
weaponPool: user.weapons.map((weapon) => ({
id: weapon.weaponSplId,
name: t(`weapons:MAIN_${weapon.weaponSplId}`),

View File

@@ -17,12 +17,11 @@ const deleteBadgeOwnerStm = sql.prepare(/* sql */ `
const userTopXPowersStm = sql.prepare(/* sql */ `
select
"SplatoonPlayer"."userId",
max("XRankPlacement"."power") as "xPower"
"SplatoonPlayer"."peakXp" as "xPower"
from
"SplatoonPlayer"
left join "XRankPlacement" on "XRankPlacement"."playerId" = "SplatoonPlayer"."id"
where "SplatoonPlayer"."userId" is not null
group by "SplatoonPlayer"."userId"
and "SplatoonPlayer"."peakXp" is not null
`);
const addXPBadgeStm = sql.prepare(/* sql */ `

View File

@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { db } from "~/db/sql";
import { dbReset } from "~/utils/Test";
import * as XRankPlacementRepository from "./XRankPlacementRepository.server";
let placementCounter = 0;
const createSplatoonPlayer = async (splId: string) => {
const result = await db
.insertInto("SplatoonPlayer")
.values({ splId })
.returning("id")
.executeTakeFirstOrThrow();
return result.id;
};
const createXRankPlacement = async ({
playerId,
power,
}: {
playerId: number;
power: number;
}) => {
placementCounter++;
await db
.insertInto("XRankPlacement")
.values({
playerId,
power,
badges: "[]",
bannerSplId: 1,
mode: "SZ",
month: 1,
year: 2024,
name: "Test Player",
nameDiscriminator: "0000",
rank: placementCounter,
region: "WEST",
title: "Test",
weaponSplId: 0,
})
.execute();
};
describe("refreshAllPeakXp", () => {
beforeEach(() => {
placementCounter = 0;
dbReset();
});
afterEach(() => {
dbReset();
});
test("sets peakXp to max power for each player", async () => {
const player1Id = await createSplatoonPlayer("player1");
const player2Id = await createSplatoonPlayer("player2");
await createXRankPlacement({ playerId: player1Id, power: 2500 });
await createXRankPlacement({ playerId: player1Id, power: 2700 });
await createXRankPlacement({ playerId: player1Id, power: 2600 });
await createXRankPlacement({ playerId: player2Id, power: 3000 });
await createXRankPlacement({ playerId: player2Id, power: 2800 });
await XRankPlacementRepository.refreshAllPeakXp();
const players = await db
.selectFrom("SplatoonPlayer")
.select(["id", "peakXp"])
.orderBy("id", "asc")
.execute();
expect(players[0].peakXp).toBe(2700);
expect(players[1].peakXp).toBe(3000);
});
test("sets peakXp to null for player with no placements", async () => {
const playerId = await createSplatoonPlayer("player1");
await XRankPlacementRepository.refreshAllPeakXp();
const player = await db
.selectFrom("SplatoonPlayer")
.select("peakXp")
.where("id", "=", playerId)
.executeTakeFirstOrThrow();
expect(player.peakXp).toBeNull();
});
});

View File

@@ -67,3 +67,15 @@ export async function monthYears() {
export type FindPlacement = InferResult<
ReturnType<typeof xRankPlacementsQueryBase>
>[number];
export async function refreshAllPeakXp() {
await db
.updateTable("SplatoonPlayer")
.set((eb) => ({
peakXp: eb
.selectFrom("XRankPlacement")
.select((eb) => eb.fn.max("XRankPlacement.power").as("peakXp"))
.whereRef("XRankPlacement.playerId", "=", "SplatoonPlayer.id"),
}))
.execute();
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,21 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/*sql*/ `
alter table "SplatoonPlayer"
add column "peakXp" real
`,
).run();
db.prepare(
/*sql*/ `
update "SplatoonPlayer"
set "peakXp" = (
select max("XRankPlacement"."power")
from "XRankPlacement"
where "XRankPlacement"."playerId" = "SplatoonPlayer"."id"
)
`,
).run();
})();
}

View File

@@ -4,6 +4,7 @@ import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { syncXPBadges } from "~/features/badges/queries/syncXPBadges.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import invariant from "~/utils/invariant";
@@ -53,6 +54,7 @@ async function main() {
}
addPlacements(placements);
await XRankPlacementRepository.refreshAllPeakXp();
syncXPBadges();
await BuildRepository.recalculateAllTop500();
logger.info(`done reading in ${placements.length} placements`);