GetDataJob.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import Joi from "joi";
  2. import { FindOptions, WhereOptions, Op, IncludeOptions } from "sequelize";
  3. import DataModuleJob from "./DataModuleJob";
  4. export enum FilterType {
  5. REGEX = "regex",
  6. CONTAINS = "contains",
  7. EXACT = "exact",
  8. DATETIME_BEFORE = "datetimeBefore",
  9. DATETIME_AFTER = "datetimeAfter",
  10. NUMBER_LESSER_EQUAL = "numberLesserEqual",
  11. NUMBER_LESSER = "numberLesser",
  12. NUMBER_GREATER = "numberGreater",
  13. NUMBER_GREATER_EQUAL = "numberGreaterEqual",
  14. NUMBER_EQUAL = "numberEquals",
  15. BOOLEAN = "boolean",
  16. SPECIAL = "special"
  17. }
  18. interface Sort {
  19. [property: string]: "ascending" | "descending";
  20. }
  21. interface Query {
  22. filter: {
  23. property: string;
  24. };
  25. filterType: FilterType;
  26. data: string;
  27. }
  28. type Payload = {
  29. page: number;
  30. pageSize: number;
  31. properties: string[];
  32. sort: Sort;
  33. queries: Query[];
  34. operator: "and" | "or" | "nor";
  35. };
  36. export default abstract class GetDataJob extends DataModuleJob {
  37. protected static _payloadSchema = Joi.object({
  38. page: Joi.number().required(),
  39. pageSize: Joi.number().required(),
  40. properties: Joi.array()
  41. .items(Joi.string().required())
  42. .min(1)
  43. .required(),
  44. sort: Joi.object()
  45. .pattern(
  46. /^/,
  47. Joi.string().valid("ascending", "descending").required()
  48. )
  49. .required(),
  50. queries: Joi.array()
  51. .items(
  52. Joi.object({
  53. filter: Joi.object({
  54. name: Joi.string().optional(), // Remove eventually
  55. displayName: Joi.string().optional(), // Remove eventually
  56. property: Joi.string().required(),
  57. filterTypes: Joi.array()
  58. .items(
  59. Joi.string()
  60. .valid(...Object.values(FilterType))
  61. .required()
  62. )
  63. .optional(), // Remove eventually
  64. defaultFilterType: Joi.string()
  65. .valid(...Object.values(FilterType))
  66. .required() // Remove eventually
  67. }).required(),
  68. filterType: Joi.string()
  69. .valid(...Object.values(FilterType))
  70. .required(),
  71. data: Joi.alternatives()
  72. .try(
  73. Joi.boolean(),
  74. Joi.string()
  75. // Joi.number(),
  76. )
  77. .required()
  78. })
  79. )
  80. .required(),
  81. operator: Joi.string().valid("and", "or", "nor").required()
  82. });
  83. protected _blacklistedProperties?: string[];
  84. protected _specialFilters?: Record<
  85. string,
  86. (query: FindOptions, data: any) => FindOptions
  87. >;
  88. protected _specialProperties?: Record<
  89. string,
  90. (query: FindOptions) => FindOptions
  91. >;
  92. protected _specialQueries?: Record<
  93. string,
  94. (query: Record<string, WhereOptions>) => {
  95. query: WhereOptions;
  96. includeProperties: string[];
  97. }
  98. >;
  99. protected async _execute() {
  100. const { page, pageSize, properties, sort, queries, operator } = this
  101. ._payload as Payload;
  102. let findQuery: FindOptions = {};
  103. // If a query filter property or sort property is blacklisted, throw error
  104. if (this._blacklistedProperties?.length) {
  105. if (
  106. queries.some(query =>
  107. this._blacklistedProperties!.some(blacklistedProperty =>
  108. blacklistedProperty.startsWith(query.filter.property)
  109. )
  110. )
  111. )
  112. throw new Error("Unable to filter by blacklisted property.");
  113. if (
  114. Object.keys(sort).some(property =>
  115. this._blacklistedProperties!.some(blacklistedProperty =>
  116. blacklistedProperty.startsWith(property)
  117. )
  118. )
  119. )
  120. throw new Error("Unable to sort by blacklisted property.");
  121. }
  122. // If a filter or property exists for a special property, add some custom pipeline steps
  123. if (this._specialProperties)
  124. Object.entries(this._specialProperties).forEach(
  125. ([specialProperty, modifyQuery]) => {
  126. // Check if a filter with the special property exists
  127. const filterExists =
  128. queries
  129. .map(query => query.filter.property)
  130. .indexOf(specialProperty) !== -1;
  131. // Check if a property with the special property exists
  132. const propertyExists =
  133. properties.indexOf(specialProperty) !== -1;
  134. // If no such filter or property exists, skip this function
  135. if (!filterExists && !propertyExists) return;
  136. // Add the specified pipeline steps into the pipeline
  137. findQuery = modifyQuery(findQuery);
  138. }
  139. );
  140. // Properties that we need to include (join) with Sequelize, e.g. createdByModel
  141. const includePropertiesSet = new Set<string>();
  142. // Adds where stage to query, which is responsible for filtering
  143. const filterQueries = queries.flatMap(query => {
  144. const { data, filter, filterType } = query;
  145. const { property } = filter;
  146. const newQuery: any = {};
  147. switch (filterType) {
  148. case FilterType.REGEX:
  149. newQuery[property] = {
  150. [Op.iRegexp]: data
  151. };
  152. break;
  153. case FilterType.CONTAINS:
  154. newQuery[property] = {
  155. [Op.like]: `%${data}%`
  156. };
  157. break;
  158. case FilterType.EXACT:
  159. newQuery[property] = data.toString();
  160. break;
  161. case FilterType.DATETIME_BEFORE:
  162. newQuery[property] = { [Op.lte]: new Date(data) };
  163. break;
  164. case FilterType.DATETIME_AFTER:
  165. newQuery[property] = { [Op.gte]: new Date(data) };
  166. break;
  167. case FilterType.NUMBER_LESSER_EQUAL:
  168. newQuery[property] = { [Op.lte]: Number(data) };
  169. break;
  170. case FilterType.NUMBER_LESSER:
  171. newQuery[property] = { [Op.lt]: Number(data) };
  172. break;
  173. case FilterType.NUMBER_GREATER:
  174. newQuery[property] = { [Op.gt]: Number(data) };
  175. break;
  176. case FilterType.NUMBER_GREATER_EQUAL:
  177. newQuery[property] = { [Op.gte]: Number(data) };
  178. break;
  179. case FilterType.NUMBER_EQUAL:
  180. newQuery[property] = { [Op.eq]: Number(data) };
  181. break;
  182. case FilterType.BOOLEAN:
  183. newQuery[property] = { [Op.eq]: !!data };
  184. break;
  185. case FilterType.SPECIAL:
  186. if (
  187. typeof this._specialFilters?.[filter.property] ===
  188. "function"
  189. ) {
  190. findQuery = this._specialFilters[filter.property](
  191. findQuery,
  192. data
  193. );
  194. newQuery[property] = { [Op.eq]: true };
  195. }
  196. break;
  197. default:
  198. throw new Error(`Invalid filter type for "${filter}"`);
  199. }
  200. if (typeof this._specialQueries?.[filter.property] === "function") {
  201. const { query, includeProperties } =
  202. this._specialQueries[filter.property](newQuery);
  203. // Keep track of what property/properties Sequelize will have to include (join) for the special query to work
  204. includeProperties.forEach(includeProperty => {
  205. includePropertiesSet.add(includeProperty);
  206. });
  207. return query;
  208. }
  209. return newQuery;
  210. });
  211. if (filterQueries.length > 0) {
  212. if (operator === "nor") {
  213. findQuery.where = {
  214. [Op.not]: {
  215. [Op.or]: filterQueries
  216. }
  217. };
  218. } else {
  219. findQuery.where = {
  220. [Op[operator]]: filterQueries
  221. };
  222. }
  223. }
  224. // Adds order stage to query if there is at least one column being sorted, responsible for sorting data
  225. if (Object.keys(sort).length > 0)
  226. findQuery.order = Object.entries(sort).map(
  227. ([property, direction]) => [
  228. property,
  229. direction === "ascending" ? "ASC" : "DESC"
  230. ]
  231. );
  232. findQuery.attributes = {
  233. include: properties,
  234. exclude: this._blacklistedProperties
  235. };
  236. findQuery.offset = pageSize * (page - 1);
  237. findQuery.limit = pageSize;
  238. // We need to tell Sequalize that some associated tables are used and must be included (joined)
  239. const includeProperties = Array.from(includePropertiesSet);
  240. findQuery.include = includeProperties.map(includeProperty => {
  241. const association = this.getModel().associations[includeProperty];
  242. const targetModel = association.target;
  243. return {
  244. model: targetModel, // E.g. User
  245. as: includeProperty, // e.g. "createdByModel"
  246. attributes: [] // We do not want to return any data from anything we include
  247. } as IncludeOptions;
  248. });
  249. // Executes the query
  250. const { rows, count } = await this.getModel().findAndCountAll(
  251. findQuery
  252. );
  253. // TODO make absolutely sure createdByModel and similar here have been removed or aren't included, if they've been included at all
  254. return { data: rows, count };
  255. }
  256. }