Introduce Kysely EmptyValuesNoopPlugin plugin

This commit is contained in:
Kalle
2026-08-03 08:44:29 +03:00
parent 881c53d961
commit 203c96055b
21 changed files with 257 additions and 244 deletions

View File

@@ -0,0 +1,58 @@
import {
type InsertQueryNode,
type KyselyPlugin,
type PluginTransformQueryArgs,
type PluginTransformResultArgs,
type QueryResult,
RawNode,
type RootOperationNode,
SelectionNode,
SelectQueryNode,
type UnknownRow,
ValuesNode,
WhereNode,
} from "kysely";
/**
* Makes inserting an empty array of values a no-op instead of a syntax error.
* Kysely compiles `.values([])` into invalid SQL, so without this plugin every
* dynamic multi-row insert would need a length check before it. The empty
* insert is rewritten into `INSERT INTO "T" SELECT * FROM "T" WHERE 0` which
* inserts zero rows and returns zero rows for any `returning` clause.
*/
export class EmptyValuesNoopPlugin implements KyselyPlugin {
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
if (args.node.kind !== "InsertQueryNode" || !isEmptyInsert(args.node)) {
return args.node;
}
const { columns: _columns, ...node } = args.node;
return Object.freeze({
...node,
values: selectNothingFrom(args.node),
});
}
async transformResult(
args: PluginTransformResultArgs,
): Promise<QueryResult<UnknownRow>> {
return args.result;
}
}
function isEmptyInsert(node: InsertQueryNode) {
return (
node.values !== undefined &&
ValuesNode.is(node.values) &&
node.values.values.length === 0
);
}
function selectNothingFrom(node: InsertQueryNode): SelectQueryNode {
return Object.freeze({
...SelectQueryNode.createFrom([node.into!]),
selections: Object.freeze([SelectionNode.createSelectAll()]),
where: WhereNode.create(RawNode.createWithSql("0")),
});
}

View File

@@ -7,6 +7,7 @@ import { Config } from "~/config";
import { ServerConfig } from "~/config.server";
import { logger } from "~/utils/logger";
import { roundToNDecimalPlaces } from "~/utils/number";
import { EmptyValuesNoopPlugin } from "./empty-values-noop-plugin";
import { FastParseJSONResultsPlugin } from "./parse-json-results-plugin";
import type { DB } from "./tables";
import { WriteTrackerPlugin } from "./write-tracker";
@@ -46,7 +47,11 @@ export const db = new Kysely<DB>({
database: sql,
}),
log,
plugins: [new FastParseJSONResultsPlugin(), new WriteTrackerPlugin()],
plugins: [
new EmptyValuesNoopPlugin(),
new FastParseJSONResultsPlugin(),
new WriteTrackerPlugin(),
],
});
// The test database file is created and migrated by scripts/ensure-test-db.ts

View File

