DataModule.ts 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234
  1. import async from "async";
  2. import config from "config";
  3. import { Db, MongoClient, ObjectId } from "mongodb";
  4. import hash from "object-hash";
  5. import { createClient, RedisClientType } from "redis";
  6. import { Document } from "src/types/Document";
  7. import JobContext from "../JobContext";
  8. import BaseModule from "../BaseModule";
  9. import ModuleManager from "../ModuleManager";
  10. import Schema, { Types } from "../Schema";
  11. import { UniqueMethods } from "../types/Modules";
  12. import { Collections } from "../types/Collections";
  13. interface ProjectionObject {
  14. [property: string]: boolean | string[] | ProjectionObject;
  15. }
  16. type Projection = null | undefined | string[] | ProjectionObject;
  17. type NormalizedProjection = {
  18. projection: [string, boolean][];
  19. mode: "includeAllBut" | "excludeAllBut";
  20. };
  21. export default class DataModule extends BaseModule {
  22. private collections?: Collections;
  23. private mongoClient?: MongoClient;
  24. private mongoDb?: Db;
  25. private redisClient?: RedisClientType;
  26. /**
  27. * Data Module
  28. *
  29. * @param moduleManager - Module manager class
  30. */
  31. public constructor(moduleManager: ModuleManager) {
  32. super(moduleManager, "data");
  33. const a = this.normalizeProjection({
  34. test123: true
  35. });
  36. }
  37. /**
  38. * startup - Startup data module
  39. */
  40. public override startup(): Promise<void> {
  41. return new Promise((resolve, reject) => {
  42. async.waterfall(
  43. [
  44. async () => super.startup(),
  45. async () => {
  46. const mongoUrl = config.get<string>("mongo.url");
  47. this.mongoClient = new MongoClient(mongoUrl);
  48. await this.mongoClient.connect();
  49. this.mongoDb = this.mongoClient.db();
  50. },
  51. async () => this.loadCollections(),
  52. async () => {
  53. const { url, password } = config.get<{
  54. url: string;
  55. password: string;
  56. }>("redis");
  57. this.redisClient = createClient({
  58. url,
  59. password
  60. });
  61. return this.redisClient.connect();
  62. },
  63. async () => {
  64. if (!this.redisClient)
  65. throw new Error("Redis connection not established");
  66. return this.redisClient.sendCommand([
  67. "CONFIG",
  68. "GET",
  69. "notify-keyspace-events"
  70. ]);
  71. },
  72. async (redisConfigResponse: string[]) => {
  73. if (
  74. !(
  75. Array.isArray(redisConfigResponse) &&
  76. redisConfigResponse[1] === "xE"
  77. )
  78. )
  79. throw new Error(
  80. `notify-keyspace-events is NOT configured correctly! It is set to: ${
  81. (Array.isArray(redisConfigResponse) &&
  82. redisConfigResponse[1]) ||
  83. "unknown"
  84. }`
  85. );
  86. },
  87. async () => super.started()
  88. ],
  89. err => {
  90. if (err) reject(err);
  91. else resolve();
  92. }
  93. );
  94. });
  95. }
  96. /**
  97. * shutdown - Shutdown data module
  98. */
  99. public override shutdown(): Promise<void> {
  100. return new Promise(resolve => {
  101. super
  102. .shutdown()
  103. .then(async () => {
  104. // TODO: Ensure the following shutdown correctly
  105. if (this.redisClient) await this.redisClient.quit();
  106. if (this.mongoClient) await this.mongoClient.close(false);
  107. })
  108. .finally(() => resolve());
  109. });
  110. }
  111. /**
  112. * loadColllection - Import and load collection schema
  113. *
  114. * @param collectionName - Name of the collection
  115. * @returns Collection
  116. */
  117. private loadCollection<T extends keyof Collections>(
  118. collectionName: T
  119. ): Promise<{
  120. schema: Collections[T]["schema"];
  121. collection: Collections[T]["collection"];
  122. }> {
  123. return new Promise(resolve => {
  124. import(`../collections/${collectionName.toString()}`).then(
  125. ({ default: schema }: { default: Schema }) => {
  126. resolve({
  127. schema,
  128. collection: this.mongoDb!.collection(
  129. collectionName.toString()
  130. )
  131. });
  132. }
  133. );
  134. });
  135. }
  136. /**
  137. * loadCollections - Load and initialize all collections
  138. *
  139. * @returns Promise
  140. */
  141. private loadCollections(): Promise<void> {
  142. return new Promise((resolve, reject) => {
  143. const fetchCollections = async () => ({
  144. abc: await this.loadCollection("abc"),
  145. station: await this.loadCollection("station")
  146. });
  147. fetchCollections()
  148. .then(collections => {
  149. this.collections = collections;
  150. resolve();
  151. })
  152. .catch(err => {
  153. reject(new Error(err));
  154. });
  155. });
  156. }
  157. /**
  158. * Takes a raw projection and turns it into a projection we can easily use
  159. *
  160. * @param projection The raw projection
  161. * @returns Normalized projection
  162. */
  163. private normalizeProjection(projection: Projection): NormalizedProjection {
  164. let initialProjection = projection;
  165. if (
  166. !(projection && typeof initialProjection === "object") &&
  167. !Array.isArray(initialProjection)
  168. )
  169. initialProjection = [];
  170. // Flatten the projection into a 2-dimensional array of key-value pairs
  171. let flattenedProjection = this.flattenProjection(initialProjection);
  172. // Make sure all values are booleans
  173. flattenedProjection = flattenedProjection.map(([key, value]) => {
  174. if (typeof value !== "boolean") return [key, !!value];
  175. return [key, value];
  176. });
  177. // Validate whether we have any 1:1 duplicate keys, and if we do, throw a path collision error
  178. const projectionKeys = flattenedProjection.map(([key]) => key);
  179. const uniqueProjectionKeys = new Set(projectionKeys);
  180. if (uniqueProjectionKeys.size !== flattenedProjection.length)
  181. throw new Error("Path collision, non-unique key");
  182. // Check for path collisions that are not the same, but for example for nested keys, like prop1.prop2 and prop1.prop2.prop3
  183. projectionKeys.forEach(key => {
  184. // Non-nested paths don't need to be checked, they're covered by the earlier path collision checking
  185. if (key.indexOf(".") !== -1) {
  186. // Recursively check for each layer of a key whether that key exists already, and if it does, throw a path collision error
  187. const recursivelyCheckForPathCollision = (
  188. keyToCheck: string
  189. ) => {
  190. // Remove the last ".prop" from the key we want to check, to check if that has any collisions
  191. const subKey = keyToCheck.substring(
  192. 0,
  193. keyToCheck.lastIndexOf(".")
  194. );
  195. if (projectionKeys.indexOf(subKey) !== -1)
  196. throw new Error(
  197. `Path collision! ${key} collides with ${subKey}`
  198. );
  199. // The sub key has another layer or more, so check that layer for path collisions too
  200. if (subKey.indexOf(".") !== -1)
  201. recursivelyCheckForPathCollision(subKey);
  202. };
  203. recursivelyCheckForPathCollision(key);
  204. }
  205. });
  206. // Check if we explicitly allow anything (with the exception of _id)
  207. const anyNonIdTrues = flattenedProjection.reduce(
  208. (anyTrues, [key, value]) => anyTrues || (value && key !== "_id"),
  209. false
  210. );
  211. // By default, include everything except keys whose value is false
  212. let mode: "includeAllBut" | "excludeAllBut" = "includeAllBut";
  213. // If in the projection we have any keys whose value is true (with the exception of _id), switch to excluding all but keys we explicitly set to true in the projection
  214. if (anyNonIdTrues) mode = "excludeAllBut";
  215. return { projection: flattenedProjection, mode };
  216. }
  217. /**
  218. * Flatten the projection we've given (which can be an array of an object) into an array with key/value pairs
  219. *
  220. * @param projection
  221. * @returns
  222. */
  223. private flattenProjection(projection: Projection): [string, boolean][] {
  224. let flattenedProjection: [
  225. string,
  226. boolean | string[] | ProjectionObject
  227. ][] = [];
  228. if (!projection) throw new Error("Projection can't be null");
  229. // Turn object/array into a key/value array
  230. if (Array.isArray(projection))
  231. flattenedProjection = projection.map(key => [key, true]);
  232. else if (typeof projection === "object")
  233. flattenedProjection = Object.entries(projection);
  234. // Go through our projection array, and recursively check if there is another layer we need to flatten
  235. const newProjection: [string, boolean][] = flattenedProjection.reduce(
  236. (currentEntries: [string, boolean][], [key, value]) => {
  237. if (typeof value === "object") {
  238. let flattenedValue = this.flattenProjection(value);
  239. flattenedValue = flattenedValue.map(
  240. ([nextKey, nextValue]) => [
  241. `${key}.${nextKey}`,
  242. nextValue
  243. ]
  244. );
  245. return [...currentEntries, ...flattenedValue];
  246. }
  247. return [...currentEntries, [key, value]];
  248. },
  249. []
  250. );
  251. return newProjection;
  252. }
  253. /**
  254. * Parse a projection based on the schema and any given projection
  255. * If no projection is given, it will exclude any restricted properties
  256. * If a projection is given, it will exclude restricted properties that are not explicitly allowed in a projection
  257. * It will return a projection used in Mongo, and if any restricted property is explicitly allowed, return that we can't use the cache
  258. *
  259. * @param schema - The schema object
  260. * @param projection - The project, which can be null
  261. * @returns
  262. */
  263. private async parseFindProjection(
  264. projection: NormalizedProjection,
  265. schema: Document,
  266. allowedRestricted: any
  267. ) {
  268. // The mongo projection object we're going to build
  269. const mongoProjection = {};
  270. // This will be false if we let Mongo return any restricted properties
  271. let canCache = true;
  272. // TODO add better comments
  273. // TODO add support for nested objects in arrays
  274. const unfilteredEntries = Object.entries(schema);
  275. await async.forEach(unfilteredEntries, async ([key, value]) => {
  276. const { restricted } = value;
  277. // Check if the current property is allowed or not based on allowedRestricted
  278. const allowedByRestricted =
  279. !restricted || this.allowedByRestricted(allowedRestricted, key);
  280. // If the property is explicitly allowed in the projection, but also restricted, find can't use cache
  281. if (allowedByRestricted && restricted) {
  282. canCache = false;
  283. }
  284. // If the property is restricted, but not explicitly allowed, make sure to have mongo exclude it. As it's excluded from Mongo, caching isn't an issue for this property
  285. else if (!allowedByRestricted) {
  286. mongoProjection[key] = false;
  287. }
  288. // If the current property is a nested schema
  289. else if (value.type === Types.Schema) {
  290. // Get the projection for the next layer
  291. const deeperProjection = this.getDeeperProjection(
  292. projection,
  293. key
  294. );
  295. // Get the allowedRestricted for the next layer
  296. const deeperAllowedRestricted = this.getDeeperAllowedRestricted(
  297. allowedRestricted,
  298. key
  299. );
  300. // Parse projection for the current value, so one level deeper
  301. const parsedProjection = await this.parseFindProjection(
  302. deeperProjection,
  303. value.schema,
  304. deeperAllowedRestricted
  305. );
  306. // If the parsed projection mongo projection contains anything, update our own mongo projection
  307. if (Object.keys(parsedProjection.mongoProjection).length > 0)
  308. mongoProjection[key] = parsedProjection.mongoProjection;
  309. // If the parsed projection says we can't use the cache, make sure we can't use cache either
  310. canCache = canCache && parsedProjection.canCache;
  311. }
  312. });
  313. return {
  314. canCache,
  315. mongoProjection
  316. };
  317. }
  318. /**
  319. * Whether a property is allowed if it's restricted
  320. *
  321. * @param projection - The projection object/array
  322. * @param property - Property name
  323. * @returns
  324. */
  325. private allowedByRestricted(allowedRestricted: any, property: string) {
  326. // All restricted properties are allowed, so allow
  327. if (allowedRestricted === true) return true;
  328. // No restricted properties are allowed, so don't allow
  329. if (!allowedRestricted) return false;
  330. // allowedRestricted is not valid, so don't allow
  331. if (!Array.isArray(allowedRestricted)) return false;
  332. // This exact property is allowed, so allow
  333. if (allowedRestricted.indexOf(property) !== -1) return true;
  334. // Don't allow by default
  335. return false;
  336. }
  337. /**
  338. * Whether a property is allowed in a projection array/object
  339. *
  340. * @param projection - The projection object/array
  341. * @param property - Property name
  342. * @returns
  343. */
  344. private allowedByProjection(
  345. projection: NormalizedProjection,
  346. property: string
  347. ) {
  348. const obj = Object.fromEntries(projection.projection);
  349. if (projection.mode === "excludeAllBut") {
  350. // Only allow if explicitly allowed
  351. if (obj[property]) return true;
  352. // If this is a nested property that has any allowed properties at some lower level, allow at this level
  353. const nestedTrue = projection.projection.reduce(
  354. (nestedTrue, [key, value]) => {
  355. if (value && key.startsWith(`${property}.`)) return true;
  356. return nestedTrue;
  357. },
  358. false
  359. );
  360. return nestedTrue;
  361. }
  362. if (projection.mode === "includeAllBut") {
  363. // Explicitly excluded, so don't allow
  364. if (obj[property] === false) return false;
  365. // Not explicitly excluded, so allow this level
  366. return true;
  367. }
  368. // This should never happen
  369. return false;
  370. }
  371. /**
  372. * Returns the projection array/object that is one level deeper based on the property key
  373. *
  374. * @param projection - The projection object/array
  375. * @param key - The property key
  376. * @returns Array or Object
  377. */
  378. private getDeeperProjection(
  379. projection: NormalizedProjection,
  380. currentKey: string
  381. ): NormalizedProjection {
  382. const newProjection: [string, boolean][] = projection.projection
  383. // Go through all key/values
  384. .map(([key, value]) => {
  385. // If a key has no ".", it has no deeper level, so return false
  386. // If a key doesn't start with the provided currentKey, it's useless to us, so return false
  387. if (
  388. key.indexOf(".") === -1 ||
  389. !key.startsWith(`${currentKey}.`)
  390. )
  391. return false;
  392. // Get the lower key, so everything after "."
  393. const lowerKey = key.substring(
  394. key.indexOf(".") + 1,
  395. key.length
  396. );
  397. // If the lower key is empty for some reason, return false, but this should never happen
  398. if (lowerKey.length === 0) return false;
  399. return [lowerKey, value];
  400. })
  401. // Filter out any false's, so only key/value pairs remain
  402. // .filter<[string, boolean]>(entries => !!entries);
  403. .filter((entries): entries is [string, boolean] => !!entries);
  404. // Return the new projection with the projection array, and the same existing mode for the projection
  405. return { projection: newProjection, mode: projection.mode };
  406. }
  407. /**
  408. * Returns the allowedRestricted that is one level deeper based on the property key
  409. *
  410. * @param projection - The projection object/array
  411. * @param key - The property key
  412. * @returns Array or Object
  413. */
  414. private getDeeperAllowedRestricted(
  415. allowedRestricted: any,
  416. currentKey: string
  417. ) {
  418. //
  419. if (typeof allowedRestricted === "boolean") return allowedRestricted;
  420. if (!Array.isArray(allowedRestricted)) return false;
  421. const newAllowedRestricted = allowedRestricted
  422. // Go through all key/values
  423. .map(key => {
  424. // If a key has no ".", it has no deeper level, so return false
  425. // If a key doesn't start with the provided currentKey, it's useless to us, so return false
  426. if (
  427. key.indexOf(".") === -1 ||
  428. !key.startsWith(`${currentKey}.`)
  429. )
  430. return false;
  431. // Get the lower key, so everything after "."
  432. const lowerKey = key.substring(
  433. key.indexOf(".") + 1,
  434. key.length
  435. );
  436. // If the lower key is empty for some reason, return false, but this should never happen
  437. if (lowerKey.length === 0) return false;
  438. return lowerKey;
  439. })
  440. // Filter out any false's, so only keys remain
  441. .filter(entries => entries);
  442. // Return the new allowedRestricted
  443. return newAllowedRestricted;
  444. }
  445. private getCastedValue(value, schemaType: Types) {
  446. if (schemaType === Types.String) {
  447. // Check if value is a string, and if not, convert the value to a string
  448. const castedValue =
  449. typeof value === "string" ? value : String(value);
  450. // Any additional validation comes here
  451. return castedValue;
  452. }
  453. if (schemaType === Types.Number) {
  454. // Check if value is a number, and if not, convert the value to a number
  455. const castedValue =
  456. typeof value === "number" ? value : Number(value);
  457. // TODO possibly allow this via a validate boolean option?
  458. // We don't allow NaN for numbers, so throw an error
  459. if (Number.isNaN(castedValue))
  460. throw new Error(
  461. `Cast error, number cannot be NaN, with value ${value}`
  462. );
  463. // Any additional validation comes here
  464. return castedValue;
  465. }
  466. if (schemaType === Types.Date) {
  467. // Check if value is a Date, and if not, convert the value to a Date
  468. const castedValue =
  469. Object.prototype.toString.call(value) === "[object Date]"
  470. ? value
  471. : new Date(value);
  472. // TODO possibly allow this via a validate boolean option?
  473. // We don't allow invalid dates, so throw an error
  474. // eslint-disable-next-line
  475. if (isNaN(castedValue)) throw new Error(`Cast error, date cannot be invalid, at key ${key} with value ${value}`);
  476. // Any additional validation comes here
  477. return castedValue;
  478. }
  479. if (schemaType === Types.Boolean) {
  480. // Check if value is a boolean, and if not, convert the value to a boolean
  481. const castedValue =
  482. typeof value === "boolean" ? value : Boolean(value);
  483. // Any additional validation comes here
  484. return castedValue;
  485. }
  486. if (schemaType === Types.ObjectId) {
  487. // Cast the value as an ObjectId and let Mongoose handle the rest
  488. const castedValue = ObjectId(value);
  489. // Any additional validation comes here
  490. return castedValue;
  491. }
  492. throw new Error(
  493. `Unsupported schema type found with type ${Types[schemaType]}. This should never happen.`
  494. );
  495. }
  496. /**
  497. * parseFindFilter - Ensure validity of filter and return a mongo filter
  498. *
  499. * @param filter - Filter
  500. * @param schema - Schema of collection document
  501. * @param options - Parser options
  502. * @returns Promise returning object with query values cast to schema types
  503. * and whether query includes restricted attributes
  504. */
  505. private async parseFindFilter(
  506. filter: any,
  507. schema: Document,
  508. allowedRestricted: boolean | string[] | null | undefined,
  509. options?: {
  510. operators?: boolean;
  511. }
  512. ): Promise<{
  513. mongoFilter;
  514. containsRestrictedProperties: boolean;
  515. canCache: boolean;
  516. }> {
  517. // TODO validate whether restricted property is allowed
  518. if (!filter || typeof filter !== "object")
  519. throw new Error(
  520. "Invalid filter provided. Filter must be an object."
  521. );
  522. const keys = Object.keys(filter);
  523. if (keys.length === 0)
  524. throw new Error(
  525. "Invalid filter provided. Filter must contain keys."
  526. );
  527. // Whether to parse operators or not
  528. const operators = !(options && options.operators === false);
  529. // The MongoDB filter we're building
  530. const mongoFilter = {};
  531. // If the filter references any properties that are restricted, this will be true, so that find knows not to cache the query object
  532. let containsRestrictedProperties = false;
  533. // Whether this filter is cachable or not
  534. let canCache = true;
  535. // Operators at the key level that we support right now
  536. const allowedKeyOperators = ["$or", "$and"];
  537. // Operators at the value level that we support right now
  538. const allowedValueOperators = ["$in"];
  539. // Loop through all key/value properties
  540. await async.each(Object.entries(filter), async ([key, value]) => {
  541. // Key must be 1 character and exist
  542. if (!key || key.length === 0)
  543. throw new Error(
  544. `Invalid filter provided. Key must be at least 1 character.`
  545. );
  546. // Handle key operators, which always start with a $
  547. if (operators && key[0] === "$") {
  548. // Operator isn't found, so throw an error
  549. if (allowedKeyOperators.indexOf(key) === -1)
  550. throw new Error(
  551. `Invalid filter provided. Operator "${key}" is not allowed.`
  552. );
  553. // We currently only support $or and $and, but here we can have different logic for different operators
  554. if (key === "$or" || key === "$and") {
  555. // $or and $and should always be an array, so check if it is
  556. if (!Array.isArray(value) || value.length === 0)
  557. throw new Error(
  558. `Key "${key}" must contain array of filters.`
  559. );
  560. // Add the operator to the mongo filter object as an empty array
  561. mongoFilter[key] = [];
  562. // Run parseFindQuery again for child objects and add them to the mongo filter operator array
  563. await async.each(value, async _value => {
  564. const {
  565. mongoFilter: _mongoFilter,
  566. containsRestrictedProperties:
  567. _containsRestrictedProperties
  568. } = await this.parseFindFilter(
  569. _value,
  570. schema,
  571. allowedRestricted,
  572. options
  573. );
  574. // Actually add the returned filter object to the mongo filter we're building
  575. mongoFilter[key].push(_mongoFilter);
  576. if (_containsRestrictedProperties)
  577. containsRestrictedProperties = true;
  578. });
  579. } else
  580. throw new Error(
  581. `Unhandled operator "${key}", this should never happen!`
  582. );
  583. } else {
  584. // Here we handle any normal keys in the query object
  585. let currentKey = key;
  586. // If the key doesn't exist in the schema, throw an error
  587. if (!Object.hasOwn(schema, key)) {
  588. if (key.indexOf(".") !== -1) {
  589. currentKey = key.substring(0, key.indexOf("."));
  590. if (!Object.hasOwn(schema, currentKey))
  591. throw new Error(
  592. `Key "${currentKey}" does not exist in the schema.`
  593. );
  594. if (
  595. schema[currentKey].type !== Types.Schema &&
  596. (schema[currentKey].type !== Types.Array ||
  597. (schema[currentKey].item.type !==
  598. Types.Schema &&
  599. schema[currentKey].item.type !==
  600. Types.Array))
  601. )
  602. throw new Error(
  603. `Key "${currentKey}" is not a schema/array.`
  604. );
  605. } else
  606. throw new Error(
  607. `Key "${key}" does not exist in the schema.`
  608. );
  609. }
  610. const { restricted } = schema[currentKey];
  611. // Check if the current property is allowed or not based on allowedRestricted
  612. const allowedByRestricted =
  613. !restricted ||
  614. this.allowedByRestricted(allowedRestricted, currentKey);
  615. if (!allowedByRestricted)
  616. throw new Error(`Key "${currentKey}" is restricted.`);
  617. // If the key in the schema is marked as restricted, containsRestrictedProperties will be true
  618. if (restricted) containsRestrictedProperties = true;
  619. // Handle value operators
  620. if (
  621. operators &&
  622. typeof value === "object" &&
  623. value &&
  624. Object.keys(value).length === 1 &&
  625. Object.keys(value)[0] &&
  626. Object.keys(value)[0][0] === "$"
  627. ) {
  628. // This entire if statement is for handling value operators like $in
  629. const operator = Object.keys(value)[0];
  630. // Operator isn't found, so throw an error
  631. if (allowedValueOperators.indexOf(operator) === -1)
  632. throw new Error(
  633. `Invalid filter provided. Operator "${operator}" is not allowed.`
  634. );
  635. // Handle the $in value operator
  636. if (operator === "$in") {
  637. mongoFilter[currentKey] = {
  638. $in: []
  639. };
  640. // Decide what type should be for the values for $in
  641. let { type } = schema[currentKey];
  642. // We don't allow schema type for $in
  643. if (type === Types.Schema)
  644. throw new Error(
  645. `Key "${currentKey}" is of type schema, which is not allowed with $in`
  646. );
  647. // Set the type to be the array item type if it's about an array
  648. if (type === Types.Array) type = schema[key].item.type;
  649. // Loop through all $in array items, check if they're not null/undefined, cast them, and return a new array
  650. if (value.$in.length > 0)
  651. mongoFilter[currentKey].$in = await async.map(
  652. value.$in,
  653. async (_value: any) => {
  654. const isNullOrUndefined =
  655. _value === null || _value === undefined;
  656. if (isNullOrUndefined)
  657. throw new Error(
  658. `Value for key ${currentKey} using $in is undefuned/null, which is not allowed.`
  659. );
  660. const castedValue = this.getCastedValue(
  661. _value,
  662. type
  663. );
  664. return castedValue;
  665. }
  666. );
  667. } else
  668. throw new Error(
  669. `Unhandled operator "${operator}", this should never happen!`
  670. );
  671. }
  672. // Handle schema type
  673. else if (schema[currentKey].type === Types.Schema) {
  674. let subFilter;
  675. if (key.indexOf(".") !== -1) {
  676. const subKey = key.substring(
  677. key.indexOf(".") + 1,
  678. key.length
  679. );
  680. subFilter = {
  681. [subKey]: value
  682. };
  683. } else subFilter = value;
  684. // Get the allowedRestricted for the next layer
  685. const deeperAllowedRestricted =
  686. this.getDeeperAllowedRestricted(
  687. allowedRestricted,
  688. currentKey
  689. );
  690. // Run parseFindFilter on the nested schema object
  691. const {
  692. mongoFilter: _mongoFilter,
  693. containsRestrictedProperties:
  694. _containsRestrictedProperties
  695. } = await this.parseFindFilter(
  696. subFilter,
  697. schema[currentKey].schema,
  698. deeperAllowedRestricted,
  699. options
  700. );
  701. mongoFilter[currentKey] = _mongoFilter;
  702. if (_containsRestrictedProperties)
  703. containsRestrictedProperties = true;
  704. }
  705. // Handle array type
  706. else if (schema[currentKey].type === Types.Array) {
  707. const isNullOrUndefined =
  708. value === null || value === undefined;
  709. if (isNullOrUndefined)
  710. throw new Error(
  711. `Value for key ${currentKey} is an array item, so it cannot be null/undefined.`
  712. );
  713. // The type of the array items
  714. const itemType = schema[currentKey].item.type;
  715. // Handle nested arrays, which are not supported
  716. if (itemType === Types.Array)
  717. throw new Error("Nested arrays not supported");
  718. // Handle schema array item type
  719. else if (itemType === Types.Schema) {
  720. let subFilter;
  721. if (key.indexOf(".") !== -1) {
  722. const subKey = key.substring(
  723. key.indexOf(".") + 1,
  724. key.length
  725. );
  726. subFilter = {
  727. [subKey]: value
  728. };
  729. } else subFilter = value;
  730. // Get the allowedRestricted for the next layer
  731. const deeperAllowedRestricted =
  732. this.getDeeperAllowedRestricted(
  733. allowedRestricted,
  734. currentKey
  735. );
  736. const {
  737. mongoFilter: _mongoFilter,
  738. containsRestrictedProperties:
  739. _containsRestrictedProperties
  740. } = await this.parseFindFilter(
  741. subFilter,
  742. schema[currentKey].item.schema,
  743. deeperAllowedRestricted,
  744. options
  745. );
  746. mongoFilter[currentKey] = _mongoFilter;
  747. if (_containsRestrictedProperties)
  748. containsRestrictedProperties = true;
  749. }
  750. // Normal array item type
  751. else {
  752. // TODO possibly handle if a user gives some weird value here, like an object or array or $ operator
  753. mongoFilter[currentKey] = this.getCastedValue(
  754. value,
  755. itemType
  756. );
  757. }
  758. }
  759. // Handle normal types
  760. else {
  761. const isNullOrUndefined =
  762. value === null || value === undefined;
  763. if (isNullOrUndefined && schema[key].required)
  764. throw new Error(
  765. `Value for key ${key} is required, so it cannot be null/undefined.`
  766. );
  767. // If the value is null or undefined, just set it as null
  768. if (isNullOrUndefined) mongoFilter[key] = null;
  769. // Cast and validate values
  770. else {
  771. const schemaType = schema[key].type;
  772. mongoFilter[key] = this.getCastedValue(
  773. value,
  774. schemaType
  775. );
  776. }
  777. }
  778. }
  779. });
  780. if (containsRestrictedProperties) canCache = false;
  781. return { mongoFilter, containsRestrictedProperties, canCache };
  782. }
  783. /**
  784. * Strip a document object from any unneeded properties, or of any restricted properties
  785. * If a projection is given
  786. * Also casts some values
  787. *
  788. * @param document - The document object
  789. * @param schema - The schema object
  790. * @param projection - The projection, which can be null
  791. * @returns
  792. */
  793. private async stripDocument(
  794. document: any,
  795. schema: any,
  796. projection: NormalizedProjection,
  797. allowedRestricted: boolean | string[] | null | undefined
  798. ) {
  799. // TODO possibly do different things with required properties?
  800. // TODO possibly do different things with properties with default?
  801. const unfilteredEntries = Object.entries(document);
  802. // Go through all properties in the document to decide whether to allow it or not, and possibly casts the value to its property type
  803. const filteredEntries = await async.reduce(
  804. unfilteredEntries,
  805. [],
  806. async (memo, [key, value]) => {
  807. // If the property does not exist in the schema, return the memo, so we won't return the key/value in the stripped document
  808. if (!schema[key]) return memo;
  809. // If we have a projection, check if the current key is allowed by it. If it not, just return the memo
  810. const allowedByProjection = this.allowedByProjection(
  811. projection,
  812. key
  813. );
  814. const allowedByRestricted =
  815. !schema[key].restricted ||
  816. this.allowedByRestricted(allowedRestricted, key);
  817. if (!allowedByProjection) return memo;
  818. if (!allowedByRestricted) return memo;
  819. // Handle nested object
  820. if (schema[key].type === Types.Schema) {
  821. // TODO possibly return nothing, or an empty object here instead?
  822. // If value is falsy, it can't be an object, so just return null
  823. if (!value) return [...memo, [key, null]];
  824. // Get the projection for the next layer
  825. const deeperProjection = this.getDeeperProjection(
  826. projection,
  827. key
  828. );
  829. // Get the allowedRestricted for the next layer
  830. const deeperAllowedRestricted =
  831. this.getDeeperAllowedRestricted(allowedRestricted, key);
  832. // Generate a stripped document/object for the current key/value
  833. const strippedDocument = await this.stripDocument(
  834. value,
  835. schema[key].schema,
  836. deeperProjection,
  837. deeperAllowedRestricted
  838. );
  839. // If the returned stripped document/object has keys, add the current key with that document/object to the memeo
  840. if (Object.keys(strippedDocument).length > 0)
  841. return [...memo, [key, strippedDocument]];
  842. // TODO possibly return null or an object here for the key instead?
  843. // The current key has no values that should be returned, so just return the memo
  844. return memo;
  845. }
  846. // Handle array type
  847. if (schema[key].type === Types.Array) {
  848. // TODO possibly return nothing, or an empty array here instead?
  849. // If value is falsy, return null with the key instead
  850. if (!value) return [...memo, [key, null]];
  851. // TODO possibly return nothing, or an empty array here instead?
  852. // If value isn't a valid array, return null with the key instead
  853. if (!Array.isArray(value)) return [...memo, [key, null]];
  854. // The type of the array items
  855. const itemType = schema[key].item.type;
  856. const items = await async.map(value, async item => {
  857. // Handle schema objects inside an array
  858. if (itemType === Types.Schema) {
  859. // TODO possibly return nothing, or an empty object here instead?
  860. // If item is falsy, it can't be an object, so just return null
  861. if (!item) return null;
  862. // Get the projection for the next layer
  863. const deeperProjection = this.getDeeperProjection(
  864. projection,
  865. key
  866. );
  867. // Get the allowedRestricted for the next layer
  868. const deeperAllowedRestricted =
  869. this.getDeeperAllowedRestricted(
  870. allowedRestricted,
  871. key
  872. );
  873. // Generate a stripped document/object for the current key/value
  874. const strippedDocument = await this.stripDocument(
  875. item,
  876. schema[key].item.schema,
  877. deeperProjection,
  878. deeperAllowedRestricted
  879. );
  880. // If the returned stripped document/object has keys, return the stripped document
  881. if (Object.keys(strippedDocument).length > 0)
  882. return strippedDocument;
  883. // TODO possibly return object here instead?
  884. // The current item has no values that should be returned, so just return null
  885. return null;
  886. }
  887. // Nested arrays are not supported
  888. if (itemType === Types.Array) {
  889. throw new Error("Nested arrays not supported");
  890. }
  891. // Handle normal types
  892. else {
  893. // If item is null or undefined, return null
  894. const isNullOrUndefined =
  895. item === null || item === undefined;
  896. if (isNullOrUndefined) return null;
  897. // TODO possibly don't validate casted in getCastedValue?
  898. // Cast item
  899. const castedValue = this.getCastedValue(
  900. item,
  901. itemType
  902. );
  903. return castedValue;
  904. }
  905. });
  906. return [...memo, [key, items]];
  907. }
  908. // Handle normal types
  909. // TODO possible don't validate casted in getCastedValue?
  910. // Cast item
  911. const castedValue = this.getCastedValue(
  912. value,
  913. schema[key].type
  914. );
  915. return [...memo, [key, castedValue]];
  916. }
  917. );
  918. return Object.fromEntries(filteredEntries);
  919. }
  920. // TODO improve caching
  921. // TODO add support for computed fields
  922. // TODO parse query - validation
  923. // TODO add proper typescript support
  924. // TODO add proper jsdoc
  925. // TODO add support for enum document attributes
  926. // TODO add support for array document attributes
  927. // TODO add support for reference document attributes
  928. // TODO fix 2nd layer of schema
  929. /**
  930. * find - Get one or more document(s) from a single collection
  931. *
  932. * @param payload - Payload
  933. * @returns Returned object
  934. */
  935. public find<CollectionNameType extends keyof Collections>(
  936. context: JobContext,
  937. {
  938. collection, // Collection name
  939. filter, // Similar to MongoDB filter
  940. projection,
  941. allowedRestricted,
  942. limit = 0, // TODO have limit off by default?
  943. page = 1,
  944. useCache = true
  945. }: {
  946. collection: CollectionNameType;
  947. filter: Record<string, any>;
  948. projection?: Projection;
  949. allowedRestricted?: boolean | string[];
  950. values?: Record<string, any>;
  951. limit?: number;
  952. page?: number;
  953. useCache?: boolean;
  954. }
  955. ): Promise<any | null> {
  956. return new Promise((resolve, reject) => {
  957. let queryHash: string | null = null;
  958. let cacheable = useCache !== false;
  959. let schema: Schema;
  960. let normalizedProjection: NormalizedProjection;
  961. let mongoFilter;
  962. let mongoProjection;
  963. async.waterfall(
  964. [
  965. // Verify whether the collection exists, and get the schema
  966. async () => {
  967. if (!collection)
  968. throw new Error("No collection specified");
  969. if (this.collections && !this.collections[collection])
  970. throw new Error("Collection not found");
  971. schema = this.collections![collection].schema;
  972. },
  973. // Normalize the projection into something we understand, and which throws an error if we have any path collisions
  974. async () => {
  975. normalizedProjection =
  976. this.normalizeProjection(projection);
  977. },
  978. // TOOD validate the projection based on the schema here
  979. // Parse the projection into a mongo projection, and returns whether this query can be cached or not
  980. async () => {
  981. const parsedProjection = await this.parseFindProjection(
  982. normalizedProjection,
  983. schema.getDocument(),
  984. allowedRestricted
  985. );
  986. cacheable = cacheable && parsedProjection.canCache;
  987. mongoProjection = parsedProjection.mongoProjection;
  988. },
  989. // Parse the filter into a mongo filter, which also validates whether the filter is legal or not, and returns whether this query can be cached or not
  990. async () => {
  991. const parsedFilter = await this.parseFindFilter(
  992. filter,
  993. schema.getDocument(),
  994. allowedRestricted
  995. );
  996. cacheable = cacheable && parsedFilter.canCache;
  997. mongoFilter = parsedFilter.mongoFilter;
  998. },
  999. // If we can use cache, get from the cache, and if we get results return those
  1000. async () => {
  1001. // If we're allowed to cache, and the filter doesn't reference any restricted fields, try to cache the query and its response
  1002. if (cacheable) {
  1003. // Turn the query object into a sha1 hash that can be used as a Redis key
  1004. queryHash = hash(
  1005. {
  1006. collection,
  1007. mongoFilter,
  1008. limit,
  1009. page
  1010. },
  1011. {
  1012. algorithm: "sha1"
  1013. }
  1014. );
  1015. // Check if the query hash already exists in Redis, and get it if it is
  1016. const cachedQuery = await this.redisClient?.GET(
  1017. `query.find.${queryHash}`
  1018. );
  1019. // Return the mongoFilter along with the cachedDocuments, if any
  1020. return {
  1021. cachedDocuments: cachedQuery
  1022. ? JSON.parse(cachedQuery)
  1023. : null
  1024. };
  1025. }
  1026. // We can't use the cache, so just continue with no cached documents
  1027. return { cachedDocuments: null };
  1028. },
  1029. // Get documents from Mongo if we got no cached documents
  1030. async ({ cachedDocuments }: any) => {
  1031. // We got cached documents, so continue with those
  1032. if (cachedDocuments) {
  1033. cacheable = false;
  1034. return cachedDocuments;
  1035. }
  1036. // TODO, add mongo projection. Make sure to keep in mind caching with queryHash.
  1037. // Create the Mongo cursor and then return the promise that gets the array of documents
  1038. return this.collections?.[collection].collection
  1039. .find(mongoFilter, mongoProjection)
  1040. .limit(limit)
  1041. .skip((page - 1) * limit)
  1042. .toArray();
  1043. },
  1044. // Add documents to the cache
  1045. async (documents: any[]) => {
  1046. // Adds query results to cache but doesnt await
  1047. if (cacheable && queryHash) {
  1048. this.redisClient!.SET(
  1049. `query.find.${queryHash}`,
  1050. JSON.stringify(documents),
  1051. {
  1052. EX: 60
  1053. }
  1054. );
  1055. }
  1056. return documents;
  1057. },
  1058. // Strips the document of any unneeded properties or properties that are restricted
  1059. async (documents: any[]) =>
  1060. async.map(documents, async (document: any) =>
  1061. this.stripDocument(
  1062. document,
  1063. schema.getDocument(),
  1064. normalizedProjection,
  1065. allowedRestricted
  1066. )
  1067. )
  1068. ],
  1069. (err, documents?: any[]) => {
  1070. if (err) reject(err);
  1071. else if (!documents || documents!.length === 0)
  1072. resolve(limit === 1 ? null : []);
  1073. else resolve(limit === 1 ? documents![0] : documents);
  1074. }
  1075. );
  1076. });
  1077. }
  1078. }
  1079. export type DataModuleJobs = {
  1080. [Property in keyof UniqueMethods<DataModule>]: {
  1081. payload: Parameters<UniqueMethods<DataModule>[Property]>[1];
  1082. returns: Awaited<ReturnType<UniqueMethods<DataModule>[Property]>>;
  1083. };
  1084. };