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