diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index e132b96e..d3b11bc2 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -67,25 +67,32 @@ const unknownFilters: FilterDef = { }; const entrySort = Sort( - [ - "order", - "seasonNumber", - "episodeNumber", - "number", - "airDate", - "nextRefresh", - ], + { + order: entries.order, + seasonNumber: entries.seasonNumber, + episodeNumber: entries.episodeNumber, + number: entries.episodeNumber, + airDate: entries.airDate, + nextRefresh: entries.nextRefresh, + }, { default: ["order"], - remap: { - number: "episodeNumber", - }, + tablePk: entries.pk, }, ); -const extraSort = Sort(["slug", "name", "runtime", "createdAt"], { - default: ["slug"], -}); +const extraSort = Sort( + { + slug: entries.slug, + name: entryTranslations.name, + runtime: entries.runtime, + createdAt: entries.createdAt, + }, + { + default: ["slug"], + tablePk: entries.pk, + }, +); async function getEntries({ after, @@ -166,13 +173,13 @@ async function getEntries({ and( filter, query ? sql`${transQ.name} %> ${query}::text` : undefined, - keysetPaginate({ table: entries, after, sort }), + keysetPaginate({ after, sort }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${transQ.name})`] - : sortToSql(sort, entries)), + : sortToSql(sort)), entries.pk, ) .limit(limit); diff --git a/api/src/controllers/seasons.ts b/api/src/controllers/seasons.ts index f578bb44..d4559727 100644 --- a/api/src/controllers/seasons.ts +++ b/api/src/controllers/seasons.ts @@ -26,6 +26,19 @@ const seasonFilters: FilterDef = { endAir: { column: seasons.endAir, type: "date" }, }; +const seasonSort = Sort( + { + seasonNumber: seasons.seasonNumber, + startAir: seasons.startAir, + endAir: seasons.endAir, + nextRefresh: seasons.nextRefresh, + }, + { + default: ["seasonNumber"], + tablePk: seasons.pk, + }, +); + export const seasonsH = new Elysia({ tags: ["series"] }) .model({ season: Season, @@ -82,13 +95,13 @@ export const seasonsH = new Elysia({ tags: ["series"] }) eq(seasons.showPk, serie.pk), filter, query ? sql`${transQ.name} %> ${query}::text` : undefined, - keysetPaginate({ table: seasons, after, sort }), + keysetPaginate({ after, sort }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${transQ.name})`] - : sortToSql(sort, seasons)), + : sortToSql(sort)), seasons.pk, ) .limit(limit); @@ -104,9 +117,7 @@ export const seasonsH = new Elysia({ tags: ["series"] }) }), }), query: t.Object({ - sort: Sort(["seasonNumber", "startAir", "endAir", "nextRefresh"], { - default: ["seasonNumber"], - }), + sort: seasonSort, filter: t.Optional(Filter({ def: seasonFilters })), query: t.Optional(t.String({ description: desc.query })), limit: t.Integer({ diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 311d3a16..50812663 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -57,18 +57,18 @@ export const showFilters: FilterDef = { }, }; export const showSort = Sort( - [ - "slug", - "rating", - "airDate", - "startAir", - "endAir", - "createdAt", - "nextRefresh", - ], { - remap: { airDate: "startAir" }, + slug: shows.slug, + rating: shows.rating, + airDate: shows.startAir, + startAir: shows.startAir, + endAir: shows.endAir, + createdAt: shows.createdAt, + nextRefresh: shows.nextRefresh, + }, + { default: ["slug"], + tablePk: shows.pk, }, ); @@ -209,13 +209,13 @@ export async function getShows({ and( filter, query ? sql`${transQ.name} %> ${query}::text` : undefined, - keysetPaginate({ table: shows, after, sort }), + keysetPaginate({ after, sort }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${transQ.name})`] - : sortToSql(sort, shows)), + : sortToSql(sort)), shows.pk, ) .limit(limit); diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts index 70696fdd..be2f3a3c 100644 --- a/api/src/controllers/staff.ts +++ b/api/src/controllers/staff.ts @@ -23,18 +23,36 @@ import { import { desc } from "~/models/utils/descriptions"; import { showFilters, showSort } from "./shows/logic"; -const staffSort = Sort(["slug", "name", "latinName"], { default: ["slug"] }); +const staffSort = Sort( + { + slug: staff.slug, + name: staff.name, + latinName: staff.latinName, + }, + { + default: ["slug"], + tablePk: staff.pk, + }, +); const staffRoleSort = Sort( - [ - "order", - // "slug", - // "name", - // "latinName", - // "characterName", "characterLatinName" - ], + { + order: roles.order, + slug: { sql: staff.slug, accessor: (x) => x.staff.slug }, + name: { sql: staff.name, accessor: (x) => x.staff.name }, + latinName: { sql: staff.latinName, accessor: (x) => x.staff.latinName }, + characterName: { + sql: sql`${roles.character}->'name'`, + accessor: (x) => x.character.name, + }, + characterLatinName: { + sql: sql`${roles.character}->'latinName'`, + accessor: (x) => x.character.latinName, + }, + }, { default: ["order"], + tablePk: staff.pk, }, ); @@ -75,13 +93,13 @@ async function getStaffRoles({ and( filter, query ? sql`${staff.name} %> ${query}::text` : undefined, - keysetPaginate({ table: roles, sort, after }), + keysetPaginate({ sort, after }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${staff.name})`] - : sortToSql(sort, roles)), + : sortToSql(sort)), shows.pk, ) .limit(limit); @@ -217,13 +235,13 @@ export const staffH = new Elysia({ tags: ["staff"] }) eq(roles.staffPk, member.pk), filter, query ? sql`${transQ.name} %> ${query}::text` : undefined, - keysetPaginate({ table: shows, after, sort }), + keysetPaginate({ after, sort }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${transQ.name})`] - : sortToSql(sort, shows)), + : sortToSql(sort)), roles.showPk, ) .limit(limit); @@ -275,13 +293,13 @@ export const staffH = new Elysia({ tags: ["staff"] }) .where( and( query ? sql`${staff.name} %> ${query}::text` : undefined, - keysetPaginate({ table: staff, after, sort }), + keysetPaginate({ after, sort }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${staff.name})`] - : sortToSql(sort, staff)), + : sortToSql(sort)), staff.pk, ) .limit(limit); diff --git a/api/src/controllers/studios.ts b/api/src/controllers/studios.ts index 8a9806bd..04b1d290 100644 --- a/api/src/controllers/studios.ts +++ b/api/src/controllers/studios.ts @@ -34,7 +34,16 @@ import { import { desc } from "~/models/utils/descriptions"; import { getShows, showFilters, showSort } from "./shows/logic"; -const studioSort = Sort(["slug", "createdAt"], { default: ["slug"] }); +const studioSort = Sort( + { + slug: studios.slug, + createdAt: studios.createdAt, + }, + { + default: ["slug"], + tablePk: studios.pk, + }, +); const studioRelations = { translations: () => { @@ -101,13 +110,13 @@ export async function getStudios({ and( filter, query ? sql`${transQ.name} %> ${query}::text` : undefined, - keysetPaginate({ table: studios, after, sort }), + keysetPaginate({ after, sort }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${transQ.name})`] - : sortToSql(sort, studios)), + : sortToSql(sort)), studios.pk, ) .limit(limit); diff --git a/api/src/models/utils/keyset-paginate.ts b/api/src/models/utils/keyset-paginate.ts index 0307367f..fa91b93d 100644 --- a/api/src/models/utils/keyset-paginate.ts +++ b/api/src/models/utils/keyset-paginate.ts @@ -1,7 +1,5 @@ -import { type Column, and, eq, gt, isNull, lt, or, sql } from "drizzle-orm"; -import type { NonEmptyArray, Sort } from "./sort"; - -type Table = Record; +import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm"; +import type { Sort } from "./sort"; type After = (string | number | boolean | undefined)[]; @@ -16,37 +14,37 @@ type After = (string | number | boolean | undefined)[]; // (x > a) OR // (x = a AND y < b) OR // (x = a AND y = b AND z > c) OR... -export const keysetPaginate = < - const T extends NonEmptyArray, - const Remap extends Partial>, ->({ - table, +export const keysetPaginate = ({ sort, after, }: { - table: Table<"pk" | Sort["sort"][number]["key"]>; + sort: Sort | undefined; after: string | undefined; - sort: Sort | undefined; }) => { if (!after || !sort) return undefined; const cursor: After = JSON.parse( Buffer.from(after, "base64").toString("utf-8"), ); - const pkSort = { key: "pk" as const, desc: false }; + const pkSort = { + sql: sort.tablePk, + isNullable: false, + accessor: (x: any) => x.pk, + desc: false, + }; if (sort.random) { return or( gt( - sql`md5(${sort.random.seed} || ${table[pkSort.key]})`, + sql`md5(${sort.random.seed} || ${sort.tablePk})`, sql`md5(${sort.random.seed} || ${cursor[0]})`, ), and( eq( - sql`md5(${sort.random.seed} || ${table[pkSort.key]})`, + sql`md5(${sort.random.seed} || ${sort.tablePk})`, sql`md5(${sort.random.seed} || ${cursor[0]})`, ), - gt(table[pkSort.key], cursor[0]), + gt(sort.tablePk, cursor[0]), ), ); } @@ -62,31 +60,19 @@ export const keysetPaginate = < where, and( previous, - or( - cmp(table[by.key], cursor[i]), - !table[by.key].notNull ? isNull(table[by.key]) : undefined, - ), + or(cmp(by.sql, cursor[i]), by.isNullable ? isNull(by.sql) : undefined), ), ); previous = and( previous, - cursor[i] === null ? isNull(table[by.key]) : eq(table[by.key], cursor[i]), + cursor[i] === null ? isNull(by.sql) : eq(by.sql, cursor[i]), ); } return where; }; -export const generateAfter = < - const ST extends NonEmptyArray, - const Remap extends Partial> = never, ->( - cursor: any, - sort: Sort, -) => { - const ret = [ - ...sort.sort.map((by) => cursor[by.remmapedKey ?? by.key]), - cursor.pk, - ]; +export const generateAfter = (cursor: any, sort: Sort) => { + const ret = [...sort.sort.map((by) => by.accessor(cursor)), cursor.pk]; return Buffer.from(JSON.stringify(ret), "utf-8").toString("base64url"); }; diff --git a/api/src/models/utils/page.ts b/api/src/models/utils/page.ts index 56fad491..86a4b222 100644 --- a/api/src/models/utils/page.ts +++ b/api/src/models/utils/page.ts @@ -1,7 +1,7 @@ import type { ObjectOptions } from "@sinclair/typebox"; import { type TSchema, t } from "elysia"; import { generateAfter } from "./keyset-paginate"; -import type { NonEmptyArray, Sort } from "./sort"; +import type { Sort } from "./sort"; export const Page = (schema: T, options?: ObjectOptions) => t.Object( @@ -16,13 +16,9 @@ export const Page = (schema: T, options?: ObjectOptions) => }, ); -export const createPage = < - T, - const ST extends NonEmptyArray, - const Remap extends Partial> = never, ->( +export const createPage = ( items: T[], - { url, sort, limit }: { url: string; sort: Sort; limit: number }, + { url, sort, limit }: { url: string; sort: Sort; limit: number }, ) => { let next: string | null = null; const uri = new URL(url); diff --git a/api/src/models/utils/sort.ts b/api/src/models/utils/sort.ts index 06c3a3b6..cd3a053c 100644 --- a/api/src/models/utils/sort.ts +++ b/api/src/models/utils/sort.ts @@ -1,34 +1,40 @@ -import { sql } from "drizzle-orm"; +import { type SQL, type SQLWrapper, sql } from "drizzle-orm"; import type { PgColumn } from "drizzle-orm/pg-core"; import { t } from "elysia"; -export type Sort< - T extends string[], - Remap extends Partial>, -> = { +export type Sort = { + tablePk: SQLWrapper; sort: { - key: Exclude | NonNullable; - remmapedKey?: keyof Remap; + sql: SQLWrapper; + isNullable: boolean; + accessor: (cursor: any) => unknown; desc: boolean; }[]; random?: { seed: number }; }; -export type NonEmptyArray = [T, ...T[]]; - -export const Sort = < - const T extends NonEmptyArray, - const Remap extends Partial> = never, ->( - values: T, +export const Sort = ( + values: Record< + string, + | PgColumn + | { + sql: PgColumn; + accessor: (cursor: any) => unknown; + } + | { + sql: SQLWrapper; + isNullable: boolean; + accessor: (cursor: any) => unknown; + } + >, { description = "How to sort the query", default: def, - remap, + tablePk, }: { - default?: T[number][]; + default?: (keyof typeof values)[]; + tablePk: SQLWrapper; description?: string; - remap?: Remap; }, ) => t @@ -36,10 +42,10 @@ export const Sort = < t.Array( t.Union([ t.UnionEnum([ - ...values, - ...values.map((x: T[number]) => `-${x}` as const), + ...Object.keys(values), + ...Object.keys(values).map((x) => `-${x}`), "random", - ]), + ] as any), t.TemplateLiteral("random:${number}"), ]), { @@ -48,21 +54,36 @@ export const Sort = < }, ), ) - .Decode((sort): Sort => { + .Decode((sort: string[]): Sort => { 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 { tablePk, random: { seed }, sort: [] }; } return { + tablePk, sort: sort.map((x) => { const desc = x[0] === "-"; - const key = (desc ? x.substring(1) : x) as T[number]; - if (remap && key in remap) - return { key: remap[key]!, remmapedKey: key, desc }; - return { key: key as Exclude, desc }; + const key = desc ? x.substring(1) : x; + if ("getSQL" in values[key]) { + return { + sql: values[key], + isNullable: !values[key].notNull, + accessor: (x) => x[key], + desc, + }; + } + return { + sql: values[key].sql, + isNullable: + "isNullable" in values[key] + ? values[key].isNullable + : !values[key].sql.notNull, + accessor: values[key].accessor, + desc, + }; }), }; }) @@ -70,20 +91,12 @@ export const Sort = < throw new Error("Encode not supported for sort"); }); -type Table = Record; - -export const sortToSql = < - T extends string[], - Remap extends Partial>, ->( - sort: Sort | undefined, - table: Table["sort"][number]["key"] | "pk">, -) => { +export const sortToSql = (sort: Sort | undefined) => { if (!sort) return []; if (sort.random) { - return [sql`md5(${sort.random.seed} || ${table.pk})`]; + return [sql`md5(${sort.random.seed} || ${sort.tablePk})`]; } return sort.sort.map((x) => - x.desc ? sql`${table[x.key]} desc nulls last` : table[x.key], + x.desc ? sql`${x.sql} desc nulls last` : (x.sql as SQL), ); };