@@ -357,12 +357,10 @@ export async function insert(args: InsertArtArgs) {
.returningAll()
.executeTakeFirstOrThrow();
if (args.linkedUsers.length > 0) {
await trx
.insertInto("ArtUserMetadata")
.values(args.linkedUsers.map((userId) => ({ artId: art.id, userId })))
.execute();
}
await trx
.insertInto("ArtUserMetadata")
.values(args.linkedUsers.map((userId) => ({ artId: art.id, userId })))
.execute();
await insertTags({ tags: args.tags, authorId, artId: art.id }, trx);
@@ -402,12 +400,10 @@ export async function update(id: number, args: UpdateArtArgs) {
await trx.deleteFrom("ArtUserMetadata").where("artId", "=", id).execute();
if (args.linkedUsers.length > 0) {
await trx
.insertInto("ArtUserMetadata")
.values(args.linkedUsers.map((userId) => ({ artId: id, userId })))
.execute();
}
await trx
.insertInto("ArtUserMetadata")
.values(args.linkedUsers.map((userId) => ({ artId: id, userId })))
.execute();
await trx.deleteFrom("TaggedArt").where("artId", "=", id).execute();
@@ -429,8 +425,6 @@ async function insertTags(
},
trx: Transaction<DB>,
) {
if (tags.length === 0) return;
const newTagNames = tags
.filter((tag) => !tag.id)
.map((tag) => {
@@ -440,16 +434,13 @@ async function insertTags(
return tag.name;
});
const newTagIds =
newTagNames.length > 0
? (
await trx
.insertInto("ArtTag")
.values(newTagNames.map((name) => ({ name, authorId })))
.returning("ArtTag.id")
.execute()
).map((tag) => tag.id)
: [];
const newTagIds = (
await trx
.insertInto("ArtTag")
.values(newTagNames.map((name) => ({ name, authorId })))
.returning("ArtTag.id")
.execute()
).map((tag) => tag.id);
const tagIds = [
...tags.flatMap((tag) => (tag.id ? [tag.id] : [])),

View File

@@ -48,6 +48,33 @@ describe("syncXPBadges", () => {
});
});
describe("replaceManagers", () => {
test("empty list clears existing managers", async () => {
const user = await UserFactory.create();
const badge = await BadgeFactory.create(null, { managerIds: [user.id] });
await BadgeRepository.replaceManagers({
badgeId: badge.id,
managerIds: [],
});
const updated = await BadgeRepository.findById(badge.id);
expect(updated?.managers).toHaveLength(0);
});
});
describe("replaceOwners", () => {
test("empty list clears existing owners", async () => {
const user = await UserFactory.create();
const badge = await BadgeFactory.create(null, { ownerIds: [user.id] });
await BadgeRepository.replaceOwners({ badgeId: badge.id, ownerIds: [] });
const updated = await BadgeRepository.findById(badge.id);
expect(updated?.owners).toHaveLength(0);
});
});
/** Gives the user a linked X Rank player whose one placement is worth `power`. */
const givePeakXp = (userId: number, power: number) =>
XRankPlacementFactory.create(

View File

@@ -180,17 +180,15 @@ export function replaceManagers({
.where("badgeId", "=", badgeId)
.execute();
if (managerIds.length > 0) {
await trx
.insertInto("BadgeManager")
.values(
managerIds.map((userId) => ({
badgeId,
userId,
})),
)
.execute();
}
await trx
.insertInto("BadgeManager")
.values(
managerIds.map((userId) => ({
badgeId,
userId,
})),
)
.execute();
});
}
@@ -207,23 +205,21 @@ export function replaceOwners({
.where("badgeId", "=", badgeId)
.execute();
if (ownerIds.length > 0) {
const counts = new Map<number, number>();
for (const userId of ownerIds) {
counts.set(userId, (counts.get(userId) ?? 0) + 1);
}
await trx
.insertInto("TournamentBadgeOwner")
.values(
Array.from(counts, ([userId, count]) => ({
badgeId,
userId,
count,
})),
)
.execute();
const counts = new Map<number, number>();
for (const userId of ownerIds) {
counts.set(userId, (counts.get(userId) ?? 0) + 1);
}
await trx
.insertInto("TournamentBadgeOwner")
.values(
Array.from(counts, ([userId, count]) => ({
badgeId,
userId,
count,
})),
)
.execute();
});
}
@@ -262,8 +258,6 @@ export async function syncXPBadges() {
return badgeId ? [{ badgeId, userId }] : [];
});
if (badgeOwners.length === 0) return;
await trx.insertInto("TournamentBadgeOwner").values(badgeOwners).execute();
});
}

View File

@@ -546,10 +546,8 @@ async function insertBuildChildren(
abilityPoints,
})),
);
if (weaponAbilityRows.length > 0) {
await trx
.insertInto("BuildWeaponAbility")
.values(weaponAbilityRows)
.execute();
}
await trx
.insertInto("BuildWeaponAbility")
.values(weaponAbilityRows)
.execute();
}

View File

