mirror of https://github.com/msgbyte/tailchat
				
				
				
			perf(admin-next): optimize mongodb's performance on statistical quantities
							parent
							
								
									98e78e28f8
								
							
						
					
					
						commit
						11154f735b
					
				@ -0,0 +1,4 @@
 | 
			
		||||
fork from https://github.com/NathanAdhitya/express-mongoose-ra-json-server
 | 
			
		||||
 | 
			
		||||
modify:
 | 
			
		||||
- count logic in get `/`
 | 
			
		||||
@ -0,0 +1,259 @@
 | 
			
		||||
import { RequestHandler, Router } from 'express';
 | 
			
		||||
import { LeanDocument } from 'mongoose';
 | 
			
		||||
import statusMessages from './statusMessages';
 | 
			
		||||
import { ADPBaseModel, ADPBaseSchema } from './utils/baseModel.interface';
 | 
			
		||||
import castFilter from './utils/castFilter';
 | 
			
		||||
import convertId from './utils/convertId';
 | 
			
		||||
import filterGetList from './utils/filterGetList';
 | 
			
		||||
import { filterReadOnly } from './utils/filterReadOnly';
 | 
			
		||||
import parseQuery from './utils/parseQuery';
 | 
			
		||||
import virtualId from './utils/virtualId';
 | 
			
		||||
 | 
			
		||||
// Export certain helper functions for custom reuse.
 | 
			
		||||
export { default as virtualId } from './utils/virtualId';
 | 
			
		||||
export { default as convertId } from './utils/convertId';
 | 
			
		||||
export { default as castFilter } from './utils/castFilter';
 | 
			
		||||
export { default as parseQuery } from './utils/parseQuery';
 | 
			
		||||
export { default as filterGetList } from './utils/filterGetList';
 | 
			
		||||
export { filterReadOnly } from './utils/filterReadOnly';
 | 
			
		||||
export { default as statusMessages } from './statusMessages';
 | 
			
		||||
 | 
			
		||||
export interface raExpressMongooseCapabilities {
 | 
			
		||||
  list?: boolean;
 | 
			
		||||
  get?: boolean;
 | 
			
		||||
  create?: boolean;
 | 
			
		||||
  update?: boolean;
 | 
			
