375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
/* eslint-disable no-await-in-loop */
|
|
/* eslint-disable no-restricted-syntax */
|
|
import deepmerge from 'deepmerge';
|
|
import mongoose, { FilterQuery } from 'mongoose';
|
|
import { combineMerge } from '../utilities/combineMerge';
|
|
import { CollectionModel } from '../collections/config/types';
|
|
import { getSchemaTypeOptions } from './getSchemaTypeOptions';
|
|
import { operatorMap } from './operatorMap';
|
|
import { sanitizeQueryValue } from './sanitizeFormattedValue';
|
|
|
|
const validOperators = ['like', 'contains', 'in', 'all', 'not_in', 'greater_than_equal', 'greater_than', 'less_than_equal', 'less_than', 'not_equals', 'equals', 'exists', 'near'];
|
|
|
|
const subQueryOptions = {
|
|
limit: 50,
|
|
lean: true,
|
|
};
|
|
|
|
type ParseType = {
|
|
searchParams?:
|
|
{
|
|
[key: string]: any;
|
|
};
|
|
sort?: boolean;
|
|
};
|
|
|
|
type PathToQuery = {
|
|
complete: boolean
|
|
path: string
|
|
Model: CollectionModel
|
|
}
|
|
|
|
type SearchParam = {
|
|
path?: string,
|
|
value: unknown,
|
|
}
|
|
|
|
class ParamParser {
|
|
locale: string;
|
|
|
|
queryHiddenFields: boolean
|
|
|
|
rawParams: any;
|
|
|
|
model: any;
|
|
|
|
query: {
|
|
searchParams: {
|
|
[key: string]: any;
|
|
};
|
|
sort: boolean;
|
|
};
|
|
|
|
constructor(model, rawParams, locale: string, queryHiddenFields?: boolean) {
|
|
this.parse = this.parse.bind(this);
|
|
this.model = model;
|
|
this.rawParams = rawParams;
|
|
this.locale = locale;
|
|
this.queryHiddenFields = queryHiddenFields;
|
|
this.query = {
|
|
searchParams: {},
|
|
sort: false,
|
|
};
|
|
}
|
|
|
|
// Entry point to the ParamParser class
|
|
|
|
async parse(): Promise<ParseType> {
|
|
if (typeof this.rawParams === 'object') {
|
|
for (const key of Object.keys(this.rawParams)) {
|
|
if (key === 'where') {
|
|
this.query.searchParams = await this.parsePathOrRelation(this.rawParams.where);
|
|
} else if (key === 'sort') {
|
|
this.query.sort = this.rawParams[key];
|
|
}
|
|
}
|
|
return this.query;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
async parsePathOrRelation(object) {
|
|
let result = {} as FilterQuery<any>;
|
|
// We need to determine if the whereKey is an AND, OR, or a schema path
|
|
for (const relationOrPath of Object.keys(object)) {
|
|
if (relationOrPath.toLowerCase() === 'and') {
|
|
const andConditions = object[relationOrPath];
|
|
const builtAndConditions = await this.buildAndOrConditions(andConditions);
|
|
if (builtAndConditions.length > 0) result.$and = builtAndConditions;
|
|
} else if (relationOrPath.toLowerCase() === 'or' && Array.isArray(object[relationOrPath])) {
|
|
const orConditions = object[relationOrPath];
|
|
const builtOrConditions = await this.buildAndOrConditions(orConditions);
|
|
if (builtOrConditions.length > 0) result.$or = builtOrConditions;
|
|
} else {
|
|
// It's a path - and there can be multiple comparisons on a single path.
|
|
// For example - title like 'test' and title not equal to 'tester'
|
|
// So we need to loop on keys again here to handle each operator independently
|
|
const pathOperators = object[relationOrPath];
|
|
if (typeof pathOperators === 'object') {
|
|
for (const operator of Object.keys(pathOperators)) {
|
|
if (validOperators.includes(operator)) {
|
|
const searchParam = await this.buildSearchParam(this.model.schema, relationOrPath, pathOperators[operator], operator);
|
|
|
|
if (searchParam?.value && searchParam?.path) {
|
|
result = {
|
|
...result,
|
|
[searchParam.path]: searchParam.value,
|
|
};
|
|
} else if (typeof searchParam?.value === 'object') {
|
|
result = deepmerge(result, searchParam.value, { arrayMerge: combineMerge });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async buildAndOrConditions(conditions) {
|
|
const completedConditions = [];
|
|
// Loop over all AND / OR operations and add them to the AND / OR query param
|
|
// Operations should come through as an array
|
|
for (const condition of conditions) {
|
|
// If the operation is properly formatted as an object
|
|
if (typeof condition === 'object') {
|
|
const result = await this.parsePathOrRelation(condition);
|
|
if (Object.keys(result).length > 0) {
|
|
completedConditions.push(result);
|
|
}
|
|
}
|
|
}
|
|
return completedConditions;
|
|
}
|
|
|
|
// Build up an array of auto-localized paths to search on
|
|
// Multiple paths may be possible if searching on properties of relationship fields
|
|
|
|
getLocalizedPaths(Model: CollectionModel, incomingPath: string, operator): PathToQuery[] {
|
|
const { schema } = Model;
|
|
const pathSegments = incomingPath.split('.');
|
|
|
|
let paths: PathToQuery[] = [
|
|
{
|
|
path: '',
|
|
complete: false,
|
|
Model,
|
|
},
|
|
];
|
|
|
|
pathSegments.every((segment, i) => {
|
|
const lastIncompletePath = paths.find(({ complete }) => !complete);
|
|
const { path } = lastIncompletePath;
|
|
|
|
const currentPath = path ? `${path}.${segment}` : segment;
|
|
const currentSchemaType = schema.path(currentPath);
|
|
const currentSchemaPathType = schema.pathType(currentPath);
|
|
|
|
if (currentSchemaPathType === 'nested') {
|
|
lastIncompletePath.path = currentPath;
|
|
return true;
|
|
}
|
|
|
|
const upcomingSegment = pathSegments[i + 1];
|
|
|
|
if (currentSchemaType && currentSchemaPathType !== 'adhocOrUndefined') {
|
|
const currentSchemaTypeOptions = getSchemaTypeOptions(currentSchemaType);
|
|
|
|
if (currentSchemaTypeOptions.localized) {
|
|
const upcomingLocalizedPath = `${currentPath}.${upcomingSegment}`;
|
|
const upcomingSchemaTypeWithLocale = schema.path(upcomingLocalizedPath);
|
|
|
|
if (upcomingSchemaTypeWithLocale) {
|
|
lastIncompletePath.path = currentPath;
|
|
return true;
|
|
}
|
|
|
|
const localePath = `${currentPath}.${this.locale}`;
|
|
const localizedSchemaType = schema.path(localePath);
|
|
|
|
if (localizedSchemaType || operator === 'near') {
|
|
lastIncompletePath.path = localePath;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
lastIncompletePath.path = currentPath;
|
|
return true;
|
|
}
|
|
|
|
const priorSchemaType = schema.path(path);
|
|
|
|
if (priorSchemaType) {
|
|
const priorSchemaTypeOptions = getSchemaTypeOptions(priorSchemaType);
|
|
if (typeof priorSchemaTypeOptions.ref === 'string') {
|
|
const RefModel = mongoose.model(priorSchemaTypeOptions.ref) as any;
|
|
|
|
lastIncompletePath.complete = true;
|
|
|
|
const remainingPath = pathSegments.slice(i).join('.');
|
|
|
|
paths = [
|
|
...paths,
|
|
...this.getLocalizedPaths(RefModel, remainingPath, operator),
|
|
];
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (operator === 'near' || currentSchemaPathType === 'adhocOrUndefined') {
|
|
lastIncompletePath.path = currentPath;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
return paths;
|
|
}
|
|
|
|
// Convert the Payload key / value / operator into a MongoDB query
|
|
async buildSearchParam(schema, incomingPath, val, operator): Promise<SearchParam> {
|
|
// Replace GraphQL nested field double underscore formatting
|
|
let sanitizedPath = incomingPath.replace(/__/gi, '.');
|
|
if (sanitizedPath === 'id') sanitizedPath = '_id';
|
|
|
|
const collectionPaths = this.getLocalizedPaths(this.model, sanitizedPath, operator);
|
|
const [{ path }] = collectionPaths;
|
|
|
|
if (path) {
|
|
const schemaType = schema.path(path);
|
|
const schemaOptions = getSchemaTypeOptions(schemaType);
|
|
const formattedValue = sanitizeQueryValue(schemaType, path, operator, val);
|
|
|
|
if (!this.queryHiddenFields && (['salt', 'hash'].includes(path) || schemaType?.options?.hidden)) {
|
|
return undefined;
|
|
}
|
|
|
|
// If there are multiple collections to search through,
|
|
// Recursively build up a list of query constraints
|
|
if (collectionPaths.length > 1) {
|
|
// Remove top collection and reverse array
|
|
// to work backwards from top
|
|
const collectionPathsToSearch = collectionPaths.slice(1).reverse();
|
|
|
|
const initialRelationshipQuery = {
|
|
value: {},
|
|
} as SearchParam;
|
|
|
|
const relationshipQuery = await collectionPathsToSearch.reduce(async (priorQuery, { Model: SubModel, path: subPath }, i) => {
|
|
const priorQueryResult = await priorQuery;
|
|
|
|
// On the "deepest" collection,
|
|
// Search on the value passed through the query
|
|
if (i === 0) {
|
|
const subQuery = await SubModel.buildQuery({
|
|
where: {
|
|
[subPath]: {
|
|
[operator]: val,
|
|
},
|
|
},
|
|
}, this.locale);
|
|
|
|
const result = await SubModel.find(subQuery, subQueryOptions);
|
|
|
|
const $in = result.map((doc) => doc._id.toString());
|
|
|
|
if (collectionPathsToSearch.length === 1) return { path, value: { $in } };
|
|
|
|
const nextSubPath = collectionPathsToSearch[i + 1].path;
|
|
|
|
return {
|
|
value: { [nextSubPath]: { $in } },
|
|
};
|
|
}
|
|
|
|
const subQuery = priorQueryResult.value;
|
|
const result = await SubModel.find(subQuery, subQueryOptions);
|
|
|
|
const $in = result.map((doc) => doc._id.toString());
|
|
|
|
// If it is the last recursion
|
|
// then pass through the search param
|
|
if (i + 1 === collectionPathsToSearch.length) {
|
|
return { path, value: { $in } };
|
|
}
|
|
|
|
return {
|
|
value: {
|
|
_id: { $in },
|
|
},
|
|
};
|
|
}, Promise.resolve(initialRelationshipQuery));
|
|
|
|
return relationshipQuery;
|
|
}
|
|
|
|
if (operator && validOperators.includes(operator)) {
|
|
const operatorKey = operatorMap[operator];
|
|
|
|
let overrideQuery = false;
|
|
let query;
|
|
|
|
// If there is a ref, this is a relationship or upload field
|
|
// IDs can be either string, number, or ObjectID
|
|
// So we need to build an `or` query for all these types
|
|
if (schemaOptions && (schemaOptions.ref || schemaOptions.refPath)) {
|
|
overrideQuery = true;
|
|
|
|
query = {
|
|
$or: [
|
|
{
|
|
[path]: {
|
|
[operatorKey]: formattedValue,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
if (typeof formattedValue === 'number' || (typeof formattedValue === 'string' && mongoose.Types.ObjectId.isValid(formattedValue))) {
|
|
query.$or.push({
|
|
[path]: {
|
|
[operatorKey]: formattedValue.toString(),
|
|
},
|
|
});
|
|
}
|
|
|
|
if (typeof formattedValue === 'string') {
|
|
if (!Number.isNaN(formattedValue)) {
|
|
query.$or.push({
|
|
[path]: {
|
|
[operatorKey]: parseFloat(formattedValue),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// If forced query
|
|
if (overrideQuery) {
|
|
return {
|
|
value: query,
|
|
};
|
|
}
|
|
|
|
// Some operators like 'near' need to define a full query
|
|
// so if there is no operator key, just return the value
|
|
if (!operatorKey) {
|
|
return {
|
|
path,
|
|
value: formattedValue,
|
|
};
|
|
}
|
|
|
|
return {
|
|
path,
|
|
value: { [operatorKey]: formattedValue },
|
|
};
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|
|
// This plugin asynchronously builds a list of Mongoose query constraints
|
|
// which can then be used in subsequent Mongoose queries.
|
|
function buildQueryPlugin(schema) {
|
|
const modifiedSchema = schema;
|
|
async function buildQuery(rawParams, locale, queryHiddenFields = false) {
|
|
const paramParser = new ParamParser(this, rawParams, locale, queryHiddenFields);
|
|
const params = await paramParser.parse();
|
|
return params.searchParams;
|
|
}
|
|
modifiedSchema.statics.buildQuery = buildQuery;
|
|
}
|
|
export default buildQueryPlugin;
|