Use skip/take cursors instead of page number

This commit is contained in:
jeffvli
2022-07-30 15:28:30 -07:00
parent b8cf1d8283
commit aa673ac854
15 changed files with 154 additions and 132 deletions
@@ -16,11 +16,11 @@ const getAlbumArtists = async (req: Request, res: Response) => {
}), }),
}); });
const { limit, page, serverFolderIds } = req.query; const { take, skip, serverFolderIds } = req.query;
const data = await albumArtistsService.findMany(req, { const data = await albumArtistsService.findMany(req, {
limit: Number(limit),
page: Number(page),
serverFolderIds: String(serverFolderIds), serverFolderIds: String(serverFolderIds),
skip: Number(skip),
take: Number(take),
user: req.auth, user: req.auth,
}); });
+4 -3
View File
@@ -38,15 +38,16 @@ const getAlbums = async (req: Request, res: Response) => {
}), }),
}); });
const { limit, page, serverFolderIds, serverUrls, sortBy, orderBy } = const { take, serverFolderIds, serverUrls, sortBy, orderBy, skip } =
req.query; req.query;
const data = await albumsService.findMany(req, { const data = await albumsService.findMany(req, {
limit: Number(limit),
orderBy: orderBy as SortOrder, orderBy: orderBy as SortOrder,
page: Number(page),
serverFolderIds: serverFolderIds && String(serverFolderIds), serverFolderIds: serverFolderIds && String(serverFolderIds),
serverUrls: serverUrls && String(serverUrls), serverUrls: serverUrls && String(serverUrls),
skip: Number(skip),
sortBy: sortBy as AlbumSort, sortBy: sortBy as AlbumSort,
take: Number(take),
user: req.auth, user: req.auth,
}); });
+3 -3
View File
@@ -27,11 +27,11 @@ const getArtists = async (req: Request, res: Response) => {
}), }),
}); });
const { limit, page, serverFolderIds } = req.query; const { take, skip, serverFolderIds } = req.query;
const data = await artistsService.findMany(req, { const data = await artistsService.findMany(req, {
limit: Number(limit),
page: Number(page),
serverFolderIds: String(serverFolderIds), serverFolderIds: String(serverFolderIds),
skip: Number(skip),
take: Number(take),
user: req.auth, user: req.auth,
}); });
+3 -3
View File
@@ -18,12 +18,12 @@ const getSongs = async (req: Request, res: Response) => {
}), }),
}); });
const { limit, page, serverFolderIds } = req.query; const { take, skip, serverFolderIds } = req.query;
const data = await songsService.findMany(req, { const data = await songsService.findMany(req, {
limit: Number(limit),
page: Number(page),
serverFolderIds: String(serverFolderIds), serverFolderIds: String(serverFolderIds),
skip: Number(skip),
take: Number(take),
user: req.auth, user: req.auth,
}); });
-1
View File
@@ -6,7 +6,6 @@ import { songHelpers } from './songs.helpers';
export enum AlbumSort { export enum AlbumSort {
DATE_ADDED = 'date_added', DATE_ADDED = 'date_added',
DATE_ADDED_REMOTE = 'date_added_remote', DATE_ADDED_REMOTE = 'date_added_remote',
DATE_PLAYED = 'date_played',
DATE_RELEASED = 'date_released', DATE_RELEASED = 'date_released',
RANDOM = 'random', RANDOM = 'random',
RATING = 'rating', RATING = 'rating',
+5 -1
View File
@@ -14,7 +14,11 @@ export const errorHandler = (
}); });
if (err.message) { if (err.message) {
message = isJsonString(err.message) ? JSON.parse(err.message) : err.message; message = isJsonString(err.message)
? Array.isArray(JSON.parse(err.message))
? JSON.parse(err.message)[0].message // Handles errors sent from zod preprocess
: JSON.parse(err.message)
: err.message;
} }
res.status(err.statusCode || 500).json({ res.status(err.statusCode || 500).json({
+5 -7
View File
@@ -39,7 +39,7 @@ const findMany = async (
req: Request, req: Request,
options: { serverFolderIds: string; user: User } & OffsetPagination options: { serverFolderIds: string; user: User } & OffsetPagination
) => { ) => {
const { user, limit, page, serverFolderIds: rServerFolderIds } = options; const { user, take, serverFolderIds: rServerFolderIds, skip } = options;
const serverFolderIds = splitNumberString(rServerFolderIds); const serverFolderIds = splitNumberString(rServerFolderIds);
if (!(await folderPermissions(serverFolderIds!, user))) { if (!(await folderPermissions(serverFolderIds!, user))) {
@@ -52,23 +52,21 @@ const findMany = async (
}; };
}); });
const startIndex = limit * page;
const totalEntries = await prisma.albumArtist.count({ const totalEntries = await prisma.albumArtist.count({
where: { OR: serverFoldersFilter }, where: { OR: serverFoldersFilter },
}); });
const albumArtists = await prisma.albumArtist.findMany({ const albumArtists = await prisma.albumArtist.findMany({
include: { genres: true }, include: { genres: true },
skip: startIndex, skip,
take: limit, take,
where: { OR: serverFoldersFilter }, where: { OR: serverFoldersFilter },
}); });
return ApiSuccess.ok({ return ApiSuccess.ok({
data: albumArtists, data: albumArtists,
paginationItems: { paginationItems: {
limit, skip,
page, take,
startIndex,
totalEntries, totalEntries,
url: req.originalUrl, url: req.originalUrl,
}, },
+8 -10
View File
@@ -55,10 +55,10 @@ const findMany = async (
) => { ) => {
const { const {
user, user,
limit, take,
page,
serverFolderIds: rServerFolderIds, serverFolderIds: rServerFolderIds,
serverUrls, serverUrls,
skip,
sortBy, sortBy,
orderBy, orderBy,
} = options; } = options;
@@ -75,7 +75,6 @@ const findMany = async (
serverFolderIds! serverFolderIds!
); );
const startIndex = limit * page;
let totalEntries = 0; let totalEntries = 0;
let albums: Album[]; let albums: Album[];
@@ -94,8 +93,8 @@ const findMany = async (
}, },
}, },
orderBy: { value: orderBy }, orderBy: { value: orderBy },
skip: startIndex, skip,
take: limit, take,
where: { where: {
album: { OR: serverFoldersFilter }, album: { OR: serverFoldersFilter },
user: { id: user.id }, user: { id: user.id },
@@ -113,8 +112,8 @@ const findMany = async (
prisma.album.findMany({ prisma.album.findMany({
include: { ...albumHelpers.include({ serverUrls, songs: false }) }, include: { ...albumHelpers.include({ serverUrls, songs: false }) },
orderBy: [{ ...albumHelpers.sort(sortBy, orderBy) }], orderBy: [{ ...albumHelpers.sort(sortBy, orderBy) }],
skip: startIndex, skip,
take: limit, take,
where: { OR: serverFoldersFilter }, where: { OR: serverFoldersFilter },
}), }),
]); ]);
@@ -126,9 +125,8 @@ const findMany = async (
return ApiSuccess.ok({ return ApiSuccess.ok({
data: toRes.albums(albums, user), data: toRes.albums(albums, user),
paginationItems: { paginationItems: {
limit, skip,
page, take,
startIndex,
totalEntries, totalEntries,
url: req.originalUrl, url: req.originalUrl,
}, },
+5 -7
View File
@@ -35,7 +35,7 @@ const findMany = async (
req: Request, req: Request,
options: { serverFolderIds: string; user: User } & OffsetPagination options: { serverFolderIds: string; user: User } & OffsetPagination
) => { ) => {
const { user, limit, page, serverFolderIds: rServerFolderIds } = options; const { user, skip, take, serverFolderIds: rServerFolderIds } = options;
const serverFolderIds = splitNumberString(rServerFolderIds); const serverFolderIds = splitNumberString(rServerFolderIds);
if (!(await folderPermissions(serverFolderIds!, user))) { if (!(await folderPermissions(serverFolderIds!, user))) {
@@ -52,23 +52,21 @@ const findMany = async (
}; };
}); });
const startIndex = limit * page;
const totalEntries = await prisma.artist.count({ const totalEntries = await prisma.artist.count({
where: { OR: serverFoldersFilter }, where: { OR: serverFoldersFilter },
}); });
const artists = await prisma.artist.findMany({ const artists = await prisma.artist.findMany({
include: { genres: true }, include: { genres: true },
skip: startIndex, skip,
take: limit, take,
where: { OR: serverFoldersFilter }, where: { OR: serverFoldersFilter },
}); });
return ApiSuccess.ok({ return ApiSuccess.ok({
data: artists, data: artists,
paginationItems: { paginationItems: {
limit, skip,
page, take,
startIndex,
totalEntries, totalEntries,
url: req.originalUrl, url: req.originalUrl,
}, },
+7 -10
View File
@@ -1,6 +1,6 @@
import { Request } from 'express'; import { Request } from 'express';
import { prisma } from '../lib'; import { prisma } from '../lib';
import { OffsetPagination, User } from '../types/types'; import { User } from '../types/types';
import { import {
ApiError, ApiError,
ApiSuccess, ApiSuccess,
@@ -52,8 +52,8 @@ const findMany = async (
artistIds: rawArtistIds, artistIds: rawArtistIds,
songIds: rawSongIds, songIds: rawSongIds,
user, user,
limit, skip,
page, take,
serverFolderIds: rServerFolderIds, serverFolderIds: rServerFolderIds,
} = options; } = options;
const serverFolderIds = splitNumberString(rServerFolderIds); const serverFolderIds = splitNumberString(rServerFolderIds);
@@ -75,8 +75,6 @@ const findMany = async (
serverFolders: { some: { id: { in: serverFolderIds } } }, serverFolders: { some: { id: { in: serverFolderIds } } },
}; };
const startIndex = limit * page;
const [totalEntries, songs] = await prisma.$transaction([ const [totalEntries, songs] = await prisma.$transaction([
prisma.song.count({ prisma.song.count({
where: { where: {
@@ -96,8 +94,8 @@ const findMany = async (
images: true, images: true,
serverFolders: { include: { server: true } }, serverFolders: { include: { server: true } },
}, },
skip: startIndex, skip,
take: limit, take,
where: { OR: serverFoldersFilter }, where: { OR: serverFoldersFilter },
}), }),
]); ]);
@@ -105,9 +103,8 @@ const findMany = async (
return ApiSuccess.ok({ return ApiSuccess.ok({
data: songs, data: songs,
paginationItems: { paginationItems: {
limit, skip,
page, take,
startIndex,
totalEntries, totalEntries,
url: req.originalUrl, url: req.originalUrl,
}, },
+4 -5
View File
@@ -113,8 +113,8 @@ export type Task = {
}; };
export type OffsetPagination = { export type OffsetPagination = {
limit: number; skip: number;
page: number; take: number;
}; };
export type PaginationResponse = { export type PaginationResponse = {
@@ -131,9 +131,8 @@ export type SuccessResponse = {
}; };
export type PaginationItems = { export type PaginationItems = {
limit: number; skip: number;
page: number; take: number;
startIndex: number;
totalEntries: number; totalEntries: number;
url: string; url: string;
}; };
+16 -17
View File
@@ -1,17 +1,16 @@
import { PaginationItems } from '../types/types'; import { PaginationItems } from '../types/types';
const getPaginationUrl = (url: string, action: 'next' | 'prev') => { const getPaginationUrl = (
const currentPageRegex = url.match(/page=(\d+)/gm); url: string,
skip: number,
if (currentPageRegex) { take: number,
const currentPage = Number(currentPageRegex[0].split('=')[1]); action: 'next' | 'prev'
const newPage = action === 'next' ? currentPage + 1 : currentPage - 1; ) => {
const normalizedUrl = process.env.APP_BASE_URL?.replace(/\/$/, ''); if (action === 'next') {
return url.replace(/skip=(\d+)/gm, `skip=${skip + take}`);
return `${normalizedUrl}${url.replace(/page=\d+/gm, `page=${newPage}`)}`;
} }
return null; return url.replace(/skip=(\d+)/gm, `skip=${skip - take}`);
}; };
export const getSuccessResponse = (options: { export const getSuccessResponse = (options: {
@@ -24,15 +23,15 @@ export const getSuccessResponse = (options: {
let pagination; let pagination;
if (paginationItems) { if (paginationItems) {
const { startIndex, totalEntries, limit, url, page } = paginationItems; const { skip, totalEntries, take, url } = paginationItems;
const hasPrevPage = startIndex - limit >= 0;
const hasNextPage = startIndex + limit <= totalEntries; const hasPrevPage = skip - take >= 0;
const hasNextPage = skip + take <= totalEntries;
pagination = { pagination = {
currentPage: page, nextPage: hasNextPage ? getPaginationUrl(url, skip, take, 'next') : null,
nextPage: hasNextPage ? getPaginationUrl(url, 'next') : null, prevPage: hasPrevPage ? getPaginationUrl(url, skip, take, 'prev') : null,
prevPage: hasPrevPage ? getPaginationUrl(url, 'prev') : null, skip,
startIndex,
totalEntries, totalEntries,
}; };
} }
-1
View File
@@ -7,7 +7,6 @@ export * from './split-text-string';
export * from './folder-permissions'; export * from './folder-permissions';
export * from './is-array-equal'; export * from './is-array-equal';
export * from './is-json-string'; export * from './is-json-string';
export * from './validate-request';
export * from './unique-array'; export * from './unique-array';
export * from './zod-validation'; export * from './zod-validation';
export * from './get-image-url'; export * from './get-image-url';
-56
View File
@@ -1,56 +0,0 @@
// Taken from zod-express-middleware: https://github.com/Aquila169/zod-express-middleware
import { ZodError, ZodSchema } from 'zod';
import { ApiError } from './api-error';
type RequestValidation<TParams, TQuery, TBody> = {
body?: ZodSchema<TBody>;
params?: ZodSchema<TParams>;
query?: ZodSchema<TQuery>;
};
type ErrorListItem = {
errors: ZodError<any>;
type: 'Query' | 'Params' | 'Body';
};
export const validateRequest = (
req: any,
schemas: RequestValidation<any, any, any>
) => {
const { params, query, body } = schemas;
const errors: Array<ErrorListItem> = [];
if (params) {
const parsed = params.safeParse(req.params);
if (!parsed.success) {
errors.push({ errors: parsed.error, type: 'Params' });
}
}
if (query) {
const parsed = query.safeParse(req.query);
if (!parsed.success) {
errors.push({ errors: parsed.error, type: 'Query' });
}
}
if (body) {
const parsed = body.safeParse(req.body);
if (!parsed.success) {
errors.push({ errors: parsed.error, type: 'Body' });
}
}
if (errors.length > 0) {
const message = JSON.stringify(
[
`[${errors[0].type}]`,
`[${errors[0].errors.issues[0].path[0]}]`,
errors[0].errors.issues[0].message,
].join(' ')
);
throw ApiError.badRequest(message);
}
};
+91 -5
View File
@@ -1,11 +1,97 @@
import { z } from 'zod'; // Taken from zod-express-middleware: https://github.com/Aquila169/zod-express-middleware
import { z, ZodError, ZodSchema } from 'zod';
import { ApiError } from './api-error';
export enum ValidationType {
BODY = 'Body',
PARAMS = 'Params',
QUERY = 'Query',
}
type RequestValidation<TParams, TQuery, TBody> = {
body?: ZodSchema<TBody>;
params?: ZodSchema<TParams>;
query?: ZodSchema<TQuery>;
};
type ErrorListItem = {
errors: ZodError<any>;
type: ValidationType;
};
export const validateRequest = (
req: any,
schemas: RequestValidation<any, any, any>
) => {
const { params, query, body } = schemas;
const errors: Array<ErrorListItem> = [];
if (params) {
const parsed = params.safeParse(req.params);
if (!parsed.success) {
errors.push({ errors: parsed.error, type: ValidationType.PARAMS });
}
}
if (query) {
const parsed = query.safeParse(req.query);
if (!parsed.success) {
errors.push({ errors: parsed.error, type: ValidationType.QUERY });
}
}
if (body) {
const parsed = body.safeParse(req.body);
if (!parsed.success) {
errors.push({ errors: parsed.error, type: ValidationType.BODY });
}
}
if (errors.length > 0) {
const message = JSON.stringify(
[
`(${errors[0].type})`,
`[${errors[0].errors.issues[0].path[0]}]`,
errors[0].errors.issues[0].message,
].join(' ')
);
throw ApiError.badRequest(message);
}
};
const requiredErrorMessage = (
type: 'Query' | 'Body' | 'Params',
key: string
) => {
return `(${type}) [${key}] Required`;
};
export const paginationValidation = { export const paginationValidation = {
limit: z.preprocess( skip: z.preprocess(
(a) => parseInt(z.string().parse(a), 10), (a) =>
z.number().min(0).max(1000) parseInt(
z
.string({
required_error: requiredErrorMessage(ValidationType.QUERY, 'skip'),
})
.parse(a),
10
),
z.number().min(0, { message: 'Must have skip' })
),
take: z.preprocess(
(a) =>
parseInt(
z
.string({
required_error: requiredErrorMessage(ValidationType.QUERY, 'take'),
})
.parse(a),
10
),
z.number().min(0)
), ),
page: z.preprocess((a) => parseInt(z.string().parse(a), 10), z.number()),
}; };
export const idValidation = { export const idValidation = {