		||||
  delete?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface raExpressMongooseOptions<T> {
 | 
			
		||||
  /** Fields to search from ?q (used for autofill and search) */
 | 
			
		||||
  q?: string[];
 | 
			
		||||
 | 
			
		||||
  /** Base name for ACLs (e.g. list operation does baseName.list) */
 | 
			
		||||
  aclName?: string;
 | 
			
		||||
 | 
			
		||||
  /** Fields to allow regex based search (non-exact search) */
 | 
			
		||||
  allowedRegexFields?: string[];
 | 
			
		||||
 | 
			
		||||
  /** Read-only fields to filter out during create and update */
 | 
			
		||||
  readOnlyFields?: string[];
 | 
			
		||||
 | 
			
		||||
  /** Function to transform inputs received in create and update */
 | 
			
		||||
  inputTransformer?: (input: Partial<T>) => Promise<Partial<T>>;
 | 
			
		||||
 | 
			
		||||
  /** Additional queries for list, e.g. deleted/hidden flag. */
 | 
			
		||||
  listQuery?: Record<string, any>;
 | 
			
		||||
 | 
			
		||||
  /** Max rows from a get operation to prevent accidental server suicide (default 100) */
 | 
			
		||||
  maxRows?: number;
 | 
			
		||||
 | 
			
		||||
  /** Extra selects for mongoose queries (in the case that certain fields are hidden by default) */
 | 
			
		||||
  extraSelects?: string;
 | 
			
		||||
 | 
			
		||||
  /** Disable or enable certain parts. */
 | 
			
		||||
  capabilities?: raExpressMongooseCapabilities;
 | 
			
		||||
 | 
			
		||||
  /** Specify a custom express.js router */
 | 
			
		||||
  router?: Router;
 | 
			
		||||
 | 
			
		||||
  /** Specify an ACL middleware to check against permissions */
 | 
			
		||||
  ACLMiddleware?: (name: string) => RequestHandler;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function raExpressMongoose<T extends ADPBaseModel, I>(
 | 
			
		||||
  model: T,
 | 
			
		||||
  options?: raExpressMongooseOptions<I>
 | 
			
		||||
) {
 | 
			
		||||
  const {
 | 
			
		||||
    q,
 | 
			
		||||
    allowedRegexFields = [],
 | 
			
		||||
    readOnlyFields,
 | 
			
		||||
    inputTransformer = (input: any) => input,
 | 
			
		||||
    listQuery,
 | 
			
		||||
    extraSelects,
 | 
			
		||||
    maxRows = 100,
 | 
			
		||||
    capabilities,
 | 
			
		||||
    aclName,
 | 
			
		||||
    router = Router(),
 | 
			
		||||
    ACLMiddleware,
 | 
			
		||||
  } = options ?? {};
 | 
			
		||||
 | 
			
		||||
  const {
 | 
			
		||||
    list: canList = true,
 | 
			
		||||
    get: canGet = true,
 | 
			
		||||
    create: canCreate = true,
 | 
			
		||||
    update: canUpdate = true,
 | 
			
		||||
    delete: canDelete = true,
 | 
			
		||||
  } = capabilities ?? {};
 | 
			
		||||
 | 
			
		||||
  /** getList, getMany, getManyReference */
 | 
			
		||||
  if (canList)
 | 
			
		||||
    router.get(
 | 
			
		||||
      '/',
 | 
			
		||||
      aclName && ACLMiddleware
 | 
			
		||||
        ? ACLMiddleware(`${aclName}.list`)
 | 
			
		||||
        : (req, res, next) => next(),
 | 
			
		||||
      async (req, res) => {
 | 
			
		||||
        const filterQuery = {
 | 
			
		||||
          ...listQuery,
 | 
			
		||||
          ...parseQuery(
 | 
			
		||||
            castFilter(
 | 
			
		||||
              convertId(filterGetList(req.query)),
 | 
			
		||||
              model,
 | 
			
		||||
              allowedRegexFields
 | 
			
		||||
            ),
 | 
			
		||||
            model,
 | 
			
		||||
            allowedRegexFields,
 | 
			
		||||
            q
 | 
			
		||||
          ),
 | 
			
		||||
        };
 | 
			
		||||
        let query = model.find(filterQuery);
 | 
			
		||||
 | 
			
		||||
        if (req.query._sort && req.query._order)
 | 
			
		||||
          query = query.sort({
 | 
			
		||||
            [typeof req.query._sort === 'string'
 | 
			
		||||
              ? req.query._sort === 'id'
 | 
			
		||||
                ? '_id'
 | 
			
		||||
                : req.query._sort
 | 
			
		||||
              : '_id']: req.query._order === 'ASC' ? 1 : -1,
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
        if (req.query._start)
 | 
			
		||||
          query = query.skip(
 | 
			
		||||
            parseInt(
 | 
			
		||||
              typeof req.query._start === 'string' ? req.query._start : '0'
 | 
			
		||||
            )
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
        if (req.query._end)
 | 
			
		||||
          query = query.limit(
 | 
			
		||||
            Math.min(
 | 
			
		||||
              parseInt(
 | 
			
		||||
                typeof req.query._end === 'string' ? req.query._end : '0'
 | 
			
		||||
              ) -
 | 
			
		||||
                (req.query._start
 | 
			
		||||
                  ? parseInt(
 | 
			
		||||
                      typeof req.query._start === 'string'
 | 
			
		||||
                        ? req.query._start
 | 
			
		||||
                        : '0'
 | 
			
		||||
                    )
 | 
			
		||||
                  : 0),
 | 
			
		||||
              maxRows
 | 
			
		||||
            )
 | 
			
		||||
          );
 | 
			
		||||
        else query = query.limit(maxRows);
 | 
			
		||||
 | 
			
		||||
        if (extraSelects) query = query.select(extraSelects);
 | 
			
		||||
 | 
			
		||||
        if (Object.keys(filterQuery).length === 0) {
 | 
			
		||||
          res.set(
 | 
			
		||||
            'X-Total-Count',
 | 
			
		||||
            (await model.estimatedDocumentCount()).toString()
 | 
			
		||||
          );
 | 
			
		||||
        } else {
 | 
			
		||||
          res.set(
 | 
			
		||||
            'X-Total-Count',
 | 
			
		||||
            (await model.countDocuments(filterQuery)).toString()
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return res.json(
 | 
			
		||||
          virtualId((await query.lean()) as LeanDocument<ADPBaseSchema>)
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  /** getOne, getMany */
 | 
			
		||||
  if (canGet)
 | 
			
		||||
    router.get(
 | 
			
		||||
      '/:id',
 | 
			
		||||
      aclName && ACLMiddleware
 | 
			
		||||
        ? ACLMiddleware(`${aclName}.list`)
 | 
			
		||||
        : (req, res, next) => next(),
 | 
			
		||||
      async (req, res) => {
 | 
			
		||||
        await model
 | 
			
		||||
          .findById(req.params.id)
 | 
			
		||||
          .select(extraSelects)
 | 
			
		||||
          .lean()
 | 
			
		||||
          .then((result) => res.json(virtualId(result)))
 | 
			
		||||
          .catch((e) => {
 | 
			
		||||
            return statusMessages.error(res, 400, e);
 | 
			
		||||
          });
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  /** create */
 | 
			
		||||
  if (canCreate)
 | 
			
		||||
    router.post(
 | 
			
		||||
      '/',
 | 
			
		||||
      aclName && ACLMiddleware
 | 
			
		||||
        ? ACLMiddleware(`${aclName}.create`)
 | 
			
		||||
        : (req, res, next) => next(),
 | 
			
		||||
      async (req, res) => {
 | 
			
		||||
        // eslint-disable-next-line new-cap
 | 
			
		||||
        const result = convertId(
 | 
			
		||||
          await inputTransformer(filterReadOnly<I>(req.body, readOnlyFields))
 | 
			
		||||
        );
 | 
			
		||||
        const newData = {
 | 
			
		||||
          ...result,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        const newEntry = new model(newData);
 | 
			
		||||
        await newEntry
 | 
			
		||||
          .save()
 | 
			
		||||
          .then((result) => res.json(virtualId(result)))
 | 
			
		||||
          .catch((e: any) => {
 | 
			
		||||
            return statusMessages.error(res, 400, e, 'Bad request');
 | 
			
		||||
          });
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  /** update */
 | 
			
		||||
  if (canUpdate)
 | 
			
		||||
    router.put(
 | 
			
		||||
      '/:id',
 | 
			
		||||
      aclName && ACLMiddleware
 | 
			
		||||
        ? ACLMiddleware(`${aclName}.edit`)
 | 
			
		||||
        : (req, res, next) => next(),
 | 
			
		||||
      async (req, res) => {
 | 
			
		||||
        const updateData = {
 | 
			
		||||
          ...(await convertId(
 | 
			
		||||
            await inputTransformer(filterReadOnly<I>(req.body, readOnlyFields))
 | 
			
		||||
          )),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        await model
 | 
			
		||||
          .findOneAndUpdate({ _id: req.params.id }, updateData, {
 | 
			
		||||
            new: true,
 | 
			
		||||
            runValidators: true,
 | 
			
		||||
          })
 | 
			
		||||
          .lean()
 | 
			
		||||
          .then((result) => res.json(virtualId(result)))
 | 
			
		||||
          .catch((e) => {
 | 
			
		||||
            return statusMessages.error(res, 400, e, 'Bad request');
 | 
			
		||||
          });
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * delete
 | 
			
		||||
   */
 | 
			
		||||
  if (canDelete)
 | 
			
		||||
    router.delete(
 | 
			
		||||
      '/:id',
 | 
			
		||||
      aclName && ACLMiddleware
 | 
			
		||||
        ? ACLMiddleware(`${aclName}.delete`)
 | 
			
		||||
        : (req, res, next) => next(),
 | 
			
		||||
      async (req, res) => {
 | 
			
		||||
        await model
 | 
			
		||||
          .findOneAndDelete({ _id: req.params.id })
 | 
			
		||||
          .then((result) => res.json(virtualId(result)))
 | 
			
		||||
          .catch((e) => {
 | 
			
		||||
            return statusMessages.error(res, 404, e, 'Element does not exist');
 | 
			
		||||
          });
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  return router;
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,24 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @file statusMessages
 | 
			
		||||
 * @description handles status messages / error responses
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { Response } from 'express';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handles rejections other than errors. 400, 401, etc.
 | 
			
		||||
 */
 | 
			
		||||
function reject(res: Response, status: number, reason?: any) {
 | 
			
		||||
  return res.status(status).json({ message: reason ?? 'Invalid request' });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handles errors
 | 
			
		||||
 */
 | 
			
		||||
function error(res: Response, status: number, e: Error, message?: string) {
 | 
			
		||||
  if (process.env.NODE_ENV !== 'production') {
 | 
			
		||||
    return res.status(status).json({ message, error: e.message });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default { reject, error };
 | 
			
		||||
@ -0,0 +1,7 @@
 | 
			
		||||
import { Model, Document } from 'mongoose';
 | 
			
		||||
 | 
			
		||||
export interface ADPBaseSchema {
 | 
			
		||||
  _id: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type ADPBaseModel = Model<ADPBaseSchema & Document & any>;
 | 
			
		||||
@ -0,0 +1,14 @@
 | 
			
		||||
/** Turns id into _id for search queries */
 | 
			
		||||
export default function convertId<T extends Record<string, unknown>>(obj: T) {
 | 
			
		||||
  if (obj.id) {
 | 
			
		||||
    const newObject = {
 | 
			
		||||
      _id: obj.id,
 | 
			
		||||
      ...obj,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    delete newObject.id;
 | 
			
		||||
    return newObject;
 | 
			
		||||
  } else {
 | 
			
		||||
    return obj;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,17 @@
 | 
			
		||||
export const filterGetListParams = [
 | 
			
		||||
  '_sort',
 | 
			
		||||
  '_order',
 | 
			
		||||
  '_start',
 | 
			
		||||
  '_end',
 | 
			
		||||
] as const;
 | 
			
		||||
 | 
			
		||||
/** Removes _sort, _order, _start, _end from a query. */
 | 
			
		||||
export default function filterGetList<T extends Record<string, unknown>>(
 | 
			
		||||
  obj: T
 | 
			
		||||
) {
 | 
			
		||||
  const filtered: any = {};
 | 
			
		||||
  Object.entries(obj).forEach(([index, value]) => {
 | 
			
		||||
    if (!filterGetListParams.includes(index as any)) filtered[index] = value;
 | 
			
		||||
  });
 | 
			
		||||
  return filtered as Omit<T, (typeof filterGetListParams)[number]>;
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,13 @@
 | 
			
		||||
/** Makes sure that it does not modify crucial and sacred parts mutates the original object. */
 | 
			
		||||
export function filterReadOnly<T extends {}>(
 | 
			
		||||
  obj: T,
 | 
			
		||||
  readOnlyFields?: string[]
 | 
			
		||||
) {
 | 
			
		||||
  if (!readOnlyFields) return obj as T;
 | 
			
		||||
 | 
			
		||||
  readOnlyFields.forEach((v) => {
 | 
			
		||||
    delete obj[v];
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return obj as Partial<T>;
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,33 @@
 | 
			
		||||
import { ADPBaseModel } from './baseModel.interface';
 | 
			
		||||
import castFilter from './castFilter';
 | 
			
		||||
 | 
			
		||||
interface parseQueryParam {
 | 
			
		||||
  q?: string;
 | 
			
		||||
  $or?: any;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Turns ?q into $or queries, deletes q
 | 
			
		||||
 * @param {Object} results Original object with the q field
 | 
			
		||||
 * @param {string[]} fields Fields to apply q to
 | 
			
		||||
 */
 | 
			
		||||
export default function parseQuery<
 | 
			
		||||
  T extends parseQueryParam,
 | 
			
		||||
  M extends ADPBaseModel
 | 
			
		||||
>(
 | 
			
		||||
  result: T,
 | 
			
		||||
  model: M,
 | 
			
		||||
  allowedRegexes: string[],
 | 
			
		||||
  fields?: string[]
 | 
			
		||||
): T & { $or?: any } {
 | 
			
		||||
  if (!fields) return result;
 | 
			
		||||
  if (result.q) {
 | 
			
		||||
    if (!Array.isArray(result.$or)) result.$or = [];
 | 
			
		||||
    fields.forEach((field) => {
 | 
			
		||||
      const newFilter = { [field]: result.q };
 | 
			
		||||
      result.$or.push(castFilter(newFilter, model, allowedRegexes));
 | 
			
		||||
    });
 | 
			
		||||
    delete result.q;
 | 
			
		||||
  }
 | 
			
		||||
  return result;
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,21 @@
 | 
			
		||||
export default function virtualId<T extends { _id: string }>(
 | 
			
		||||
  arr: T[]
 | 
			
		||||
): Array<T & { id: string }>;
 | 
			
		||||
export default function virtualId<T extends { _id: string }>(
 | 
			
		||||
  doc: T
 | 
			
		||||
): T & { id: string };
 | 
			
		||||
 | 
			
		||||
/** Virtual ID (_id to id) for react-admin */
 | 
			
		||||
export default function virtualId<T extends { _id: string }>(el: Array<T> | T) {
 | 
			
		||||
  if (Array.isArray(el)) {
 | 
			
		||||
    return el.map((e) => {
 | 
			
		||||
      return {
 | 
			
		||||
        id: e._id,
 | 
			
		||||
        ...e,
 | 
			
		||||
        _id: undefined,
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return { id: el._id, ...el, _id: undefined };
 | 
			
		||||
}
 | 
			
		||||
					Loading…
					
					
				
		Reference in New Issue