objection-paginator
Version:
Paginated queries for Objection.js
398 lines (397 loc) • 14.7 kB
TypeScript
import { Model, QueryBuilder } from "objection";
import { SortDescriptor } from "./sort-descriptor.js";
/**
* Paginator instance configuration.
*/
export interface PaginatorOptions {
/**
* The maxiumum number of items to fetch in a page. Defaults to 1000.
*/
limit?: number;
/**
* The name of the sort to use, as defined in the static sorts property.
* Defaults to 'default'.
*/
sort?: string;
}
/**
* Options provided to the static ::getPage method.
*/
export interface GetPageOptions extends PaginatorOptions {
/**
* The cursor to resume from, if any.
*/
cursor?: string | null;
}
/**
* Represents the result of a single paginated query.
*/
export interface Page<T extends Model> {
/**
* The model instances returned by the query.
*/
items: T[];
/**
* The number of items remaining after this page.
*/
remaining: number;
/**
* The cursor string for getting the next page.
*/
cursor: string;
}
/**
* A tuple that includes an item of type T if and only if T is not undefined.
*
* @remarks
* This is used in the constructor and getPage type signatures in order to make
* the TArgs type argument fully optional. You may need it if you intend to
* override either of these.
*/
export declare type If<T> = T extends undefined ? [] : [T];
/**
* A generic interface that must be implemented by all Paginator constructors.
*
* @remarks
* This is used as a workaround to bind generic types to the static getPage
* method, which is otherwise not possible in TypeScript. You may need it if you
* intend to override this method.
*/
export interface PaginatorConstructor<TModel extends Model, TArgs = undefined> {
new (options?: PaginatorOptions, ...rest: If<TArgs>): Paginator<TModel, TArgs>;
}
/**
* A base class for defining paginated queries in Objection.
*
* @remarks
* This class accepts the following type parameters:
* - TModel: The type of Model instances returned by queries. Must be a subtype
* of the Objection Model class.
* - TArgs: Required aruments for queries. If omittied, no arguments are
* required.
*
* To define a paginated query, extend this class, providing the TModel as well
* as the TArgs if desired. You must provide an implementation for the
* `#getBaseQuery` method, and you probably should at least define a default
* sort on the static `sorts` property. Any alternate sorting methods should be
* defined there as well.
*
* When defining sorts, you should take care to ensure that the combination of
* sort values will always produce a deterministic sort order. In other words,
* the combination of specified columns should always be unique within your
* database. If you fail to do this, the sort order may vary based on the
* implementation details of your database, possibly causing inconsistent
* pagination.
*
* @example
* The following subtype defines a paginated query on people, sorted by their
* firstName, lastName, then id. The id is included since it is known to be
* unique, while the combination of firstName and lastName is not:
*
* ```ts
* import { Paginator } from 'objection-paginator';
* import { Person } from '../models/person';
* import { QueryBuilder } from 'objection';
*
* export class People extends Paginator<Person> {
* static sorts = { default: [ 'firstName', 'lastName', 'id' ] };
*
* // Note: *do not* mark this method as `async`. You want to return the
* // builder itself, without executing it. This allows the paginator to
* // mutate it as needed before executing.
* getBaseQuery(): QueryBuilder<Person> {
* return Person.query();
* }
* }
*
* ```
*
* Additional arguments for your query, which can be specified using the TArgs
* property, will be available through `this.args` in the `#getBaseQuery`
* method:
*
* ```ts
* export interface PeopleWithFirstNameArgs {
* firstName: string;
* }
*
* export class PeopleWithFirstName extends Paginator<
* Person,
* PeopleWithFirstNameArgs,
* > {
* static sorts = { default: [ 'lastName', 'id' ] };
*
* getBaseQuery(): QueryBuilder<Person> {
* return Person.query().where({ firstName: this.args.firstName });
* }
* }
*
* ```
*
* For more examples, see the README and the `test/lib` directory of this
* module's GitHub repo.
*/
export declare abstract class Paginator<TModel extends Model, TArgs = undefined> {
/**
* A map from supported sort names to sort descriptor arrays.
*
* @remarks
* Each item in a descriptor array should be a single column name, or a full
* sort descriptor object. Fetched items will be sorted by the first
* descriptor, then the next, and so on.
*
* For each concrete Paginator subtype, you should at least specify a
* default sort. Additional sorts can be added by simply adding more
* properties to this map.
*/
static sorts?: Record<string, (SortDescriptor | string)[]>;
/**
* Used to uniquely identify a Paginator subtype.
*
* @remarks
* This will be stored in cursors created by this Paginator subtype, and
* will be checked when consuming cursors to ensure cursors from completely
* unrelated queries aren't being sent in by clients.
*
* This defaults to the constructor name of your subtype, so there is
* usually no need to set it unless there's a naming collision or you'd
* simply like to specify your own.
*/
static queryName?: string;
/**
* Cached sort nodes, created within each subtype the first time it is used.
*/
private static _sortNodes?;
/**
* The maximum number of items to fetch for a page.
*
* @remarks
* For optimization purposes, this property is read-only. If you need to
* change the limit, simply create another instance.
*/
readonly limit: number;
/**
* The name of the sort to use, as defined in the static sorts property.
*
* @remarks
* For optimization purposes, this property is read-only. If you need to
* change the sort, simply create another instance.
*/
readonly sort: string;
/**
* The args provided to the instance, if any.
*/
args: TArgs;
/**
* Creates a Paginator.
*
* @remarks
* Since this class is abstract, you will need to create a subtype before
* you can use this constructor. It will throw if called directly.
*
* @param options - Instance-level configuration options.
* @param rest - Remaining parameters. Will include the paginator args,
* if any.
*/
constructor(options?: PaginatorOptions, ...rest: If<TArgs>);
/**
* Creates and executes a Paginator in one call.
*
* @remarks
* Like the constructor itself, this method cannot be used except through
* a non-abstract subtype. It will throw if you try.
*
* @param options - Instance-level configuration options, along with an
* optional cursor string.
* @param rest - Remaining parameters. Will include the paginator args, if
* any.
*/
static getPage<TModel extends Model, TArgs = undefined>(this: PaginatorConstructor<TModel, TArgs>, options?: GetPageOptions, ...rest: If<TArgs>): Promise<Page<TModel>>;
/**
* Gets an identifier to include and check in cursors.
* @returns The queryName property, if specified, or the constructor name
* if not.
*/
private static _getQueryName;
/**
* Creates all of the sort nodes from the static sorts property.
*
* @remarks
* This method will only be called once for a particular subtype. Its
* result will be cached on the class itself.
*
* @returns The created map from sort names to sort nodes.
*/
private static _createSortNodes;
/**
* Gets the sort nodes for the class.
*
* @remarks
* This handles the caching of sort nodes on the class. It ensures we
* aren't creating them over and over for every request when we know they
* won't change unless the process restarts.
*
* @returns A map from sort names to sort nodes.
*/
private static _getSortNodes;
/**
* A convenience for accessing the class constructor with all of its type
* information intact.
*
* @remarks
* TypeScript is annoying and still types `this.constructor` as `Function`.
* I don't know if this will ever change, but this is the best workaround I
* can think of right now.
*/
private get _cls();
/**
* Executes the Paginator, resolving with the fetched Page.
*
* @remarks
* You can keep using the same instance to fetch additional pages, but since
* you're usually only fetching one page per request, and you don't want to
* store paginator instances between requests, you're usally only going to
* call this method once for each instance.
*
* For this reason, the `::getPage` static method is included to create and
* execute a query in a single call.
*
* @param cursor - The cursor string from the previous page, if any.
* @returns The fetched Page.
*/
execute(cursor?: string | null): Promise<Page<TModel>>;
/**
* Fetches the sort node corresponding to the instance's sort name.
*
* @remarks
* This method will throw an UnknownSortError if the corresponding sort node
* does not exist. This means that the sort name is not actually checked
* until you actually attempt to execute the paginator.
*
* @returns The fetched sort node.
*/
private _getSortNode;
/**
* Creates a cursor for this subtype.
*
* @remarks
* If provided with an item, the cursor will include values so that the next
* page fetched with the cursor will include items *after* this value. This
* is used to create cursors from the last fetched item in a page.
*
* If not provided with an item, the cursor will have no values, and using
* it will simply resume from the beginning of the sort. This is used to
* create cursors for initial queries that come back empty, meaning there's
* nothing that matches it yet.
*
* @param item - The model instance to resume from, if any.
* @returns The created cursor object.
*/
private _createCursor;
/**
* Creates a cursor string for this subtype.
*
* @remarks
* This method is the same as #_createCursor, except that it serializes
* the cursor object before returning it.
*
* @param item - The model instance to resume from, if any.
*/
private _createCursorString;
/**
* Validates a cursor object against the paginator instance.
*
* @remarks
* This method is responsible for checking the query name and sort name of
* the provided cursor. It will throw an InvalidCursorError if any problems
* are found.
*
* @param cursor - The unmutated cursor object.
*/
private _validateCursor;
/**
* Parses and validates a cursor string.
* @param str - The encoded cursor string.
* @returns The parsed cursor.
*/
private _parseCursor;
/**
* Extracts the values, if any, from an optional cursor string.
* @param str - The encoded cursor string, if any.
* @returns The cursor values, or undefined if there are none.
*/
private _getCursorValues;
/**
* Applies the sort node for this paginator instance to the provided
* Objection query builder.
*
* @remarks
* Note that this method mutates the query builder.
*
* @param qry - The query builder to mutate.
* @param cursor - The cursor string from the last page, if any.
*/
private _applySortNode;
/**
* Applies the limit for this paginator instance to the provided Objection
* query builder.
*
* @remarks
* Note that this method mutates the query builder.
*
* @param qry - The query builder to mutate.
*/
private _applyLimit;
/**
* Creates the final query for this paginator instance.
*
* @remarks
* This is called during #execute to create the full query builder to get
* the page, before executing. It fetches the user-defined base query and
* applies both the sort node and the limit to it.
*
* @param cursor - The cursor string from the last page, if any.
* @returns The final query to execute.
*/
private _getQuery;
/**
* Queries the database for the number of items left after this page.
*
* @remarks
* The `resultSize` query used below could possibly be parallelized with the
* original query, but it can possibly rely on metadata about related tables
* that Objection may or may not have built before the initial query is
* executed. To avoid metadata-related errors, we do both queries in series.
*
* We could allow users to use `Objection.initialize` to force the metadata
* to be loaded ahead of time, and then send a flag to their Paginators to
* do the queries in parallel, but it seems like a micro-optimization.
*
* Additionally, performing the queries in series allows us to do other
* optimizations, such as eliminating the resultSize query entirely
* depending on the number of items fetched. If got back fewer items than
* the limit, for example, we can assume that we've reached the end of the
* result set and just return zero without doing a second query.
*
* @param qry - The original query builder, *after* execution.
* @param itemCount - The nubmer of items found for this page.
*/
private _getRemainingCount;
/**
* Returns an Objection QueryBuilder which will match the entire result set
* of the paginator.
*
* @remarks
* An implementation for this method must be provided in all concrete
* subtypes. Typically, you'll want to return a plain, unaltered query on
* your model, though sometimes you'll want to apply your own filters or
* load related data using Objection's awesome relationship features.
*
* You should absolutely *not* mark this method as `async`, or otherwise
* invoke the `then` or `catch` methods of the query builder. You want to
* return the un-executed builder as-is, so that the paginator can work its
* magic and apply all the necessary orderBy and where clauses before
* execution.
*/
abstract getBaseQuery(): QueryBuilder<TModel>;
}