support custom order modifier

* refactor returned object
This commit is contained in:
Philipinho
2026-01-23 18:38:31 +00:00
parent 8282403ee8
commit 9ec0f46eb6
@@ -1,35 +1,38 @@
// source: https://github.com/charlie-hadden/kysely-paginate/blob/main/src/cursor.ts - MIT // adapted from https://github.com/charlie-hadden/kysely-paginate/blob/main/src/cursor.ts - MIT
import { import {
OrderByDirectionExpression, OrderByDirection,
OrderByModifiers,
ReferenceExpression, ReferenceExpression,
SelectQueryBuilder, SelectQueryBuilder,
StringReference, StringReference,
} from "kysely"; } from 'kysely';
type SortField<DB, TB extends keyof DB, O> = type SortField<DB, TB extends keyof DB, O> =
| { | {
expression: expression:
| (StringReference<DB, TB> & keyof O & string) | (StringReference<DB, TB> & keyof O & string)
| (StringReference<DB, TB> & `${string}.${keyof O & string}`); | (StringReference<DB, TB> & `${string}.${keyof O & string}`);
direction: OrderByDirectionExpression; direction: OrderByDirection;
key?: keyof O & string; orderModifier?: OrderByModifiers;
} key?: keyof O & string;
}
| { | {
expression: ReferenceExpression<DB, TB>; expression: ReferenceExpression<DB, TB>;
direction: OrderByDirectionExpression; direction: OrderByDirection;
key: keyof O & string; orderModifier?: OrderByModifiers;
}; key: keyof O & string;
};
type ExtractSortFieldKey< type ExtractSortFieldKey<
DB, DB,
TB extends keyof DB, TB extends keyof DB,
O, O,
T extends SortField<DB, TB, O>, T extends SortField<DB, TB, O>,
> = T["key"] extends keyof O & string > = T['key'] extends keyof O & string
? T["key"] ? T['key']
: T["expression"] extends keyof O & string : T['expression'] extends keyof O & string
? T["expression"] ? T['expression']
: T["expression"] extends `${string}.${infer K}` : T['expression'] extends `${string}.${infer K}`
? K extends keyof O & string ? K extends keyof O & string
? K ? K
: never : never
@@ -101,19 +104,22 @@ type CursorPaginationResultRow<
: TCursorKey extends false : TCursorKey extends false
? never ? never
: TCursorKey extends true : TCursorKey extends true
? "$cursor" ? '$cursor'
: TCursorKey]: string; : TCursorKey]: string;
}; };
type CursorPaginationMeta = {
limit: number;
hasMore: boolean;
nextCursor: string | null;
};
export type CursorPaginationResult< export type CursorPaginationResult<
TRow, TRow,
TCursorKey extends string | boolean | undefined, TCursorKey extends string | boolean | undefined = undefined,
> = { > = {
startCursor: string | undefined; meta: CursorPaginationMeta;
endCursor: string | undefined; items: CursorPaginationResultRow<TRow, TCursorKey>[];
hasNextPage?: boolean;
hasPrevPage?: boolean;
rows: CursorPaginationResultRow<TRow, TCursorKey>[];
}; };
export async function executeWithCursorPagination< export async function executeWithCursorPagination<
@@ -141,22 +147,22 @@ export async function executeWithCursorPagination<
const decodeCursor = opts.decodeCursor ?? defaultDecodeCursor; const decodeCursor = opts.decodeCursor ?? defaultDecodeCursor;
const parseCursor = const parseCursor =
typeof opts.parseCursor === "function" typeof opts.parseCursor === 'function'
? opts.parseCursor ? opts.parseCursor
: opts.parseCursor.parse; : opts.parseCursor.parse;
const fields = opts.fields.map((field) => { const fields = opts.fields.map((field) => {
let key = field.key; let key = field.key;
if (!key && typeof field.expression === "string") { if (!key && typeof field.expression === 'string') {
const expressionParts = field.expression.split("."); const expressionParts = field.expression.split('.');
key = (expressionParts[1] ?? expressionParts[0]) as key = (expressionParts[1] ?? expressionParts[0]) as
| (keyof O & string) | (keyof O & string)
| undefined; | undefined;
} }
if (!key) throw new Error("missing key"); if (!key) throw new Error('missing key');
return { ...field, key }; return { ...field, key };
}); });
@@ -180,7 +186,7 @@ export async function executeWithCursorPagination<
function applyCursor( function applyCursor(
qb: SelectQueryBuilder<DB, TB, O>, qb: SelectQueryBuilder<DB, TB, O>,
encoded: string, encoded: string,
defaultDirection: "asc" | "desc", defaultDirection: OrderByDirection,
) { ) {
const decoded = decodeCursor(encoded, fieldNames); const decoded = decodeCursor(encoded, fieldNames);
const cursor = parseCursor(decoded); const cursor = parseCursor(decoded);
@@ -192,71 +198,68 @@ export async function executeWithCursorPagination<
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const field = fields[i]!; const field = fields[i]!;
const comparison = field.direction === defaultDirection ? ">" : "<"; const comparison = field.direction === defaultDirection ? '>' : '<';
const value = cursor[field.key as keyof typeof cursor]; const value = cursor[field.key as keyof typeof cursor];
const conditions = [eb(field.expression, comparison, value)]; const conditions = [eb(field.expression, comparison, value)];
if (expression) { if (expression) {
conditions.push(and([eb(field.expression, "=", value), expression])); conditions.push(and([eb(field.expression, '=', value), expression]));
} }
expression = or(conditions); expression = or(conditions);
} }
if (!expression) { if (!expression) {
throw new Error("Error building cursor expression"); throw new Error('Error building cursor expression');
} }
return expression; return expression;
}); });
} }
if (opts.after) qb = applyCursor(qb, opts.after, "asc"); if (opts.after) qb = applyCursor(qb, opts.after, 'asc');
if (opts.before) qb = applyCursor(qb, opts.before, "desc"); if (opts.before) qb = applyCursor(qb, opts.before, 'desc');
const reversed = !!opts.before && !opts.after; const reversed = !!opts.before && !opts.after;
for (const { expression, direction } of fields) { for (const { expression, direction, orderModifier } of fields) {
qb = qb.orderBy( qb = qb.orderBy(
expression, expression,
reversed ? (direction === "asc" ? "desc" : "asc") : direction, orderModifier ??
(reversed ? (direction === 'asc' ? 'desc' : 'asc') : direction),
); );
} }
const rows = await qb.limit(opts.perPage + 1).execute(); const rows = await qb.limit(opts.perPage + 1).execute();
const hasNextPage = reversed ? undefined : rows.length > opts.perPage; const hasMore = rows.length > opts.perPage;
const hasPrevPage = !reversed ? undefined : rows.length > opts.perPage;
// If we fetched an extra row to determine if we have a next page, that // If we fetched an extra row to determine if we have a next page, that
// shouldn't be in the returned results // shouldn't be in the returned results
if (rows.length > opts.perPage) rows.pop(); if (hasMore) rows.pop();
if (reversed) rows.reverse(); if (reversed) rows.reverse();
const startRow = rows[0];
const endRow = rows[rows.length - 1]; const endRow = rows[rows.length - 1];
const nextCursor = hasMore && endRow ? generateCursor(endRow) : null;
const startCursor = startRow ? generateCursor(startRow) : undefined;
const endCursor = endRow ? generateCursor(endRow) : undefined;
return { return {
startCursor, items: rows.map((row) => {
endCursor,
hasNextPage,
hasPrevPage,
rows: rows.map((row) => {
if (opts.cursorPerRow) { if (opts.cursorPerRow) {
const cursorKey = const cursorKey =
typeof opts.cursorPerRow === "string" ? opts.cursorPerRow : "$cursor"; typeof opts.cursorPerRow === 'string' ? opts.cursorPerRow : '$cursor';
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
(row as any)[cursorKey] = generateCursor(row); (row as any)[cursorKey] = generateCursor(row);
} }
return row as CursorPaginationResultRow<O, TCursorKey>; return row as CursorPaginationResultRow<O, TCursorKey>;
}), }),
meta: {
limit: opts.perPage,
hasMore,
nextCursor,
},
}; };
} }
@@ -270,16 +273,16 @@ export function defaultEncodeCursor<
for (const [key, value] of values) { for (const [key, value] of values) {
switch (typeof value) { switch (typeof value) {
case "string": case 'string':
cursor.set(key, value); cursor.set(key, value);
break; break;
case "number": case 'number':
case "bigint": case 'bigint':
cursor.set(key, value.toString(10)); cursor.set(key, value.toString(10));
break; break;
case "object": { case 'object': {
if (value instanceof Date) { if (value instanceof Date) {
cursor.set(key, value.toISOString()); cursor.set(key, value.toISOString());
break; break;
@@ -292,7 +295,7 @@ export function defaultEncodeCursor<
} }
} }
return Buffer.from(cursor.toString(), "utf8").toString("base64url"); return Buffer.from(cursor.toString(), 'utf8').toString('base64url');
} }
export function defaultDecodeCursor< export function defaultDecodeCursor<
@@ -309,15 +312,15 @@ export function defaultDecodeCursor<
try { try {
parsed = [ parsed = [
...new URLSearchParams( ...new URLSearchParams(
Buffer.from(cursor, "base64url").toString("utf8"), Buffer.from(cursor, 'base64url').toString('utf8'),
).entries(), ).entries(),
]; ];
} catch { } catch {
throw new Error("Unparsable cursor"); throw new Error('Unparsable cursor');
} }
if (parsed.length !== fields.length) { if (parsed.length !== fields.length) {
throw new Error("Unexpected number of fields"); throw new Error('Unexpected number of fields');
} }
for (let i = 0; i < fields.length; i++) { for (let i = 0; i < fields.length; i++) {
@@ -325,11 +328,11 @@ export function defaultDecodeCursor<
const expectedName = fields[i]; const expectedName = fields[i];
if (!field) { if (!field) {
throw new Error("Unable to find field"); throw new Error('Unable to find field');
} }
if (field[0] !== expectedName) { if (field[0] !== expectedName) {
throw new Error("Unexpected field name"); throw new Error('Unexpected field name');
} }
} }