|
@@ -1,6 +1,7 @@
|
|
|
import async from "async";
|
|
|
import config from "config";
|
|
|
import mongoose, { Schema } from "mongoose";
|
|
|
+import hash from "object-hash";
|
|
|
import { createClient, RedisClientType } from "redis";
|
|
|
import BaseModule from "../BaseModule";
|
|
|
import ModuleManager from "../ModuleManager";
|
|
@@ -40,8 +41,10 @@ export default class DataModule extends BaseModule {
|
|
|
|
|
|
async () => {
|
|
|
if (this.collections) {
|
|
|
- Object.values(this.collections).forEach(
|
|
|
- collection => collection.model.syncIndexes()
|
|
|
+ await async.each(
|
|
|
+ Object.values(this.collections),
|
|
|
+ async collection =>
|
|
|
+ collection.model.syncIndexes()
|
|
|
);
|
|
|
} else
|
|
|
throw new Error("Collections have not been loaded");
|
|
@@ -168,15 +171,109 @@ export default class DataModule extends BaseModule {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- // TODO decide on whether to throw an exception if no results found, possible configurable via param
|
|
|
+ // TODO split core into parseDocument(document, schema, { partial: boolean; })
|
|
|
+ /**
|
|
|
+ * parseQuery - Ensure validity of query
|
|
|
+ *
|
|
|
+ * @param query - Query
|
|
|
+ * @param schema - Schema of collection document
|
|
|
+ * @param options - Parser options
|
|
|
+ * @returns Promise returning object with query values cast to schema types
|
|
|
+ * and whether query includes restricted attributes
|
|
|
+ */
|
|
|
+ private async parseQuery(
|
|
|
+ query: any,
|
|
|
+ schema: any,
|
|
|
+ options?: {
|
|
|
+ operators?: boolean;
|
|
|
+ }
|
|
|
+ ): Promise<{ castQuery: any; restricted: boolean }> {
|
|
|
+ if (!query || typeof query !== "object")
|
|
|
+ throw new Error("Invalid query provided. Query must be an object.");
|
|
|
+ const keys = Object.keys(query);
|
|
|
+ if (keys.length === 0)
|
|
|
+ throw new Error("Invalid query provided. Query must contain keys.");
|
|
|
+ const operators = !(options && options.operators === false);
|
|
|
+ const castQuery: any = {};
|
|
|
+ let restricted = false;
|
|
|
+ await async.each(Object.entries(query), async ([key, value]) => {
|
|
|
+ if (operators && (key === "$or" || key === "$and")) {
|
|
|
+ if (!Array.isArray(value))
|
|
|
+ throw new Error(
|
|
|
+ `Key "${key}" must contain array of queries`
|
|
|
+ );
|
|
|
+ castQuery[key] = [];
|
|
|
+ await async.each(value, async _value => {
|
|
|
+ const { castQuery: _castQuery, restricted: _restricted } =
|
|
|
+ await this.parseQuery(_value, schema, options);
|
|
|
+ castQuery[key].push(_castQuery);
|
|
|
+ restricted = restricted || _restricted;
|
|
|
+ });
|
|
|
+ } else if (schema[key] !== undefined) {
|
|
|
+ if (schema[key].restricted) restricted = true;
|
|
|
+ if (
|
|
|
+ schema[key].type === undefined &&
|
|
|
+ Object.keys(schema[key]).length > 0
|
|
|
+ ) {
|
|
|
+ const { castQuery: _castQuery, restricted: _restricted } =
|
|
|
+ await this.parseQuery(value, schema[key], options);
|
|
|
+ castQuery[key] = _castQuery;
|
|
|
+ restricted = restricted || _restricted;
|
|
|
+ } else if (
|
|
|
+ operators &&
|
|
|
+ typeof value === "object" &&
|
|
|
+ Object.keys(value).length === 1 &&
|
|
|
+ Array.isArray(value.$in)
|
|
|
+ ) {
|
|
|
+ if (value.$in.length > 0)
|
|
|
+ castQuery[key] = {
|
|
|
+ $in: await async.map(
|
|
|
+ value.$in,
|
|
|
+ async (_value: any) => {
|
|
|
+ if (
|
|
|
+ key === "_id" &&
|
|
|
+ !schema[key].type.isValid(_value)
|
|
|
+ )
|
|
|
+ throw new Error(
|
|
|
+ "Invalid value for _id"
|
|
|
+ );
|
|
|
+ if (typeof schema[key].type === "function")
|
|
|
+ return new schema[key].type(_value);
|
|
|
+ throw new Error(
|
|
|
+ `Invalid schema type for ${key}`
|
|
|
+ );
|
|
|
+ }
|
|
|
+ )
|
|
|
+ };
|
|
|
+ else throw new Error(`Invalid value for ${key}`);
|
|
|
+ } else {
|
|
|
+ if (key === "_id" && !schema[key].type.isValid(value))
|
|
|
+ throw new Error("Invalid value for _id");
|
|
|
+ if (typeof schema[key].type === "function")
|
|
|
+ castQuery[key] = new schema[key].type(value);
|
|
|
+ else throw new Error(`Invalid schema type for ${key}`);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ throw new Error(
|
|
|
+ `Invalid query provided. Key "${key}" not found`
|
|
|
+ );
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return { castQuery, restricted };
|
|
|
+ }
|
|
|
+
|
|
|
// TODO hide sensitive fields
|
|
|
- // TOOD don't store sensitive fields in cache
|
|
|
// TODO improve caching
|
|
|
// TODO add option to only request certain fields
|
|
|
// TODO add support for computed fields
|
|
|
- // TODO parse query
|
|
|
+ // TODO parse query - validation
|
|
|
// TODO add proper typescript support
|
|
|
// TODO add proper jsdoc
|
|
|
+ // TODO add support for enum document attributes
|
|
|
+ // TODO add support for array document attributes
|
|
|
+ // TODO add support for reference document attributes
|
|
|
+ // TODO prevent caching if requiring restricted values
|
|
|
+ // TODO fix 2nd layer of schema
|
|
|
/**
|
|
|
* find - Find data
|
|
|
*
|
|
@@ -189,8 +286,7 @@ export default class DataModule extends BaseModule {
|
|
|
values, // TODO: Add support
|
|
|
limit = 1, // TODO have limit off by default?
|
|
|
page = 1,
|
|
|
- useCache = true,
|
|
|
- convertArrayToSingle = false
|
|
|
+ useCache = true
|
|
|
}: {
|
|
|
collection: T;
|
|
|
query: Record<string, any>;
|
|
@@ -198,11 +294,10 @@ export default class DataModule extends BaseModule {
|
|
|
limit?: number;
|
|
|
page?: number;
|
|
|
useCache?: boolean;
|
|
|
- convertArrayToSingle?: boolean;
|
|
|
- }): Promise<any> {
|
|
|
+ }): Promise<any | null> {
|
|
|
return new Promise((resolve, reject) => {
|
|
|
- let addToCache = false;
|
|
|
- let cacheKeyName: string | null = null;
|
|
|
+ let queryHash: string | null = null;
|
|
|
+ let cacheable = useCache !== false;
|
|
|
|
|
|
async.waterfall(
|
|
|
[
|
|
@@ -215,129 +310,100 @@ export default class DataModule extends BaseModule {
|
|
|
},
|
|
|
|
|
|
// Verify whether the query is valid-enough to continue
|
|
|
- async () => {
|
|
|
- if (
|
|
|
- !query ||
|
|
|
- typeof query !== "object" ||
|
|
|
- Object.keys(query).length === 0
|
|
|
- )
|
|
|
- new Error(
|
|
|
- "Invalid query provided. Query must be an object."
|
|
|
- );
|
|
|
- },
|
|
|
+ async () =>
|
|
|
+ this.parseQuery(
|
|
|
+ query,
|
|
|
+ this.collections![collection].schema.document
|
|
|
+ ),
|
|
|
|
|
|
// If we can use cache, get from the cache, and if we get results return those, otherwise return null
|
|
|
- async () => {
|
|
|
- // Not using cache, so return
|
|
|
- if (!useCache) return null;
|
|
|
- // More than one query key, so impossible to get from cache
|
|
|
- if (Object.keys(query).length > 1) return null;
|
|
|
-
|
|
|
- // First key and only key in query object
|
|
|
- const queryPropertyName = Object.keys(query)[0];
|
|
|
- // Corresponding property from schema document
|
|
|
- const documentProperty =
|
|
|
- this.collections![collection].schema.document[
|
|
|
- queryPropertyName
|
|
|
- ];
|
|
|
-
|
|
|
- if (!documentProperty)
|
|
|
- throw new Error(
|
|
|
- `Query property ${queryPropertyName} not found in document.`
|
|
|
- );
|
|
|
- // If query name is not a cache key, just continue
|
|
|
- if (!documentProperty.cacheKey) return null;
|
|
|
-
|
|
|
- const values = [];
|
|
|
- if (
|
|
|
- Object.prototype.hasOwnProperty.call(
|
|
|
- query[queryPropertyName],
|
|
|
- "$in"
|
|
|
- )
|
|
|
- )
|
|
|
- values.push(...query[queryPropertyName].$in);
|
|
|
- else values.push(query[queryPropertyName]);
|
|
|
-
|
|
|
- const cachedDocuments: any[] = [];
|
|
|
-
|
|
|
- await async.each(values, async value =>
|
|
|
- this.redis
|
|
|
- ?.GET(
|
|
|
- `${collection}.${queryPropertyName}.${value.toString()}`
|
|
|
- )
|
|
|
- .then((cachedDocument: any) => {
|
|
|
- if (cachedDocument)
|
|
|
- cachedDocuments.push(
|
|
|
- JSON.parse(cachedDocument)
|
|
|
- );
|
|
|
- })
|
|
|
+ async ({ castQuery, restricted }: any) => {
|
|
|
+ // Not using cache or query contains restricted values, so return
|
|
|
+ if (!cacheable || restricted)
|
|
|
+ return { castQuery, cachedDocuments: null };
|
|
|
+ queryHash = hash(
|
|
|
+ { collection, castQuery, values, limit, page },
|
|
|
+ {
|
|
|
+ algorithm: "sha1"
|
|
|
+ }
|
|
|
);
|
|
|
-
|
|
|
- // TODO optimize this
|
|
|
- if (cachedDocuments.length !== values.length) {
|
|
|
- addToCache = true;
|
|
|
- cacheKeyName = queryPropertyName;
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- return cachedDocuments;
|
|
|
+ const cachedQuery = await this.redis?.GET(
|
|
|
+ `query.find.${queryHash}`
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ castQuery,
|
|
|
+ cachedDocuments: cachedQuery
|
|
|
+ ? JSON.parse(cachedQuery)
|
|
|
+ : null
|
|
|
+ };
|
|
|
},
|
|
|
|
|
|
// If we didn't get documents from the cache, get them from mongo
|
|
|
- async (cachedDocuments: any[] | null) => {
|
|
|
- if (cachedDocuments) return cachedDocuments;
|
|
|
-
|
|
|
+ async ({ castQuery, cachedDocuments }: any) => {
|
|
|
+ if (cachedDocuments) {
|
|
|
+ cacheable = false;
|
|
|
+ return cachedDocuments;
|
|
|
+ }
|
|
|
+ const getFindValues = async (object: any) => {
|
|
|
+ const find: any = {};
|
|
|
+ await async.each(
|
|
|
+ Object.entries(object),
|
|
|
+ async ([key, value]) => {
|
|
|
+ if (
|
|
|
+ value.type === undefined &&
|
|
|
+ Object.keys(value).length > 0
|
|
|
+ ) {
|
|
|
+ const _find = await getFindValues(
|
|
|
+ value
|
|
|
+ );
|
|
|
+ if (Object.keys(_find).length > 0)
|
|
|
+ find[key] = _find;
|
|
|
+ } else if (!value.restricted)
|
|
|
+ find[key] = true;
|
|
|
+ }
|
|
|
+ );
|
|
|
+ return find;
|
|
|
+ };
|
|
|
+ const find: any = await getFindValues(
|
|
|
+ this.collections![collection].schema.document
|
|
|
+ );
|
|
|
return this.collections?.[collection].model
|
|
|
- .find(query)
|
|
|
+ .find(castQuery, find)
|
|
|
.limit(limit)
|
|
|
.skip((page - 1) * limit);
|
|
|
},
|
|
|
|
|
|
- // Convert documents from Mongoose model to regular objects, and if we got no documents throw an error
|
|
|
- async (documents: any[]) => {
|
|
|
- if (documents.length === 0)
|
|
|
- throw new Error("No results found.");
|
|
|
-
|
|
|
- return documents.map(document => {
|
|
|
- if (!document._doc) return document;
|
|
|
-
|
|
|
- const rawDocument = document._doc;
|
|
|
- rawDocument._id = rawDocument._id.toString();
|
|
|
- return rawDocument;
|
|
|
- });
|
|
|
- },
|
|
|
+ // Convert documents from Mongoose model to regular objects
|
|
|
+ async (documents: any[]) =>
|
|
|
+ async.map(documents, async (document: any) => {
|
|
|
+ const { castQuery } = await this.parseQuery(
|
|
|
+ document._doc || document,
|
|
|
+ this.collections![collection].schema.document,
|
|
|
+ { operators: false }
|
|
|
+ );
|
|
|
+ return castQuery;
|
|
|
+ }),
|
|
|
|
|
|
// Add documents to the cache
|
|
|
async (documents: any[]) => {
|
|
|
- // TODO only add new things to cache
|
|
|
- // Adds the fetched documents to the cache, but doesn't wait for it to complete
|
|
|
- if (addToCache && cacheKeyName) {
|
|
|
- async.each(
|
|
|
- documents,
|
|
|
- // TODO verify that the cache key name property actually exists for these documents
|
|
|
- async (document: any) =>
|
|
|
- this.redis!.SET(
|
|
|
- `${collection}.${cacheKeyName}.${document[
|
|
|
- cacheKeyName!
|
|
|
- ].toString()}`,
|
|
|
- JSON.stringify(document),
|
|
|
- {
|
|
|
- EX: 60
|
|
|
- }
|
|
|
- )
|
|
|
+ // Adds query results to cache but doesnt await
|
|
|
+ if (cacheable && queryHash) {
|
|
|
+ this.redis!.SET(
|
|
|
+ `query.find.${queryHash}`,
|
|
|
+ JSON.stringify(documents),
|
|
|
+ {
|
|
|
+ EX: 60
|
|
|
+ }
|
|
|
);
|
|
|
}
|
|
|
-
|
|
|
return documents;
|
|
|
}
|
|
|
],
|
|
|
(err, documents?: any[]) => {
|
|
|
if (err) reject(err);
|
|
|
- else if (convertArrayToSingle)
|
|
|
- resolve(
|
|
|
- documents!.length === 1 ? documents![0] : documents
|
|
|
- );
|
|
|
- else resolve(documents);
|
|
|
+ else if (!documents || documents!.length === 0)
|
|
|
+ resolve(limit === 1 ? null : []);
|
|
|
+ else resolve(limit === 1 ? documents![0] : documents);
|
|
|
}
|
|
|
);
|
|
|
});
|