diff --git a/api/package.json b/api/package.json index 465af2a8..feffaf22 100644 --- a/api/package.json +++ b/api/package.json @@ -20,6 +20,5 @@ "@types/pg": "^8.11.10", "bun-types": "^1.1.42" }, - "module": "src/index.js", - "packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72" + "module": "src/index.js" } diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 30c5494b..5951d966 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -157,13 +157,12 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .get( "", async ({ - query: { limit, after, sort, filter, random }, + query: { limit, after, sort, filter }, headers: { "accept-language": languages }, request: { url }, }) => { const langs = processLanguages(languages); const [transQ, transCol] = getTranslationQuery(langs, true); - // TODO: Add sql indexes on sort keys const items = await db @@ -177,10 +176,14 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .innerJoin(transQ, eq(shows.pk, transQ.pk)) .where(and(filter, keysetPaginate({ table: shows, after, sort }))) .orderBy( - ...(random !== undefined - ? [sql`md5(${random} || ${shows.pk} )`] + ...(sort.random !== undefined + ? [ + sort.random.desc + ? sql`md5(${sort.random.seed} || ${shows.pk}) desc` + : sql`md5(${sort.random.seed} || ${shows.pk})`, + ] : []), - ...sort.map((x) => + ...sort.sort.map((x) => x.desc ? sql`${shows[x.key]} desc nulls last` : shows[x.key], ), shows.pk, @@ -193,17 +196,10 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) detail: { description: "Get all movies" }, query: t.Object({ sort: Sort(["slug", "rating", "airDate", "createdAt", "nextRefresh"], { - // TODO: Add random remap: { airDate: "startAir" }, default: ["slug"], description: "How to sort the query", }), - random: t.Optional( - t.Integer({ - minimum: 0, - description: "Seed to shuffle items", - }), - ), filter: t.Optional(Filter({ def: movieFilters })), limit: t.Integer({ minimum: 1, diff --git a/api/src/models/utils/keyset-paginate.ts b/api/src/models/utils/keyset-paginate.ts index 6bcba223..29aa14ba 100644 --- a/api/src/models/utils/keyset-paginate.ts +++ b/api/src/models/utils/keyset-paginate.ts @@ -24,7 +24,7 @@ export const keysetPaginate = < sort, after, }: { - table: Table<"pk" | Sort[number]["key"]>; + table: Table<"pk" | Sort["sort"][number]["key"]>; after: string | undefined; sort: Sort; }) => { @@ -39,7 +39,7 @@ export const keysetPaginate = < // PERF: See https://use-the-index-luke.com/sql/partial-results/fetch-next-page#sb-equivalent-logic let where = undefined; let previous = undefined; - for (const [i, by] of [...sort, pkSort].entries()) { + for (const [i, by] of [...sort.sort, pkSort].entries()) { const cmp = by.desc ? lt : gt; where = or( where, @@ -62,7 +62,7 @@ export const keysetPaginate = < export const generateAfter = (cursor: any, sort: Sort) => { const ret = [ - ...sort.map((by) => cursor[by.remmapedKey ?? by.key]), + ...sort.sort.map((by) => cursor[by.remmapedKey ?? by.key]), cursor.pk, ]; return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64url"); diff --git a/api/src/models/utils/sort.ts b/api/src/models/utils/sort.ts index e452688a..cc841997 100644 --- a/api/src/models/utils/sort.ts +++ b/api/src/models/utils/sort.ts @@ -1,13 +1,16 @@ -import { t, TSchema } from "elysia"; +import { t } from "elysia"; export type Sort< T extends string[], Remap extends Partial>, > = { - key: Exclude | NonNullable; - remmapedKey?: keyof Remap; - desc: boolean; -}[]; + sort: { + key: Exclude | NonNullable; + remmapedKey?: keyof Remap; + desc: boolean; + }[]; + random?: { desc: boolean; seed: number }; +}; export type NonEmptyArray = [T, ...T[]]; @@ -29,9 +32,15 @@ export const Sort = < t .Transform( t.Array( - t.UnionEnum([ - ...values, - ...values.map((x: T[number]) => `-${x}` as const), + t.Union([ + t.UnionEnum([ + ...values, + ...values.map((x: T[number]) => `-${x}` as const), + ]), + t.Union([ + t.TemplateLiteral("random:${number}"), + t.TemplateLiteral("-random:${number}"), + ]), ]), { // TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia @@ -42,12 +51,39 @@ export const Sort = < ), ) .Decode((sort): Sort => { - return sort.map((x) => { + const sortItems: Sort["sort"] = []; + let random: Sort["random"] = undefined; + for (const x of sort) { const desc = x[0] === "-"; const key = (desc ? x.substring(1) : x) as T[number]; - if (key in remap) return { key: remap[key]!, remmapedKey: key, desc }; - return { key: key as Exclude, desc }; - }); + if (key == "random") { + random = { + seed: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER), + desc, + }; + continue; + } else if (key.startsWith("random:")) { + const strSeed = key.replace("random:", ""); + random = { + seed: parseInt(strSeed), + desc, + }; + continue; + } + + if (key in remap) { + sortItems.push({ key: remap[key]!, remmapedKey: key, desc }); + } else { + sortItems.push({ + key: key as Exclude, + desc, + }); + } + } + return { + sort: sortItems, + random, + }; }) .Encode(() => { throw new Error("Encode not supported for sort"); diff --git a/api/tests/movies/get-all-movies.test.ts b/api/tests/movies/get-all-movies.test.ts index 76da7e14..9b25526f 100644 --- a/api/tests/movies/get-all-movies.test.ts +++ b/api/tests/movies/get-all-movies.test.ts @@ -126,7 +126,7 @@ describe("Get all movies", () => { it("No limit, compare order with same seeds", async () => { // First query let [resp1, body1] = await getMovies({ - random: 100, + sort: "random:100", }); expectStatus(resp1, body1).toBe(200); const items1: Movie[] = body1.items; @@ -134,7 +134,7 @@ describe("Get all movies", () => { // Second query let [resp2, body2] = await getMovies({ - random: 100, + sort: "random:100", }); expectStatus(resp2, body2).toBe(200); const items2: Movie[] = body2.items; @@ -145,7 +145,7 @@ describe("Get all movies", () => { it("No limit, compare order with different seeds", async () => { // First query let [resp1, body1] = await getMovies({ - random: 100, + sort: "random:100", }); expectStatus(resp1, body1).toBe(200); const items1: Movie[] = body1.items; @@ -153,14 +153,42 @@ describe("Get all movies", () => { // Second query let [resp2, body2] = await getMovies({ - random: 1, + sort: "random:5", }); expectStatus(resp2, body2).toBe(200); const items2: Movie[] = body2.items; const items2Ids = items2.map(({ id }) => id); - console.log(items1Ids, items2Ids); expect(items1Ids).not.toEqual(items2Ids); }); + + it("Limit 1, pages 1 and 2 ", async () => { + // First query fetches all + // use the result to know what is expected + let [resp, body] = await getMovies({ + sort: "random:1234", + }); + expectStatus(resp, body).toBe(200); + let items: Movie[] = body.items; + const expectedIds = items.map(({ id }) => id); + + // Get First Page + [resp, body] = await getMovies({ + sort: "random:1234", + limit: 1, + }); + expectStatus(resp, body).toBe(200); + items = body.items; + expect(items.length).toBe(1); + expect(items[0].id).toBe(expectedIds[0]); + // Get Second Page + resp = await movieApp.handle(new Request(body.next)); + body = await resp.json(); + + expectStatus(resp, body).toBe(200); + items = body.items; + expect(items.length).toBe(1); + expect(items[0].id).toBe(expectedIds[1]); + }); }); }); diff --git a/api/tests/movies/movies-helper.ts b/api/tests/movies/movies-helper.ts index 825da29e..9d160b15 100644 --- a/api/tests/movies/movies-helper.ts +++ b/api/tests/movies/movies-helper.ts @@ -30,7 +30,6 @@ export const getMovies = async ({ limit?: number; after?: string; sort?: string | string[]; - random?: number; langs?: string; }) => { const resp = await movieApp.handle(