Replace the old sql queries with kysely queries for the VODs feature (#2423)

This commit is contained in:
Phil-hacker
2025-08-28 18:03:37 +02:00
committed by GitHub
parent 09b0b72206
commit 55c580f53a
11 changed files with 293 additions and 299 deletions

View File

@@ -45,7 +45,7 @@ import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { createVod } from "~/features/vods/queries/createVod.server";
import * as VodRepository from "~/features/vods/VodRepository.server";
import {
secondsToHoursMinutesSecondString,
youtubeIdToYoutubeUrl,
@@ -1722,8 +1722,8 @@ function otherTeams() {
}
}
function realVideo() {
createVod({
async function realVideo() {
await VodRepository.createVod({
type: "TOURNAMENT",
youtubeUrl: youtubeIdToYoutubeUrl("M4aV-BQWlVg"),
date: { day: 2, month: 2, year: 2023 },
@@ -1776,8 +1776,8 @@ function realVideo() {
});
}
function realVideoCast() {
createVod({
async function realVideoCast() {
await VodRepository.createVod({
type: "CAST",
youtubeUrl: youtubeIdToYoutubeUrl("M4aV-BQWlVg"),
date: { day: 2, month: 2, year: 2023 },

View File

@@ -1,6 +1,6 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { findVods } from "~/features/vods/queries/findVods.server";
import * as VodRepository from "~/features/vods/VodRepository.server";
import { notFoundIfFalsy } from "~/utils/remix.server";
export const loader = async ({ params }: LoaderFunctionArgs) => {
@@ -10,6 +10,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
return {
// TODO: add pagination instead of not showing oldest vods at all
vods: findVods({ userId, limit: 100 }),
vods: await VodRepository.findVodsByUserId(userId),
};
};

View File

@@ -1,5 +1,273 @@
import { expressionBuilder, sql } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import * as R from "remeda";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import { weaponIdToArrayWithAlts } from "~/modules/in-game-lists/weapon-ids";
import {
dateToDatabaseTimestamp,
dayMonthYearToDatabaseTimestamp,
} from "~/utils/dates";
import invariant from "~/utils/invariant";
import { VODS_PAGE_BATCH_SIZE } from "./vods-constants";
import type { VideoBeingAdded, Vod } from "./vods-types";
import {
extractYoutubeIdFromVideoUrl,
hoursMinutesSecondsStringToSeconds,
} from "./vods-utils";
export function deleteById(id: number) {
return db.deleteFrom("UnvalidatedVideo").where("id", "=", id).execute();
}
export async function findVodsByUserId(
userId: Tables["User"]["id"],
limit = 100,
) {
return findVods({ userId, limit });
}
export async function findVods({
weapon,
mode,
stageId,
type,
userId,
limit = VODS_PAGE_BATCH_SIZE,
}: {
weapon?: MainWeaponId;
mode?: ModeShort;
stageId?: StageId;
type?: Tables["Video"]["type"];
userId?: number;
limit?: number;
}) {
let query = db
.selectFrom("Video")
.leftJoin("VideoMatch", "Video.id", "VideoMatch.videoId")
.leftJoin(
"VideoMatchPlayer",
"VideoMatch.id",
"VideoMatchPlayer.videoMatchId",
)
.leftJoin("User", "VideoMatchPlayer.playerUserId", "User.id")
.selectAll("Video")
.select(({ fn, ref }) => [
sql<
Array<number>
>`json_group_array(distinct ${ref("VideoMatchPlayer.weaponSplId")})`
.$castTo<MainWeaponId[]>()
.as("weapons"),
fn
.agg("json_group_array", ["VideoMatchPlayer.playerName"])
.$castTo<string[]>()
.as("playerNames"),
selectPlayers(),
]);
if (userId) {
query = query.where("User.id", "=", 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),
);
}
const result = await query
.groupBy("Video.id")
.orderBy("Video.youtubeDate", "desc")
.limit(limit)
.execute();
const vods = result.map((value) => {
const { playerNames, players, ...vod } = value;
return {
...vod,
pov: playerNames[0] ?? players[0],
};
});
return vods;
}
export async function findVodById(id: Tables["Video"]["id"]) {
const videoQuery = db
.selectFrom("Video")
.select([
"id",
"title",
"youtubeDate",
"youtubeId",
"type",
"submitterUserId",
])
.where("Video.id", "=", id);
const video = await videoQuery.executeTakeFirst();
if (video) {
const videoMatchQuery = db
.selectFrom("VideoMatch")
.select([
"VideoMatch.id",
"VideoMatch.mode",
"VideoMatch.stageId",
"VideoMatch.startsAt",
])
.leftJoin(
"VideoMatchPlayer",
"VideoMatch.id",
"VideoMatchPlayer.videoMatchId",
)
.leftJoin("User", "VideoMatchPlayer.playerUserId", "User.id")
.select(selectPlayers())
.select(({ fn }) => [
fn
.agg("json_group_array", ["VideoMatchPlayer.weaponSplId"])
.$castTo<MainWeaponId[]>()
.as("weapons"),
fn
.agg("json_group_array", ["VideoMatchPlayer.playerName"])
.as("playerNames"),
])
.where("VideoMatch.videoId", "=", id)
.groupBy("VideoMatch.id")
.orderBy("VideoMatch.startsAt", "asc")
.orderBy("VideoMatchPlayer.player", "asc");
const matches = await videoMatchQuery.execute();
return {
...video,
pov: resolvePov(matches),
matches: R.map(matches, R.omit(["players", "playerNames"])),
};
}
return null;
}
function resolvePov(matches: any): Vod["pov"] {
for (const match of matches) {
if (match.playerNames.length > 0) {
return match.playerNames[0];
}
if (match.players.length > 0) {
return match.players[0];
}
}
return;
}
export async function updateVodByReplacing(
args: VideoBeingAdded & {
submitterUserId: number;
isValidated: boolean;
id: number;
},
) {
return createVod(args);
}
export async function createVod(
args: VideoBeingAdded & {
submitterUserId: number;
isValidated: boolean;
id?: number;
},
) {
const youtubeId = extractYoutubeIdFromVideoUrl(args.youtubeUrl);
invariant(youtubeId, "Invalid YouTube URL");
return db.transaction().execute(async (trx) => {
let videoId: number;
const video = {
title: args.title,
type: args.type,
youtubeDate: dayMonthYearToDatabaseTimestamp(args.date),
eventId: args.eventId ?? null,
youtubeId,
submitterUserId: args.submitterUserId,
validatedAt: args.isValidated
? dateToDatabaseTimestamp(new Date())
: null,
};
if (args.id) {
await trx
.deleteFrom("VideoMatch")
.where("videoId", "=", args.id)
.execute();
await trx
.updateTable("UnvalidatedVideo")
.set(video)
.where("id", "=", args.id)
.execute();
videoId = args.id;
} else {
const result = await trx
.insertInto("UnvalidatedVideo")
.values(video)
.returning("UnvalidatedVideo.id")
.executeTakeFirstOrThrow();
videoId = result.id;
}
for (const match of args.matches) {
const videoMatchResult = await trx
.insertInto("VideoMatch")
.values({
videoId: videoId,
startsAt: hoursMinutesSecondsStringToSeconds(match.startsAt),
stageId: match.stageId,
mode: match.mode,
})
.returning("VideoMatch.id")
.executeTakeFirstOrThrow();
const matchId = videoMatchResult.id;
for (const [i, weaponSplId] of match.weapons.entries()) {
await trx
.insertInto("VideoMatchPlayer")
.values({
videoMatchId: matchId,
playerUserId: args.pov?.type === "USER" ? args.pov.userId : null,
playerName: args.pov?.type === "NAME" ? args.pov.name : null,
weaponSplId,
player: i + 1,
})
.executeTakeFirstOrThrow();
}
}
return { ...video, id: videoId };
});
}
function selectPlayers() {
const eb = expressionBuilder<DB>();
return jsonArrayFrom(
eb
.selectFrom("User")
.select([
"User.username",
"User.discordId",
"User.discordAvatar",
"User.customUrl",
]),
).as("players");
}

View File

@@ -2,14 +2,15 @@ import { type ActionFunctionArgs, redirect } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import { badRequestIfFalsy, unauthorizedIfFalsy } from "~/utils/remix.server";
import { userVodsPage } from "~/utils/urls";
import { findVodById } from "../queries/findVodById.server";
import * as VodRepository from "../VodRepository.server";
import { canEditVideo } from "../vods-utils";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = await requireUser(request);
const vod = badRequestIfFalsy(findVodById(Number(params.id)));
const vod = badRequestIfFalsy(
await VodRepository.findVodById(Number(params.id)),
);
unauthorizedIfFalsy(
canEditVideo({

View File

@@ -4,8 +4,7 @@ import { requireUser } from "~/features/auth/core/user.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { notFoundIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { vodVideoPage } from "~/utils/urls";
import { createVod, updateVodByReplacing } from "../queries/createVod.server";
import { findVodById } from "../queries/findVodById.server";
import * as VodRepository from "../VodRepository.server";
import { videoInputSchema } from "../vods-schemas";
import { canEditVideo } from "../vods-utils";
@@ -20,7 +19,9 @@ export const action: ActionFunction = async ({ request }) => {
let video: Tables["Video"];
if (data.vodToEditId) {
const vod = notFoundIfFalsy(findVodById(data.vodToEditId));
const vod = notFoundIfFalsy(
await VodRepository.findVodById(data.vodToEditId),
);
if (
!canEditVideo({
@@ -32,14 +33,14 @@ export const action: ActionFunction = async ({ request }) => {
throw new Response("no permissions to edit this vod", { status: 401 });
}
video = updateVodByReplacing({
video = await VodRepository.updateVodByReplacing({
...data.video,
submitterUserId: user.id,
isValidated: true,
id: data.vodToEditId,
});
} else {
video = createVod({
video = await VodRepository.createVod({
...data.video,
submitterUserId: user.id,
isValidated: true,

View File

@@ -1,9 +1,11 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { notFoundIfFalsy } from "~/utils/remix.server";
import { findVodById } from "../queries/findVodById.server";
import * as VodRepository from "../VodRepository.server";
export const loader = ({ params }: LoaderFunctionArgs) => {
const vod = notFoundIfFalsy(findVodById(Number(params.id)));
export const loader = async ({ params }: LoaderFunctionArgs) => {
const vod = notFoundIfFalsy(
await VodRepository.findVodById(Number(params.id)),
);
return { vod };
};

View File

@@ -3,7 +3,7 @@ import { z } from "zod/v4";
import { requireUser } from "~/features/auth/core/user.server";
import { notFoundIfFalsy } from "~/utils/remix.server";
import { actualNumber, id } from "~/utils/zod";
import { findVodById } from "../queries/findVodById.server";
import * as VodRepository from "../VodRepository.server";
import { canEditVideo, vodToVideoBeingAdded } from "../vods-utils";
const newVodLoaderParamsSchema = z.object({
@@ -22,7 +22,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return { vodToEdit: null };
}
const vod = notFoundIfFalsy(findVodById(params.data.vod));
const vod = notFoundIfFalsy(await VodRepository.findVodById(params.data.vod));
const vodToEdit = vodToVideoBeingAdded(vod);
if (

View File

@@ -1,5 +1,5 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { findVods } from "../queries/findVods.server";
import * as VodRepository from "../VodRepository.server";
import { VODS_PAGE_BATCH_SIZE } from "../vods-constants";
export const loader = async ({ request }: LoaderFunctionArgs) => {
@@ -7,7 +7,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const limit = Number(url.searchParams.get("limit") ?? VODS_PAGE_BATCH_SIZE);
const vods = findVods({
const vods = await VodRepository.findVods({
...Object.fromEntries(
Array.from(url.searchParams.entries()).filter(([, value]) => value),
),

View File

@@ -1,102 +0,0 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import {
dateToDatabaseTimestamp,
dayMonthYearToDatabaseTimestamp,
} from "~/utils/dates";
import invariant from "~/utils/invariant";
import type { VideoBeingAdded } from "../vods-types";
import {
extractYoutubeIdFromVideoUrl,
hoursMinutesSecondsStringToSeconds,
} from "../vods-utils";
const createVideoStm = sql.prepare(/* sql */ `
insert into "UnvalidatedVideo"
("id", "title", "type", "youtubeDate", "eventId", "youtubeId", "submitterUserId", "validatedAt")
values
(@id, @title, @type, @youtubeDate, @eventId, @youtubeId, @submitterUserId, @validatedAt)
returning *
`);
const deleteVideoStm = sql.prepare(/* sql */ `
delete from "UnvalidatedVideo"
where "id" = @id
`);
const createVideoMatchStm = sql.prepare(/* sql */ `
insert into "VideoMatch"
("videoId", "startsAt", "stageId", "mode")
values
(@videoId, @startsAt, @stageId, @mode)
returning *
`);
const createVideoMatchPlayerStm = sql.prepare(/* sql */ `
insert into "VideoMatchPlayer"
("videoMatchId", "playerUserId", "playerName", "weaponSplId", "player")
values
(@videoMatchId, @playerUserId, @playerName, @weaponSplId, @player)
`);
export const createVod = sql.transaction(
(
args: VideoBeingAdded & {
submitterUserId: number;
isValidated: boolean;
id?: number;
},
) => {
const youtubeId = extractYoutubeIdFromVideoUrl(args.youtubeUrl);
invariant(youtubeId, "Invalid YouTube URL");
const video = createVideoStm.get({
id: args.id ?? null,
title: args.title,
type: args.type,
youtubeDate: dayMonthYearToDatabaseTimestamp(args.date),
eventId: args.eventId ?? null,
youtubeId,
submitterUserId: args.submitterUserId,
validatedAt: args.isValidated
? dateToDatabaseTimestamp(new Date())
: null,
}) as Tables["Video"];
for (const match of args.matches) {
const videoMatch = createVideoMatchStm.get({
videoId: video.id,
startsAt: hoursMinutesSecondsStringToSeconds(match.startsAt),
stageId: match.stageId,
mode: match.mode,
}) as any;
for (const [i, weaponSplId] of match.weapons.entries()) {
createVideoMatchPlayerStm.run({
videoMatchId: videoMatch.id,
playerUserId: args.pov?.type === "USER" ? args.pov.userId : null,
playerName: args.pov?.type === "NAME" ? args.pov.name : null,
weaponSplId,
player: i + 1,
});
}
}
return video;
},
);
export const updateVodByReplacing = sql.transaction(
(
args: VideoBeingAdded & {
submitterUserId: number;
isValidated: boolean;
id: number;
},
) => {
deleteVideoStm.run({ id: args.id });
const video = createVod(args);
return video;
},
);

View File

@@ -1,77 +0,0 @@
import { sql } from "~/db/sql";
import { parseDBArray } from "~/utils/sql";
import type { Vod } from "../vods-types";
const videoStm = sql.prepare(/* sql */ `
select
v."id",
v."title",
v."youtubeDate",
v."youtubeId",
v."type",
v."submitterUserId"
from "Video" v
where v."id" = @id
`);
const videoMatchesStm = sql.prepare(/* sql */ `
select
vm."id",
vm."mode",
vm."stageId",
vm."startsAt",
json_group_array("vp"."weaponSplId") as "weapons",
json_group_array("vp"."playerName") as "playerNames",
json_group_array(
json_object(
'username',
"u"."username",
'discordId',
"u"."discordId",
'discordAvatar',
"u"."discordAvatar",
'customUrl',
"u"."customUrl",
'id',
"u"."id"
)
) as "players"
from "VideoMatch" vm
left join "VideoMatchPlayer" vp on vm."id" = vp."videoMatchId"
left join "User" u on vp."playerUserId" = u."id"
where vm."videoId" = @id
group by vm."id"
order by vm."startsAt" asc, vp."player" asc
`);
export function findVodById(id: Vod["id"]): Vod | null {
const video = videoStm.get({ id }) as any;
if (!video) return null;
const matches = videoMatchesStm.all({ id }) as any[];
return {
...video,
pov: resolvePov(matches),
matches: matches.map(({ players: _1, playerNames: _2, ...match }) => {
return {
...match,
weapons: parseDBArray(match.weapons),
};
}),
};
}
function resolvePov(matches: any): Vod["pov"] {
for (const match of matches) {
if (parseDBArray(match.playerNames).length > 0) {
return parseDBArray(match.playerNames)[0];
}
if (parseDBArray(match.players).length > 0) {
return parseDBArray(match.players)[0];
}
}
return;
}

View File

@@ -1,99 +0,0 @@
import * as R from "remeda";
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import { parseDBArray, parseDBJsonArray } from "~/utils/sql";
import { weaponIdToArrayWithAlts } from "../../../modules/in-game-lists/weapon-ids";
import { VODS_PAGE_BATCH_SIZE } from "../vods-constants";
import type { ListVod } from "../vods-types";
const query = (byUser?: true) => /* sql */ `
select
v."id",
v."title",
v."youtubeId",
v."type",
json_group_array("vp"."weaponSplId") as "weapons",
json_group_array("vp"."playerName") as "playerNames",
json_group_array(
json_object(
'username',
"u"."username",
'discordId',
"u"."discordId",
'discordAvatar',
"u"."discordAvatar",
'customUrl',
"u"."customUrl"
)
) as "players"
from "Video" v
left join "VideoMatch" vm on v."id" = vm."videoId"
left join "VideoMatchPlayer" vp on vm."id" = vp."videoMatchId"
left join "User" u on vp."playerUserId" = u."id"
where ${
byUser
? /* sql */ `u."id" = @userId`
: /* sql */ `
v."type" = coalesce(@type, v."type")
and vm."mode" = coalesce(@mode, vm."mode")
and vm."stageId" = coalesce(@stageId, vm."stageId")`
}
group by v."id"
order by v."youtubeDate" desc
`;
const stm = sql.prepare(query());
const stmByUser = sql.prepare(query(true));
export function findVods({
weapon,
mode,
stageId,
type,
userId,
limit = VODS_PAGE_BATCH_SIZE,
}: {
weapon?: MainWeaponId;
mode?: ModeShort;
stageId?: StageId;
type?: Tables["Video"]["type"];
userId?: number;
limit?: number;
}): Array<ListVod> {
const stmToUse = userId ? stmByUser : stm;
const vods = stmToUse.all({
mode: mode ?? null,
stageId: stageId ?? null,
type: type ?? null,
userId: userId ?? null,
}) as any[];
const weaponIdsToFilterBy = weapon
? weaponIdToArrayWithAlts(Number(weapon) as MainWeaponId) // TODO: fix on caller side
: [];
return vods
.filter((vod) => {
if (weaponIdsToFilterBy.length === 0) return true;
return parseDBArray(vod.weapons).some((weaponId: any) =>
weaponIdsToFilterBy.includes(weaponId),
);
})
.map(({ playerNames: playerNamesRaw, players: playersRaw, ...vod }) => {
const playerNames = parseDBArray(playerNamesRaw);
const players = parseDBJsonArray(playersRaw);
return {
...vod,
weapons: R.unique(parseDBArray(vod.weapons)),
pov: playerNames[0] ?? players[0],
};
})
.slice(0, limit);
}