getData.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import { PipelineStage, Schema, SchemaTypes } from "mongoose";
  2. export enum FilterType {
  3. REGEX = "regex",
  4. CONTAINS = "contains",
  5. EXACT = "exact",
  6. DATETIME_BEFORE = "datetimeBefore",
  7. DATETIME_AFTER = "datetimeAfter",
  8. NUMBER_LESSER_EQUAL = "numberLesserEqual",
  9. NUMBER_LESSER = "numberLesser",
  10. NUMBER_GREATER = "numberGreater",
  11. NUMBER_GREATER_EQUAL = "numberGreaterEqual",
  12. NUMBER_EQUAL = "numberEquals",
  13. BOOLEAN = "boolean",
  14. SPECIAL = "special"
  15. }
  16. export interface GetData {
  17. getData(payload: {
  18. page: number;
  19. pageSize: number;
  20. properties: string[];
  21. sort: Record<string, "ascending" | "descending">;
  22. queries: {
  23. data: any;
  24. filter: {
  25. property: string;
  26. };
  27. filterType: FilterType;
  28. }[];
  29. operator: "and" | "or" | "nor";
  30. }): Promise<{
  31. data: any[];
  32. count: number;
  33. }>;
  34. }
  35. export default function getDataPlugin(schema: Schema) {
  36. schema.static(
  37. "getData",
  38. async function getData(
  39. payload: Parameters<GetData["getData"]>[0]
  40. ): ReturnType<GetData["getData"]> {
  41. const { page, pageSize, properties, sort, queries, operator } =
  42. payload;
  43. const getData = schema.get("getData");
  44. if (!getData)
  45. throw new Error("Schema doesn't have getData defined.");
  46. const {
  47. blacklistedProperties,
  48. specialFilters,
  49. specialProperties,
  50. specialQueries
  51. } = getData;
  52. const pipeline: PipelineStage[] = [];
  53. // If a query filter property or sort property is blacklisted, throw error
  54. if (Array.isArray(blacklistedProperties)) {
  55. if (
  56. queries.some(query =>
  57. blacklistedProperties.some(blacklistedProperty =>
  58. blacklistedProperty.startsWith(
  59. query.filter.property
  60. )
  61. )
  62. )
  63. )
  64. throw new Error(
  65. "Unable to filter by blacklisted property."
  66. );
  67. if (
  68. Object.keys(sort).some(property =>
  69. blacklistedProperties.some(blacklistedProperty =>
  70. blacklistedProperty.startsWith(property)
  71. )
  72. )
  73. )
  74. throw new Error("Unable to sort by blacklisted property.");
  75. }
  76. // If a filter or property exists for a special property, add some custom pipeline steps
  77. if (typeof specialProperties === "object")
  78. Object.entries(specialProperties).forEach(
  79. ([specialProperty, pipelineSteps]) => {
  80. // Check if a filter with the special property exists
  81. const filterExists =
  82. queries
  83. .map(query => query.filter.property)
  84. .indexOf(specialProperty) !== -1;
  85. // Check if a property with the special property exists
  86. const propertyExists =
  87. properties.indexOf(specialProperty) !== -1;
  88. // If no such filter or property exists, skip this function
  89. if (!filterExists && !propertyExists) return;
  90. // Add the specified pipeline steps into the pipeline
  91. pipeline.push(...pipelineSteps);
  92. }
  93. );
  94. // Adds the match stage to aggregation pipeline, which is responsible for filtering
  95. const filterQueries = queries.flatMap(query => {
  96. const { data, filter, filterType } = query;
  97. const { property } = filter;
  98. const newQuery: any = {};
  99. switch (filterType) {
  100. case FilterType.REGEX:
  101. newQuery[property] = new RegExp(
  102. `${data.slice(1, data.length - 1)}`,
  103. "i"
  104. );
  105. break;
  106. case FilterType.CONTAINS:
  107. newQuery[property] = new RegExp(
  108. `${data.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
  109. "i"
  110. );
  111. break;
  112. case FilterType.EXACT:
  113. newQuery[property] = data.toString();
  114. break;
  115. case FilterType.DATETIME_BEFORE:
  116. newQuery[property] = { $lte: new Date(data) };
  117. break;
  118. case FilterType.DATETIME_AFTER:
  119. newQuery[property] = { $gte: new Date(data) };
  120. break;
  121. case FilterType.NUMBER_LESSER_EQUAL:
  122. newQuery[property] = { $lte: Number(data) };
  123. break;
  124. case FilterType.NUMBER_LESSER:
  125. newQuery[property] = { $lt: Number(data) };
  126. break;
  127. case FilterType.NUMBER_GREATER:
  128. newQuery[property] = { $gt: Number(data) };
  129. break;
  130. case FilterType.NUMBER_GREATER_EQUAL:
  131. newQuery[property] = { $gte: Number(data) };
  132. break;
  133. case FilterType.NUMBER_EQUAL:
  134. newQuery[property] = { $eq: Number(data) };
  135. break;
  136. case FilterType.BOOLEAN:
  137. newQuery[property] = { $eq: !!data };
  138. break;
  139. case FilterType.SPECIAL:
  140. if (
  141. typeof specialFilters === "object" &&
  142. typeof specialFilters[filter.property] ===
  143. "function"
  144. ) {
  145. pipeline.push(
  146. ...specialFilters[filter.property](data)
  147. );
  148. newQuery[property] = { $eq: true };
  149. }
  150. break;
  151. default:
  152. throw new Error(`Invalid filter type for "${filter}"`);
  153. }
  154. if (
  155. typeof specialQueries === "object" &&
  156. typeof specialQueries[filter.property] === "function"
  157. ) {
  158. return specialQueries[filter.property](newQuery);
  159. }
  160. return newQuery;
  161. });
  162. const filterQuery: any = {};
  163. if (filterQueries.length > 0)
  164. filterQuery[`$${operator}`] = filterQueries;
  165. pipeline.push({ $match: filterQuery });
  166. // Adds sort stage to aggregation pipeline if there is at least one column being sorted, responsible for sorting data
  167. if (Object.keys(sort).length > 0)
  168. pipeline.push({
  169. $sort: Object.fromEntries(
  170. Object.entries(sort).map(([property, direction]) => [
  171. property,
  172. direction === "ascending" ? 1 : -1
  173. ])
  174. )
  175. });
  176. // Adds first project stage to aggregation pipeline, responsible for including only the requested properties
  177. pipeline.push({
  178. $project: Object.fromEntries(
  179. properties.map(property => [property, 1])
  180. )
  181. });
  182. // Adds second project stage to aggregation pipeline, responsible for excluding some specific properties
  183. if (
  184. Array.isArray(blacklistedProperties) &&
  185. blacklistedProperties.length > 0
  186. )
  187. pipeline.push({
  188. $project: Object.fromEntries(
  189. blacklistedProperties.map(property => [property, 0])
  190. )
  191. });
  192. // Adds the facet stage to aggregation pipeline, responsible for returning a total document count, skipping and limitting the documents that will be returned
  193. pipeline.push({
  194. $facet: {
  195. count: [{ $count: "count" }],
  196. documents: [
  197. { $skip: pageSize * (page - 1) },
  198. { $limit: pageSize }
  199. ]
  200. }
  201. });
  202. // Executes the aggregation pipeline
  203. const [result] = await this.aggregate(pipeline).exec();
  204. if (result.count.length === 0) return { data: [], count: 0 };
  205. properties.forEach(property => {
  206. const type = schema.path(property);
  207. if (type instanceof SchemaTypes.ObjectId) {
  208. const { ref } = type?.options ?? {};
  209. if (ref) {
  210. result.documents = result.documents.map(
  211. (document: any) => ({
  212. ...document,
  213. [property]: {
  214. _name: ref,
  215. _id: document[property]
  216. }
  217. })
  218. );
  219. }
  220. } else if (
  221. type instanceof SchemaTypes.Array &&
  222. type.caster instanceof SchemaTypes.ObjectId
  223. ) {
  224. console.log("Array relation not implemented", property);
  225. }
  226. });
  227. result.documents = result.documents.map((document: any) => ({
  228. ...document,
  229. // TODO properly support getModelName in TypeScript
  230. // eslint-disable-next-line
  231. // @ts-ignore
  232. _name: schema.statics.getModelName()
  233. }));
  234. const { documents: data } = result;
  235. const { count } = result.count[0];
  236. return { data, count };
  237. }
  238. );
  239. }