@@ -506,19 +506,17 @@ export async function insert(args: CreateArgs) {
.executeTakeFirstOrThrow()
).id;
if (copiedStaff.length > 0) {
await trx
.insertInto("TournamentStaff")
.columns(["role", "userId", "tournamentId"])
.values(
copiedStaff.map((staff) => ({
role: staff.role,
userId: staff.userId,
tournamentId: tournamentId!,
})),
)
.execute();
}
await trx
.insertInto("TournamentStaff")
.columns(["role", "userId", "tournamentId"])
.values(
copiedStaff.map((staff) => ({
role: staff.role,
userId: staff.userId,
tournamentId: tournamentId!,
})),
)
.execute();
}
const avatarImgId = args.avatarFileName
@@ -750,8 +748,6 @@ function insertBadges(
{ eventId, badges }: { eventId: number; badges: CreateArgs["badges"] },
trx: Transaction<DB>,
) {
if (!badges.length) return;
return trx
.insertInto("CalendarEventBadge")
.values(
@@ -788,8 +784,6 @@ export function upsertReportedScores(args: {
.where("eventId", "=", args.eventId)
.execute();
if (args.results.length === 0) return;
const insertedTeams = await trx
.insertInto("CalendarEventResultTeam")
.values(
@@ -812,8 +806,6 @@ export function upsertReportedScores(args: {
})),
);
if (players.length === 0) return;
await trx.insertInto("CalendarEventResultPlayer").values(players).execute();
});
}
@@ -840,8 +832,6 @@ async function upsertMapPool(
)
.execute();
if (!mapPoolMaps.length) return;
await trx
.insertInto("MapPoolMap")
.values(

View File

@@ -7,9 +7,7 @@ export function replaceAll(streams: Omit<Tables["LiveStream"], "id">[]) {
return db.transaction().execute(async (trx) => {
await trx.deleteFrom("LiveStream").execute();
if (streams.length > 0) {
await trx.insertInto("LiveStream").values(streams).execute();
}
await trx.insertInto("LiveStream").values(streams).execute();
});
}
@@ -21,8 +19,6 @@ export function replaceAll(streams: Omit<Tables["LiveStream"], "id">[]) {
export function insertTournamentStreamers(
rows: Omit<Tables["TournamentStreamer"], "id">[],
) {
if (rows.length === 0) return Promise.resolve([]);
return db
.insertInto("TournamentStreamer")
.values(rows)

View File

@@ -65,19 +65,17 @@ export async function updateOwnMatchProfile({
.where("userId", "=", userId)
.execute();
if (weaponPool.length > 0) {
await trx
.insertInto("UserWeaponPool")
.values(
weaponPool.map((wpn, i) => ({
userId,
sortOrder: i,
weaponSplId: wpn.id,
isFavorite: toDBBoolean(wpn.isFavorite),
})),
)
.execute();
}
await trx
.insertInto("UserWeaponPool")
.values(
weaponPool.map((wpn, i) => ({
userId,
sortOrder: i,
weaponSplId: wpn.id,
isFavorite: toDBBoolean(wpn.isFavorite),
})),
)
.execute();
await trx
.updateTable("User")

View File

@@ -59,13 +59,11 @@ async function insertMatchSkillsInTransaction(
}
}
if (teamUsers.length > 0) {
await executor
.insertInto("SkillTeamUser")
.values(teamUsers)
.onConflict((oc) => oc.columns(["skillId", "userId"]).doNothing())
.execute();
}
await executor
.insertInto("SkillTeamUser")
.values(teamUsers)
.onConflict((oc) => oc.columns(["skillId", "userId"]).doNothing())
.execute();
if (!oldMatchMemento) return;

View File

@@ -26,8 +26,6 @@ export function upsertMapResults(
>[],
trx?: Transaction<DB>,
) {
if (results.length === 0) return;
const executor = trx ?? db;
return executor
@@ -418,8 +416,6 @@ export function upsertPlayerResults(
results: Tables["PlayerResult"][],
trx?: Transaction<DB>,
) {
if (results.length === 0) return;
const executor = trx ?? db;
return executor

View File

@@ -45,9 +45,7 @@ export async function replaceByMatchId(
.where("groupMatchId", "=", matchId)
.execute();
if (weapons.length > 0) {
await executor.insertInto("ReportedWeapon").values(weapons).execute();
}
await executor.insertInto("ReportedWeapon").values(weapons).execute();
}
export async function deleteOwnByMapIndex({

View File

@@ -566,21 +566,19 @@ export async function closeExpiredContinueVotes() {
)
.execute();
if (members.length > 0) {
await trx
.insertInto("GroupMatchContinueVote")
.values(
members.map((member) => ({
groupId: member.groupId,
userId: member.userId,
isContinuing: 0 as const,
})),
)
.onConflict((oc) =>
oc.columns(["groupId", "userId"]).doUpdateSet({ isContinuing: 0 }),
)
.execute();
}
await trx
.insertInto("GroupMatchContinueVote")
.values(
members.map((member) => ({
groupId: member.groupId,
userId: member.userId,
isContinuing: 0 as const,
})),
)
.onConflict((oc) =>
oc.columns(["groupId", "userId"]).doUpdateSet({ isContinuing: 0 }),
)
.execute();
}
return {

View File

@@ -10,9 +10,7 @@ export async function replaceAll(
await db.transaction().execute(async (trx) => {
await trx.deleteFrom("SplatoonRotation").execute();
if (rotations.length > 0) {
await trx.insertInto("SplatoonRotation").values(rotations).execute();
}
await trx.insertInto("SplatoonRotation").values(rotations).execute();
});
cachedRotations = await queryAll();

View File

@@ -36,8 +36,6 @@ export function findVodsByTournamentId(tournamentId: number) {
}
export function insertMany(vods: Omit<Tables["TournamentMatchVod"], "id">[]) {
if (vods.length === 0) return;
return db
.insertInto("TournamentMatchVod")
.values(vods)

View File

@@ -636,17 +636,15 @@ export function update({
.where("TournamentOrganizationBadge.organizationId", "=", id)
.execute();
if (badges.length > 0) {
await trx
.insertInto("TournamentOrganizationBadge")
.values(
badges.map((badgeId) => ({
organizationId: id,
badgeId,
})),
)
.execute();
}
await trx
.insertInto("TournamentOrganizationBadge")
.values(
badges.map((badgeId) => ({
organizationId: id,
badgeId,
})),
)
.execute();
return updatedOrg;
});

View File

@@ -837,18 +837,16 @@ export function setStaff({
.where("tournamentId", "=", tournamentId)
.execute();
if (staff.length > 0) {
await trx
.insertInto("TournamentStaff")
.values(
staff.map((staffer) => ({
tournamentId,
userId: staffer.userId,
role: staffer.role,
})),
)
.execute();
}
await trx
.insertInto("TournamentStaff")
.values(
staff.map((staffer) => ({
tournamentId,
userId: staffer.userId,
role: staffer.role,
})),
)
.execute();
});
}
@@ -1142,29 +1140,25 @@ export function finalize({
}
}
if (skillTeamUsers.length > 0) {
await trx
.insertInto("SkillTeamUser")
.values(skillTeamUsers)
.onConflict((oc) => oc.columns(["skillId", "userId"]).doNothing())
.execute();
}
await trx
.insertInto("SkillTeamUser")
.values(skillTeamUsers)
.onConflict((oc) => oc.columns(["skillId", "userId"]).doNothing())
.execute();
// SeedingSkill has `on conflict replace` set in its migration
if (summary.seedingSkills.length > 0) {
await trx
.insertInto("SeedingSkill")
.values(
summary.seedingSkills.map((seedingSkill) => ({
type: seedingSkill.type,
mu: seedingSkill.mu,
sigma: seedingSkill.sigma,
ordinal: seedingSkill.ordinal,
userId: seedingSkill.userId,
})),
)
.execute();
}
await trx
.insertInto("SeedingSkill")
.values(
summary.seedingSkills.map((seedingSkill) => ({
type: seedingSkill.type,
mu: seedingSkill.mu,
sigma: seedingSkill.sigma,
ordinal: seedingSkill.ordinal,
userId: seedingSkill.userId,
})),
)
.execute();
if (summary.mapResultDeltas.length > 0) {
invariant(seasonValue !== null, "Season missing for map result");
@@ -1243,12 +1237,7 @@ export function finalize({
userId,
})),
);
if (badgeOwners.length > 0) {
await trx
.insertInto("TournamentBadgeOwner")
.values(badgeOwners)
.execute();
}
await trx.insertInto("TournamentBadgeOwner").values(badgeOwners).execute();
if (trophyReceiver && trophyReceiver.userIds.length > 0) {
const tournamentRow = await trx
@@ -1290,12 +1279,10 @@ export function finalize({
div: tournamentResult.div,
}));
if (tournamentResults.length > 0) {
await trx
.insertInto("TournamentResult")
.values(tournamentResults)
.execute();
}
await trx
.insertInto("TournamentResult")
.values(tournamentResults)
.execute();
await trx
.updateTable("Tournament")

View File

@@ -531,17 +531,15 @@ export function copyFromAnotherTournament({
)
.execute();
if (oldMapPool.length > 0) {
await trx
.insertInto("MapPoolMap")
.values(
oldMapPool.map((mapPoolMap) => ({
...mapPoolMap,
tournamentTeamId: newTeam.id,
})),
)
.execute();
}
await trx
.insertInto("MapPoolMap")
.values(
oldMapPool.map((mapPoolMap) => ({
...mapPoolMap,
tournamentTeamId: newTeam.id,
})),
)
.execute();
});
}
@@ -893,18 +891,16 @@ export function upsertCounterpickMaps({
.where("MapPoolMap.tournamentTeamId", "=", tournamentTeamId)
.execute();
if (mapPool.stageModePairs.length > 0) {
await trx
.insertInto("MapPoolMap")
.values(
mapPool.stageModePairs.map(({ stageId, mode }) => ({
tournamentTeamId,
stageId,
mode,
})),
)
.execute();
}
await trx
.insertInto("MapPoolMap")
.values(
mapPool.stageModePairs.map(({ stageId, mode }) => ({
tournamentTeamId,
stageId,
mode,
})),
)
.execute();
});
}

View File

@@ -605,8 +605,6 @@ async function replaceSpecialTrophyOwners({
}
await deleteStale.execute();
if (userIds.length === 0) return;
await trx
.insertInto("SpecialTrophyOwner")
.values(

View File

@@ -294,8 +294,6 @@ export async function upsertWidgets(
return db.transaction().execute(async (trx) => {
await trx.deleteFrom("UserWidget").where("userId", "=", userId).execute();
if (widgets.length === 0) return;
await trx
.insertInto("UserWidget")
.values(
@@ -1106,19 +1104,17 @@ export function updateOwnProfile(args: UpdateProfileArgs) {
.execute();
}
if (args.weapons.length > 0) {
await trx
.insertInto("UserWeapon")
.values(
args.weapons.map((weapon, i) => ({
userId,
weaponSplId: weapon.weaponSplId,
isFavorite: weapon.isFavorite ?? 0,
order: i + 1,
})),
)
.execute();
}
await trx
.insertInto("UserWeapon")
.values(
args.weapons.map((weapon, i) => ({
userId,
weaponSplId: weapon.weaponSplId,
isFavorite: weapon.isFavorite ?? 0,
order: i + 1,
})),
)
.execute();
return trx
.updateTable("User")
@@ -1220,17 +1216,15 @@ export function updateOwnResultHighlights(args: UpdateResultHighlightsArgs) {
.where("userId", "=", userId)
.execute();
if (args.resultTeamIds.length > 0) {
await trx
.insertInto("UserResultHighlight")
.values(
args.resultTeamIds.map((teamId) => ({
userId,
teamId,
})),
)
.execute();
}
await trx
.insertInto("UserResultHighlight")
.values(
args.resultTeamIds.map((teamId) => ({
userId,
teamId,
})),
)
.execute();
await trx
.updateTable("TournamentResult")

View File

@@ -287,7 +287,6 @@ export async function insert(
.executeTakeFirstOrThrow();
videoId = result.id;
}
if (args.matches.length === 0) return { ...video, id: videoId };
const insertedMatches = await trx
.insertInto("VideoMatch")
@@ -318,9 +317,7 @@ export async function insert(
})),
);
if (players.length > 0) {
await trx.insertInto("VideoMatchPlayer").values(players).execute();
}
await trx.insertInto("VideoMatchPlayer").values(players).execute();
return { ...video, id: videoId };
});