Skip to content

Commit

Permalink
feat(api): random sort (#771)
Browse files Browse the repository at this point in the history
  • Loading branch information
zoriya authored Jan 13, 2025
2 parents 57ae120 + d1609dd commit 4d0a6e5
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 29 deletions.
50 changes: 43 additions & 7 deletions api/src/controllers/movies.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { and, desc, eq, sql } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { and, eq, sql } from "drizzle-orm";
import { Elysia, redirect, t } from "elysia";
import { KError } from "~/models/error";
import { comment } from "~/utils";
import { db } from "../db";
Expand Down Expand Up @@ -95,14 +95,12 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
return error(404, {
status: 404,
message: "Movie not found",
details: undefined,
});
}
if (!ret.translation) {
return error(422, {
status: 422,
message: "Accept-Language header could not be satisfied.",
details: undefined,
});
}
set.headers["content-language"] = ret.translation.language;
Expand Down Expand Up @@ -154,6 +152,41 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
},
},
)
.get(
"random",
async ({ error, redirect }) => {
const [movie] = await db
.select({ id: shows.id })
.from(shows)
.where(eq(shows.kind, "movie"))
.orderBy(sql`random()`)
.limit(1);
if (!movie)
return error(404, {
status: 404,
message: "No movies in the database",
});
return redirect(`/movies/${movie.id}`);
},
{
detail: {
description: "Get a random movie",
},
response: {
302: t.Void({
description:
"Redirected to the [/movies/{id}](#tag/movies/GET/movies/{id}) route.",
}),
404: {
...KError,
description: "No movie found with the given id or slug.",
examples: [
{ status: 404, message: "Movie not found", details: undefined },
],
},
},
},
)
.get(
"",
async ({
Expand All @@ -163,7 +196,6 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
}) => {
const langs = processLanguages(languages);
const [transQ, transCol] = getTranslationQuery(langs, true);

// TODO: Add sql indexes on sort keys

const items = await db
Expand All @@ -177,7 +209,12 @@ 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(
...sort.map((x) => (x.desc ? sql`${shows[x.key]} desc nulls last` : shows[x.key])),
...(sort.random
? [sql`md5(${sort.random.seed} || ${shows.pk})`]
: []),
...sort.sort.map((x) =>
x.desc ? sql`${shows[x.key]} desc nulls last` : shows[x.key],
),
shows.pk,
)
.limit(limit);
Expand All @@ -188,7 +225,6 @@ 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",
Expand Down
5 changes: 3 additions & 2 deletions api/src/controllers/seed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ export const seed = new Elysia()
const err = validateTranslations(body.translations);
if (err) return error(400, err);

const { status, ...ret } = await seedMovie(body);
return error(status, ret);
const ret = await seedMovie(body);
if (ret.status === 422) return error(422, ret);
return error(ret.status, ret);
},
{
body: "seed-movie",
Expand Down
14 changes: 13 additions & 1 deletion api/src/controllers/seed/movies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { conflictUpdateAllExcept } from "~/db/schema/utils";
import type { SeedMovie } from "~/models/movie";
import { processOptImage } from "./images";
import { guessNextRefresh } from "./refresh";
import { KErrorT } from "~/models/error";

type Show = typeof shows.$inferInsert;
type ShowTrans = typeof showTranslations.$inferInsert;
Expand All @@ -30,8 +31,19 @@ export type SeedMovieResponse = typeof SeedMovieResponse.static;
export const seedMovie = async (
seed: SeedMovie,
): Promise<
SeedMovieResponse & { status: "Created" | "OK" | "Conflict" }
| (SeedMovieResponse & { status: "Created" | "OK" | "Conflict" })
| { status: 422; message: string }
> => {
if (seed.slug === "random") {
if (!seed.airDate) {
return {
status: 422,
message: "`random` is a reserved slug. Use something else.",
};
}
seed.slug = `random-${getYear(seed.airDate)}`;
}

const { translations, videos: vids, ...bMovie } = seed;

const ret = await db.transaction(async (tx) => {
Expand Down
25 changes: 21 additions & 4 deletions api/src/models/utils/keyset-paginate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { NonEmptyArray, Sort } from "./sort";
import { eq, or, type Column, and, gt, lt, isNull } from "drizzle-orm";
import { eq, or, type Column, and, gt, lt, isNull, sql } from "drizzle-orm";

type Table<Name extends string> = Record<Name, Column>;

Expand All @@ -24,7 +24,7 @@ export const keysetPaginate = <
sort,
after,
}: {
table: Table<"pk" | Sort<T, Remap>[number]["key"]>;
table: Table<"pk" | Sort<T, Remap>["sort"][number]["key"]>;
after: string | undefined;
sort: Sort<T, Remap>;
}) => {
Expand All @@ -35,11 +35,28 @@ export const keysetPaginate = <

const pkSort = { key: "pk" as const, desc: false };

if (sort.random) {
return or(
gt(
sql`md5(${sort.random.seed} || ${table[pkSort.key]})`,
sql`md5(${sort.random.seed} || ${cursor[0]})`,
),
and(
eq(
sql`md5(${sort.random.seed} || ${table[pkSort.key]})`,
sql`md5(${sort.random.seed} || ${cursor[0]})`,
),
gt(table[pkSort.key], cursor[0]),
),
);
}

// TODO: Add an outer query >= for perf
// 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,
Expand All @@ -62,7 +79,7 @@ export const keysetPaginate = <

export const generateAfter = (cursor: any, sort: Sort<any, any>) => {
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");
Expand Down
7 changes: 6 additions & 1 deletion api/src/models/utils/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,17 @@ export const createPage = <T>(
{ url, sort, limit }: { url: string; sort: Sort<any, any>; limit: number },
) => {
let next: string | null = null;
const uri = new URL(url);

if (sort.random) {
uri.searchParams.set("sort", `random:${sort.random.seed}`);
url = uri.toString();
}

// we can't know for sure if there's a next page when the current page is full.
// maybe the next page is empty, this is a bit weird but it allows us to handle pages
// without making a new request to the db so it's fine.
if (items.length === limit && limit > 0) {
const uri = new URL(url);
uri.searchParams.set("after", generateAfter(items[items.length - 1], sort));
next = uri.toString();
}
Expand Down
42 changes: 29 additions & 13 deletions api/src/models/utils/sort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ export type Sort<
T extends string[],
Remap extends Partial<Record<T[number], string>>,
> = {
key: Exclude<T[number], keyof Remap> | NonNullable<Remap[keyof Remap]>;
remmapedKey?: keyof Remap;
desc: boolean;
}[];
sort: {
key: Exclude<T[number], keyof Remap> | NonNullable<Remap[keyof Remap]>;
remmapedKey?: keyof Remap;
desc: boolean;
}[];
random?: { seed: number };
};

export type NonEmptyArray<T> = [T, ...T[]];

Expand All @@ -29,9 +32,13 @@ export const Sort = <
t
.Transform(
t.Array(
t.UnionEnum([
...values,
...values.map((x: T[number]) => `-${x}` as const),
t.Union([
t.Literal("random"),
t.TemplateLiteral("random:${number}"),
t.UnionEnum([
...values,
...values.map((x: T[number]) => `-${x}` as const),
]),
]),
{
// TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia
Expand All @@ -42,12 +49,21 @@ export const Sort = <
),
)
.Decode((sort): Sort<T, Remap> => {
return sort.map((x) => {
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<typeof key, keyof Remap>, desc };
});
const random = sort.find((x) => x.startsWith("random"));
if (random) {
const seed = random.includes(":")
? Number.parseInt(random.substring("random:".length))
: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
return { random: { seed }, sort: [] };
}
return {
sort: sort.map((x) => {
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<typeof key, keyof Remap>, desc };
}),
};
})
.Encode(() => {
throw new Error("Encode not supported for sort");
Expand Down
81 changes: 80 additions & 1 deletion api/tests/movies/get-all-movies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { shows } from "~/db/schema";
import { bubble } from "~/models/examples";
import { dune1984 } from "~/models/examples/dune-1984";
import { dune } from "~/models/examples/dune-2021";
import { getMovies, movieApp } from "./movies-helper";
import { getMovie, getMovies, movieApp } from "./movies-helper";
import type { Movie } from "~/models/movie";
import { isUuid } from "~/models/utils";

beforeAll(async () => {
await db.delete(shows);
Expand Down Expand Up @@ -120,4 +122,81 @@ describe("Get all movies", () => {
next: null,
});
});

describe("Random sort", () => {
it("No limit, compare order with same seeds", async () => {
// First query
const [resp1, body1] = await getMovies({
sort: "random:100",
});
expectStatus(resp1, body1).toBe(200);
const items1: Movie[] = body1.items;
const items1Ids = items1.map(({ id }) => id);

// Second query
const [resp2, body2] = await getMovies({
sort: "random:100",
});
expectStatus(resp2, body2).toBe(200);
const items2: Movie[] = body2.items;
const items2Ids = items2.map(({ id }) => id);

expect(items1Ids).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]);
});
it("Limit 1, pages 1 and 2, no seed ", async () => {
const [resp, body] = await getMovies({
sort: "random",
limit: 2,
});
expectStatus(resp, body).toBe(200);

const resp2 = await movieApp.handle(new Request(body.next));
const body2 = await resp2.json();
expectStatus(resp2, body).toBe(200);

expect(body2.items.length).toBe(1);
expect(body.items.map((x: Movie) => x.slug)).not.toContain(
body2.items[0].slug,
);
});

it("Get /random", async () => {
const resp = await movieApp.handle(
new Request("http://localhost/movies/random"),
);
expect(resp.status).toBe(302);
const location = resp.headers.get("location")!;
expect(location).toStartWith("/movies/");
const id = location.substring("/movies/".length);
expect(isUuid(id)).toBe(true);
});
});
});
10 changes: 10 additions & 0 deletions api/tests/movies/seed-movies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@ describe("Movie seeding", () => {
});
});

it("Refuses random as a slug", async () => {
const [resp, body] = await createMovie({ ...bubble, slug: "random", airDate: null });
expectStatus(resp, body).toBe(422);
});
it("Refuses random as a slug but fallback w/ airDate", async () => {
const [resp, body] = await createMovie({ ...bubble, slug: "random" });
expectStatus(resp, body).toBe(201);
expect(body.slug).toBe("random-2022");
});

test.todo("Create correct video slug (version)", async () => {});
test.todo("Create correct video slug (part)", async () => {});
test.todo("Create correct video slug (rendering)", async () => {});
Expand Down

0 comments on commit 4d0a6e5

Please sign in to comment.