diff --git a/apps/backend/services/djs.service.ts b/apps/backend/services/djs.service.ts index f25e317..6e8334b 100644 --- a/apps/backend/services/djs.service.ts +++ b/apps/backend/services/djs.service.ts @@ -14,7 +14,7 @@ import { specialty_shows, user, } from '@wxyc/database'; -import { and, eq, isNull } from 'drizzle-orm'; +import { and, eq, inArray, isNull } from 'drizzle-orm'; export const addToBin = async (bin_entry: NewBinEntry): Promise => { const added_bin_entry = await db.insert(bins).values(bin_entry).returning(); @@ -63,47 +63,58 @@ type ShowPeek = { // ERRORS IN SERVICES ARE 500 ERRORS export const getPlaylistsForDJ = async (dj_id: string) => { - // gets a 'preview set' of 4 artists/albums and the show id for each show the dj has been in const this_djs_shows = await db.select().from(show_djs).where(eq(show_djs.dj_id, dj_id)); - const show_previews = []; - for (let i = 0; i < this_djs_shows.length; i++) { - const show = await db.select().from(shows).where(eq(shows.id, this_djs_shows[i].show_id)); - - const djs_involved = await db - .select({ dj_id: show_djs.dj_id, dj_name: user.djName }) - .from(show_djs) - .innerJoin(user, and(eq(show_djs.show_id, show[0].id), eq(show_djs.dj_id, user.id))); - - const peek_object: ShowPeek = { - show: show[0].id, - show_name: show[0].show_name ?? '', - date: show[0].start_time, - djs: djs_involved, - specialty_show: '', - preview: [], - }; - - if (show[0].specialty_id != null) { - const specialty_show = await db - .select() - .from(specialty_shows) - .where(eq(specialty_shows.id, show[0].specialty_id)); - peek_object.specialty_show = specialty_show[0].specialty_name; - } - - //get 4 track entries to display in preview - const entries: FSEntry[] = await db - .select() - .from(flowsheet) - .limit(4) - .where(and(eq(flowsheet.show_id, show[0].id), isNull(flowsheet.message))); - - peek_object.preview = entries; - show_previews.push(peek_object); + if (this_djs_shows.length === 0) return []; + + const showIds = this_djs_shows.map((s) => s.show_id); + + const allShows = await db.select().from(shows).where(inArray(shows.id, showIds)); + + const allDjs = await db + .select({ dj_id: show_djs.dj_id, dj_name: user.djName, show_id: show_djs.show_id }) + .from(show_djs) + .innerJoin(user, eq(show_djs.dj_id, user.id)) + .where(inArray(show_djs.show_id, showIds)); + + const specialtyIds = allShows.filter((s) => s.specialty_id != null).map((s) => s.specialty_id!); + + const allSpecialties = + specialtyIds.length > 0 + ? await db.select().from(specialty_shows).where(inArray(specialty_shows.id, specialtyIds)) + : []; + + const allEntries: FSEntry[] = await db + .select() + .from(flowsheet) + .where(and(inArray(flowsheet.show_id, showIds), isNull(flowsheet.message))); + + const specialtyMap = new Map(allSpecialties.map((s) => [s.id, s.specialty_name])); + const djsByShow = new Map(); + for (const dj of allDjs) { + const list = djsByShow.get(dj.show_id) ?? []; + list.push({ dj_id: dj.dj_id, dj_name: dj.dj_name }); + djsByShow.set(dj.show_id, list); + } + const entriesByShow = new Map(); + for (const entry of allEntries) { + if (entry.show_id == null) continue; + const list = entriesByShow.get(entry.show_id) ?? []; + list.push(entry); + entriesByShow.set(entry.show_id, list); } - return show_previews; + return allShows.map((show) => { + const preview = (entriesByShow.get(show.id) ?? []).slice(0, 4); + return { + show: show.id, + show_name: show.show_name ?? '', + date: show.start_time, + djs: djsByShow.get(show.id) ?? [], + specialty_show: specialtyMap.get(show.specialty_id!) ?? '', + preview, + } satisfies ShowPeek; + }); }; export const getPlaylist = async (show_id: number) => { diff --git a/tests/unit/services/djs.getPlaylistsForDJ.test.ts b/tests/unit/services/djs.getPlaylistsForDJ.test.ts new file mode 100644 index 0000000..150ddda --- /dev/null +++ b/tests/unit/services/djs.getPlaylistsForDJ.test.ts @@ -0,0 +1,166 @@ +let selectCallCount = 0; +const selectSpy = jest.fn(); +const queryResults: unknown[][] = []; + +function createChain(resolveIndex: number) { + const resolver = () => queryResults[resolveIndex] ?? []; + const chain: Record = {}; + for (const m of ['select', 'from', 'where', 'innerJoin', 'leftJoin', 'limit', 'orderBy']) { + chain[m] = jest.fn(() => chain); + } + chain.then = (onFulfill: (v: unknown) => unknown, onReject?: (e: unknown) => unknown) => + Promise.resolve(resolver()).then(onFulfill, onReject); + return chain; +} + +jest.mock('@wxyc/database', () => ({ + db: { + select: (...args: unknown[]) => { + selectSpy(...args); + return createChain(selectCallCount++); + }, + }, + show_djs: { dj_id: 'dj_id', show_id: 'show_id' }, + shows: { id: 'id', specialty_id: 'specialty_id' }, + specialty_shows: { id: 'id', specialty_name: 'specialty_name' }, + flowsheet: { show_id: 'show_id', message: 'message' }, + user: { id: 'id', djName: 'djName' }, + bins: {}, + library: {}, + artists: {}, + format: {}, + genres: {}, +})); + +jest.mock('drizzle-orm', () => ({ + eq: jest.fn((a, b) => ({ eq: [a, b] })), + and: jest.fn((...args: unknown[]) => ({ and: args })), + isNull: jest.fn((col) => ({ isNull: col })), + inArray: jest.fn((col, vals) => ({ inArray: [col, vals] })), +})); + +import { getPlaylistsForDJ } from '../../../apps/backend/services/djs.service'; + +const DJ_ID = 'dj-1'; + +function makeShowDjRows(count: number) { + return Array.from({ length: count }, (_, i) => ({ + show_id: i + 1, + dj_id: DJ_ID, + active: true, + })); +} + +function makeShowRows(count: number) { + return Array.from({ length: count }, (_, i) => ({ + id: i + 1, + primary_dj_id: DJ_ID, + specialty_id: i === 0 ? 10 : null, + show_name: `Show ${i + 1}`, + start_time: new Date(`2024-01-${String(i + 1).padStart(2, '0')}T20:00:00Z`), + end_time: null, + })); +} + +function makeDjsForShows(count: number) { + return Array.from({ length: count }, (_, i) => ({ + dj_id: DJ_ID, + dj_name: 'DJ One', + show_id: i + 1, + })); +} + +function makeFlowsheetRows(showCount: number) { + const entries = []; + for (let s = 0; s < showCount; s++) { + for (let e = 0; e < 4; e++) { + entries.push({ + id: s * 4 + e + 1, + show_id: s + 1, + album_id: null, + rotation_id: null, + entry_type: 'track', + track_title: `Track ${e + 1}`, + album_title: `Album ${e + 1}`, + artist_name: `Artist ${e + 1}`, + record_label: null, + play_order: e + 1, + request_flag: false, + message: null, + add_time: new Date(), + }); + } + } + return entries; +} + +describe('djs.service - getPlaylistsForDJ', () => { + beforeEach(() => { + selectCallCount = 0; + selectSpy.mockClear(); + queryResults.length = 0; + }); + + const NUM_SHOWS = 5; + const MAX_ALLOWED_SELECTS = 6; + + it(`should make at most ${MAX_ALLOWED_SELECTS} select() calls, not O(N) for ${NUM_SHOWS} shows`, async () => { + const showDjRows = makeShowDjRows(NUM_SHOWS); + const showRows = makeShowRows(NUM_SHOWS); + const djRows = makeDjsForShows(NUM_SHOWS); + const specialtyRows = [{ id: 10, specialty_name: 'Jazz After Hours' }]; + const flowsheetRows = makeFlowsheetRows(NUM_SHOWS); + + // Provide enough results for both the batched (5 queries) and N+1 (17+ queries) paths. + // Batched order: [showDjs, shows, allDjs, specialties, flowsheet] + // N+1 order: [showDjs, show1, djs1, specialty1, fs1, show2, djs2, fs2, ...] + // We fill enough slots so either code path can run without crashing. + queryResults.push(showDjRows); // 0: show_djs for DJ + queryResults.push(showRows); // 1: batched shows / N+1 show[0] + queryResults.push(djRows); // 2: batched djs / N+1 djs[0] + queryResults.push(specialtyRows); // 3: batched specialty / N+1 specialty[0] + queryResults.push(flowsheetRows); // 4: batched flowsheet / N+1 flowsheet[0] + // Extra slots for N+1 loop iterations (shows 2-5) + for (let i = 1; i < NUM_SHOWS; i++) { + queryResults.push([showRows[i]]); // show + queryResults.push([{ dj_id: DJ_ID, dj_name: 'DJ One' }]); // djs + if (showRows[i].specialty_id != null) { + queryResults.push(specialtyRows); + } + queryResults.push(flowsheetRows.filter((e) => e.show_id === i + 1)); // flowsheet + } + + await getPlaylistsForDJ(DJ_ID); + + const totalSelectCalls = selectSpy.mock.calls.length; + expect(totalSelectCalls).toBeLessThanOrEqual(MAX_ALLOWED_SELECTS); + }); + + it('returns correct ShowPeek structures', async () => { + const showDjRows = makeShowDjRows(2); + const showRows = makeShowRows(2); + const djRows = makeDjsForShows(2); + const specialtyRows = [{ id: 10, specialty_name: 'Jazz After Hours' }]; + const flowsheetRows = makeFlowsheetRows(2); + + queryResults.push(showDjRows); + queryResults.push(showRows); + queryResults.push(djRows); + queryResults.push(specialtyRows); + queryResults.push(flowsheetRows); + // N+1 fallback slots + queryResults.push([showRows[1]]); + queryResults.push([{ dj_id: DJ_ID, dj_name: 'DJ One' }]); + queryResults.push(flowsheetRows.filter((e) => e.show_id === 2)); + + const result = await getPlaylistsForDJ(DJ_ID); + + expect(result).toHaveLength(2); + expect(result[0]).toHaveProperty('show'); + expect(result[0]).toHaveProperty('show_name'); + expect(result[0]).toHaveProperty('date'); + expect(result[0]).toHaveProperty('djs'); + expect(result[0]).toHaveProperty('specialty_show'); + expect(result[0]).toHaveProperty('preview'); + }); +});