import { PagingError } from "./errors";
import { Adapter } from "../persistence/Adapter";
import { Context } from "../persistence/Context";
import { prefixMethod } from "../utils/utils";
import { UnsupportedError } from "../persistence/errors";
import { Model } from "@decaf-ts/decorator-validation";
import { Constructor } from "@decaf-ts/decoration";
import { LoggedClass } from "@decaf-ts/logging";
import {
ContextualArgs,
MaybeContextualArg,
} from "../utils/ContextualLoggedClass";
import { DirectionLimitOffset, PreparedStatement } from "./types";
import { PreparedStatementKeys } from "./constants";
import { Repository } from "../repository/Repository";
import { SerializationError } from "@decaf-ts/db-decorators";
/**
* @description Handles pagination for database queries
* @summary Provides functionality for navigating through paginated query results
*
* This abstract class manages the state and navigation of paginated database query results.
* It tracks the current page, total pages, and record count, and provides methods for
* moving between pages.
*
* @template M - The model type this paginator operates on
* @template R - The return type of the paginated query (defaults to M[])
* @template Q - The query type (defaults to any)
* @param {Adapter<any, Q, any, any>} adapter - The database adapter to use for executing queries
* @param {Q} query - The query to paginate
* @param {number} size - The number of records per page
* @param {Constructor<M>} clazz - The constructor for the model type
* @class Paginator
* @example
* // Create a paginator for a user query
* const userQuery = db.select().from(User);
* const paginator = await userQuery.paginate(10); // 10 users per page
*
* // Get the first page of results
* const firstPage = await paginator.page(1);
*
* // Navigate to the next page
* const secondPage = await paginator.next();
*
* // Get information about the pagination
* console.log(`Page ${paginator.current} of ${paginator.total}, ${paginator.count} total records`);
*
* @mermaid
* sequenceDiagram
* participant Client
* participant Paginator
* participant Adapter
* participant Database
*
* Client->>Paginator: new Paginator(adapter, query, size, clazz)
* Client->>Paginator: page(1)
* Paginator->>Paginator: validatePage(1)
* Paginator->>Paginator: prepare(query)
* Paginator->>Adapter: execute query with pagination
* Adapter->>Database: execute query
* Database-->>Adapter: return results
* Adapter-->>Paginator: return results
* Paginator-->>Client: return page results
*
* Client->>Paginator: next()
* Paginator->>Paginator: page(current + 1)
* Paginator->>Paginator: validatePage(current + 1)
* Paginator->>Adapter: execute query with pagination
* Adapter->>Database: execute query
* Database-->>Adapter: return results
* Adapter-->>Paginator: return results
* Paginator-->>Client: return page results
*/
export abstract class Paginator<
M extends Model,
R = M[],
Q = any,
> extends LoggedClass {
protected _currentPage!: number;
protected _totalPages!: number;
protected _recordCount!: number;
protected _bookmark?: number | string;
protected limit!: number;
private _statement?: Q;
get current() {
return this._currentPage;
}
get total() {
return this._totalPages;
}
get count(): number {
return this._recordCount;
}
protected get statement() {
if (!this._statement) this._statement = this.prepare(this.query as Q);
return this._statement;
}
protected constructor(
protected readonly adapter: Adapter<any, any, Q, any>,
protected readonly query: Q | PreparedStatement<M>,
readonly size: number,
protected readonly clazz: Constructor<M>
) {
super();
prefixMethod(this, this.page, this.pagePrefix, this.page.name);
}
protected isPreparedStatement() {
const query = this.query as PreparedStatement<any>;
return (
query.method &&
query.method.match(
new RegExp(
`${PreparedStatementKeys.FIND_BY}|${PreparedStatementKeys.LIST_BY}|${PreparedStatementKeys.FIND}`,
"gi"
)
)
);
}
protected async pagePrefix(page?: number, ...args: MaybeContextualArg<any>) {
const { ctxArgs } = (
await this.adapter["logCtx"](
[this.clazz, ...args],
PreparedStatementKeys.PAGE_BY,
true
)
).for(this.pagePrefix);
ctxArgs.shift();
return [page, ...ctxArgs];
}
protected async pagePrepared(
page: number,
bookmark?: any,
...argz: ContextualArgs<any>
): Promise<M[]> {
const { log, ctxArgs } = this.adapter["logCtx"](
bookmark && !(bookmark instanceof Context)
? [...argz]
: [bookmark, ...argz],
this.pagePrepared
);
log.debug(
`Running paged prepared statement ${page} page${bookmark ? ` - bookmark ${bookmark}` : ""}`
);
if (bookmark && !(bookmark instanceof Context)) this._bookmark = bookmark;
const repo = Repository.forModel(this.clazz, this.adapter.alias);
const statement = this.query as PreparedStatement<M>;
const { method, args, params } = statement;
if (method === PreparedStatementKeys.FIND) {
const preparedArgs = [PreparedStatementKeys.PAGE, ...args];
const preparedParams: DirectionLimitOffset = {
limit: this.size,
offset: page,
bookmark: this._bookmark,
};
preparedArgs.push(preparedParams);
const result = await repo.statement(
...(preparedArgs as [string, any]),
...ctxArgs
);
return this.apply(result);
}
const regexp = new RegExp(
`^${PreparedStatementKeys.FIND_BY}|${PreparedStatementKeys.LIST_BY}`,
"gi"
);
if (!method.match(regexp))
throw new UnsupportedError(
`Method ${method} is not supported for pagination`
);
regexp.lastIndex = 0;
const pagedMethod = method.replace(regexp, PreparedStatementKeys.PAGE_BY);
const preparedArgs = [pagedMethod, ...args];
let preparedParams: DirectionLimitOffset = {
limit: this.size,
offset: page,
bookmark: this._bookmark,
};
if (
pagedMethod === PreparedStatementKeys.PAGE_BY &&
preparedArgs.length <= 2
) {
preparedArgs.push(params.direction);
} else {
preparedParams = {
direction: params.direction,
limit: this.size,
offset: page,
bookmark: this._bookmark,
};
}
preparedArgs.push(preparedParams);
const result = await repo.statement(
...(preparedArgs as [string, any]),
...ctxArgs
);
return this.apply(result);
}
/**
* @description Prepares a statement for pagination
* @summary Modifies the raw query statement to include pagination parameters.
* This protected method sets the limit parameter on the query to match the page size.
* @param {RawRamQuery<M>} rawStatement - The original query statement
* @return {RawRamQuery<M>} The modified query with pagination parameters
*/
protected abstract prepare(rawStatement: Q): Q;
async next(...args: MaybeContextualArg<any>) {
return this.page(this.current + 1, ...args);
}
async previous(...args: MaybeContextualArg<any>) {
return this.page(this.current - 1, ...args);
}
protected validatePage(page: number) {
if (page < 1 || !Number.isInteger(page))
throw new PagingError(
"Page number cannot be under 1 and must be an integer"
);
if (typeof this._totalPages !== "undefined" && page > this._totalPages)
throw new PagingError(
`Only ${this._totalPages} are available. Cannot go to page ${page}`
);
return page;
}
async page(
page: number = 1,
bookmark?: any,
...args: MaybeContextualArg<any>
): Promise<R> {
const { ctxArgs } = this.adapter["logCtx"]([bookmark, ...args], this.page);
if (this.isPreparedStatement())
return (await this.pagePrepared(page, ...ctxArgs)) as R;
throw new UnsupportedError(
"Raw support not available without subclassing this"
);
}
serialize(data: M[], toString: boolean = false): string | SerializedPage<M> {
const serialization: SerializedPage<M> = {
data: data,
current: this.current,
total: this.total,
count: this.count,
bookmark: this._bookmark,
};
try {
return toString ? JSON.stringify(serialization) : serialization;
} catch (e: unknown) {
throw new SerializationError(e as Error);
}
}
apply(serialization: string | SerializedPage<M>): M[] {
const ser =
typeof serialization === "string"
? Paginator.deserialize<M>(serialization)
: serialization;
this._currentPage = ser.current;
this._totalPages = ser.total;
this._recordCount = ser.count;
this._bookmark = ser.bookmark;
return ser.data;
}
static deserialize<M extends Model>(str: string): SerializedPage<M> {
try {
return JSON.parse(str);
} catch (e: unknown) {
throw new SerializationError(e as Error);
}
}
static isSerializedPage(obj: SerializedPage<any> | any) {
return (
obj &&
typeof obj === "object" &&
Array.isArray(obj.data) &&
typeof obj.total === "number" &&
typeof obj.current === "number" &&
typeof obj.count === "number"
);
}
}
export type SerializedPage<M extends Model> = {
current: number;
total: number;
count: number;
data: M[];
bookmark?: number | string;
};
Source