Skip to content

Commit

Permalink
refactor: streamline mongoose plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
adborroto committed Jan 17, 2025
1 parent 938fa61 commit 0040cd1
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 105 deletions.
40 changes: 15 additions & 25 deletions src/mongoose.plugin.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,53 @@
import { Schema } from 'mongoose';
import _ from 'underscore';
import { Schema } from 'mongoose';
import find, { FindParams } from './find';
import search, { SearchParams } from './search';

interface PaginatePluginOptions {
name?: string; // Name of the pagination function
searchFnName?: string; // Name of the search function
name?: string;
searchFnName?: string;
}

/**
* Mongoose plugin for adding `paginate` and `search` functionality.
*
* @param schema - The Mongoose schema to enhance.
* @param options - Configuration options for the plugin.
* Mongoose plugin
* @param schema mongoose schema.
* @param options plugin options
*/
export default function paginatePlugin(schema: Schema, options?: PaginatePluginOptions): void {
/**
* `paginate` function for querying paginated results.
*
* @param params - Query parameters for the pagination.
* @returns The paginated results.
* paginate function
* @param params required parameter
*/
const findFn = async function(this: any, params: FindParams): Promise<any> {
const findFn = function(this: any, params: FindParams): Promise<any> {
if (!this.collection) {
throw new Error('collection property not found');
}

params = _.extend({}, params);

return find(this.collection, params);
};

/**
* `search` function for performing a search query.
*
* @param searchString - The string to search for.
* @param params - Additional query parameters.
* @returns The search results.
* search function
* @param searchString String to search on. Required parameter
* @param params search parameters
*/
const searchFn = async function(
this: any,
searchString: string,
params: SearchParams
): Promise<any> {
const searchFn = function(this: any, searchString: string, params: SearchParams): Promise<any> {
if (!this.collection) {
throw new Error('collection property not found');
}

params = _.extend({}, params);

return search(this.collection, searchString, params);
};

// Attach the `paginate` function to the schema statics
if (options?.name) {
schema.statics[options.name] = findFn;
} else {
schema.statics.paginate = findFn;
}

// Attach the `search` function to the schema statics
if (options?.searchFnName) {
schema.statics[options.searchFnName] = searchFn;
} else {
Expand Down
1 change: 1 addition & 0 deletions src/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default async function search<T = Document>(
searchString: string,
params: SearchParams
): Promise<SearchResponse<T>> {
if (_.isString(params.limit)) params.limit = parseInt((params.limit as any) as string, 10);
if (params.next) {
params.next = bsonUrlEncoding.decode(params.next as string);
}
Expand Down
130 changes: 71 additions & 59 deletions src/utils/query.ts
Original file line number Diff line number Diff line change
@@ -1,127 +1,144 @@
import objectPath from 'object-path';
import bsonUrlEncoding from './bsonUrlEncoding';

export type PaginationToken = { _id: string; [key: string]: any } | string | [any, unknown];

export type PaginationParams = {
paginatedField?: string;
sortCaseInsensitive?: boolean;
sortAscending?: boolean;
previous?: string | [unknown, unknown];
next?: string | [unknown, unknown];
previous?: PaginationToken;
next?: PaginationToken;
limit?: number;
after?: string | [unknown, unknown];
after?: PaginationToken;
hint?: string;
before?: string;
};

export type PaginationResponse<T> = {
results: T[];
previous: string | null;
previous: PaginationToken; // Updated to reflect a more specific type
hasPrevious: boolean;
next: string | null;
next: PaginationToken; // Updated to reflect a more specific type
hasNext: boolean;
};

type SortObject = Record<string, 1 | -1>;

type CursorQuery = Record<string, any>;

/**
* Helper function to encode pagination tokens.
*
* NOTE: this function modifies the passed-in `response` argument directly.
*
* @param params - Pagination parameters
* @param response - The response object to modify
* @param {Object} params
* @param {String} paginatedField
* @param {boolean} sortCaseInsensitive
*
* @param {Object} response The response
* @param {String?} previous
* @param {String?} next
*
* @returns void
*/
export function encodePaginationTokens<T>(
params: PaginationParams,
response: PaginationResponse<T>,
previous: T | null,
next: T | null
): void {
function encodePaginationTokens(params: PaginationParams, response: PaginationResponse<any>): void {
const shouldSecondarySortOnId = params.paginatedField !== '_id';

if (previous) {
let previousPaginatedField = objectPath.get(previous, params.paginatedField);
if (response.previous) {
let previousPaginatedField = objectPath.get(response.previous, params.paginatedField);
if (params.sortCaseInsensitive) {
previousPaginatedField = previousPaginatedField?.toLowerCase?.() ?? '';
}
response.previous = shouldSecondarySortOnId
? bsonUrlEncoding.encode([previousPaginatedField, (previous as any)._id])
: bsonUrlEncoding.encode(previousPaginatedField);
if (shouldSecondarySortOnId) {
if (
typeof response.previous === 'object' &&
response.previous !== null &&
'_id' in response.previous
) {
response.previous = bsonUrlEncoding.encode([previousPaginatedField, response.previous._id]);
}
} else {
response.previous = bsonUrlEncoding.encode(previousPaginatedField);
}
}

if (next) {
let nextPaginatedField = objectPath.get(next, params.paginatedField);
if (response.next) {
let nextPaginatedField = objectPath.get(response.next, params.paginatedField);
if (params.sortCaseInsensitive) {
nextPaginatedField = nextPaginatedField?.toLowerCase?.() ?? '';
}
response.next = shouldSecondarySortOnId
? bsonUrlEncoding.encode([nextPaginatedField, (next as any)._id])
: bsonUrlEncoding.encode(nextPaginatedField);
if (shouldSecondarySortOnId) {
if (typeof response.next === 'object' && response.next !== null && '_id' in response.next) {
response.next = bsonUrlEncoding.encode([nextPaginatedField, response.next._id]);
}
} else {
response.next = bsonUrlEncoding.encode(nextPaginatedField);
}
}
}

/**
* Parses the raw results from a find or aggregate query and generates a response object that
* contains various pagination properties.
* contain the various pagination properties
*
* @param {Object[]} results the results from a query
* @param {Object} params The params originally passed to `find` or `aggregate`
*
* @param results - The results from a query
* @param params - The parameters originally passed to `find` or `aggregate`
* @returns The object containing pagination properties
* @return {Object} The object containing pagination properties
*/
export function prepareResponse<T>(results: T[], params: PaginationParams): PaginationResponse<T> {
function prepareResponse(results: any[], params: any): any {
const hasMore = results.length > params.limit;

// Remove the extra element that we added to 'peek' to see if there were more entries.
if (hasMore) results.pop();

const hasPrevious = !!params.next || !!(params.previous && hasMore);
const hasNext = !!params.previous || hasMore;

// If we sorted reverse to get the previous page, correct the sort order.
if (params.previous) results = results.reverse();

const response: PaginationResponse<T> = {
const response = {
results,
previous: results[0],
hasPrevious,
next: results[results.length - 1],
hasNext,
previous: null,
next: null,
};

const previous = results[0] || null;
const next = results[results.length - 1] || null;

encodePaginationTokens(params, response, previous, next);
encodePaginationTokens(params, response);

return response;
}

/**
* Generates a `$sort` object given the parameters.
* Generates a `$sort` object given the parameters
*
* @param {Object} params The params originally passed to `find` or `aggregate`
*
* @param params - The parameters originally passed to `find` or `aggregate`
* @returns A sort object
* @return {Object} a sort object
*/
export function generateSort(params: PaginationParams): SortObject {
function generateSort(params: any): any {
const sortAsc =
(!params.sortAscending && params.previous) || (params.sortAscending && !params.previous);
const sortDir = sortAsc ? 1 : -1;

if (params.paginatedField === '_id') {
return { _id: sortDir };
if (params.paginatedField == '_id') {
return {
_id: sortDir,
};
} else {
const field = params.sortCaseInsensitive ? '__lc' : params.paginatedField;
return { [field]: sortDir, _id: sortDir };
return {
[field]: sortDir,
_id: sortDir,
};
}
}

/**
* Generates a cursor query that provides the offset capabilities.
function /**
* Generates a cursor query that provides the offset capabilities
*
* @param params - The parameters originally passed to `find` or `aggregate`
* @returns A cursor offset query
* @param {Object} params The params originally passed to `find` or `aggregate`
*
* @return {Object} a cursor offset query
*/
export function generateCursorQuery(params: PaginationParams): CursorQuery {
generateCursorQuery(params: any): any {
if (!params.next && !params.previous) return {};

const sortAsc =
Expand Down Expand Up @@ -213,9 +230,4 @@ export function generateCursorQuery(params: PaginationParams): CursorQuery {
}
}

export default {
prepareResponse,
encodePaginationTokens,
generateSort,
generateCursorQuery,
};
export { encodePaginationTokens, prepareResponse, generateSort, generateCursorQuery };
4 changes: 2 additions & 2 deletions test/mongoosePlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('mongoose plugin', () => {

it('returns data in the expected format', async () => {
const data = await (Post as any).paginate();
var hasOwnProperty = Object.prototype.hasOwnProperty;
const hasOwnProperty = Object.prototype.hasOwnProperty;
expect(hasOwnProperty.call(data, 'results')).toBe(true);
expect(hasOwnProperty.call(data, 'previous')).toBe(true);
expect(hasOwnProperty.call(data, 'hasPrevious')).toBe(true);
Expand All @@ -88,7 +88,7 @@ describe('mongoose plugin', () => {

it('returns data in the expected format for search function', async () => {
const data = await (Post as any).search('Post #1', { limit: 3 });
var hasOwnProperty = Object.prototype.hasOwnProperty;
const hasOwnProperty = Object.prototype.hasOwnProperty;
expect(hasOwnProperty.call(data, 'results')).toBe(true);
expect(hasOwnProperty.call(data, 'next')).toBe(true);
});
Expand Down
28 changes: 9 additions & 19 deletions test/utils/query.test.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,38 @@
import bsonUrlEncoding from '../../src/utils/bsonUrlEncoding';
import {
encodePaginationTokens,
PaginationResponse,
generateCursorQuery,
} from '../../src/utils/query';
import { encodePaginationTokens, generateCursorQuery } from '../../src/utils/query';

describe('encodePaginationTokens', () => {
it('encodes the pagination tokens on the passed-in response object', () => {
type PaginatedType = { _id: string };
const params = {
paginatedField: '_id',
};
const response: PaginationResponse<PaginatedType> = {
const response = {
results: [],
previous: null,
previous: { _id: '456' },
hasPrevious: false,
next: null,
next: { _id: '789' },
hasNext: false,
};
const previous: PaginatedType = { _id: '456' };
const next: PaginatedType = { _id: '789' };

encodePaginationTokens<PaginatedType>(params, response, previous, next);
encodePaginationTokens(params, response);

expect(response.next).toEqual(bsonUrlEncoding.encode('789'));
expect(response.previous).toEqual(bsonUrlEncoding.encode('456'));
});

it("constructs pagination tokens using both the _id and the paginatedField if the latter isn't the former", () => {
type PaginatedType = { _id: string; name: string };
const params = {
paginatedField: 'name',
};
const response: PaginationResponse<PaginatedType> = {
const response = {
results: [],
previous: null,
previous: { _id: '456', name: 'Test' },
hasPrevious: false,
next: null,
next: { _id: '789', name: 'Test 2' },
hasNext: false,
};
const previous: PaginatedType = { _id: '456', name: 'Test' };
const next: PaginatedType = { _id: '789', name: 'Test 2' };

encodePaginationTokens<PaginatedType>(params, response, previous, next);
encodePaginationTokens(params, response);

expect(response.next).toEqual(bsonUrlEncoding.encode(['Test 2', '789']));
expect(response.previous).toEqual(bsonUrlEncoding.encode(['Test', '456']));
Expand Down

0 comments on commit 0040cd1

Please sign in to comment.