580 lines
17 KiB
TypeScript
580 lines
17 KiB
TypeScript
/* eslint-disable no-continue */
|
|
/* eslint-disable no-await-in-loop */
|
|
/* eslint-disable no-restricted-syntax */
|
|
import deepmerge from 'deepmerge';
|
|
import { FilterQuery } from 'mongoose';
|
|
import { combineMerge } from '../utilities/combineMerge';
|
|
import { operatorMap } from './operatorMap';
|
|
import { sanitizeQueryValue } from './sanitizeQueryValue';
|
|
import { PayloadRequest, Where } from '../types';
|
|
import { Field, FieldAffectingData, fieldAffectsData, TabAsField, UIField } from '../fields/config/types';
|
|
import { CollectionPermission, FieldPermissions, GlobalPermission } from '../auth';
|
|
import flattenFields from '../utilities/flattenTopLevelFields';
|
|
import { getEntityPolicies } from '../utilities/getEntityPolicies';
|
|
import { SanitizedConfig } from '../config/types';
|
|
import QueryError from '../errors/QueryError';
|
|
|
|
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 PathToQuery = {
|
|
complete: boolean
|
|
collectionSlug?: string
|
|
path: string
|
|
field: Field | TabAsField
|
|
fields?: (FieldAffectingData | UIField | TabAsField)[]
|
|
fieldPolicies?: {
|
|
[field: string]: FieldPermissions
|
|
}
|
|
}
|
|
|
|
type SearchParam = {
|
|
path?: string,
|
|
value: unknown,
|
|
}
|
|
|
|
type ParamParserArgs = {
|
|
req: PayloadRequest
|
|
collectionSlug?: string
|
|
globalSlug?: string
|
|
versionsFields?: Field[]
|
|
model: any
|
|
where: Where
|
|
overrideAccess?: boolean
|
|
}
|
|
|
|
export class ParamParser {
|
|
collectionSlug?: string
|
|
|
|
globalSlug?: string
|
|
|
|
overrideAccess: boolean
|
|
|
|
req: PayloadRequest
|
|
|
|
where: Where;
|
|
|
|
model: any;
|
|
|
|
fields: Field[]
|
|
|
|
localizationConfig: SanitizedConfig['localization']
|
|
|
|
policies: {
|
|
collections?: {
|
|
[collectionSlug: string]: CollectionPermission;
|
|
};
|
|
globals?: {
|
|
[globalSlug: string]: GlobalPermission;
|
|
};
|
|
}
|
|
|
|
errors: { path: string }[]
|
|
|
|
constructor({
|
|
req,
|
|
collectionSlug,
|
|
globalSlug,
|
|
versionsFields,
|
|
model,
|
|
where,
|
|
overrideAccess,
|
|
}: ParamParserArgs) {
|
|
this.req = req;
|
|
this.collectionSlug = collectionSlug;
|
|
this.globalSlug = globalSlug;
|
|
this.parse = this.parse.bind(this);
|
|
this.model = model;
|
|
this.where = where;
|
|
this.overrideAccess = overrideAccess;
|
|
this.localizationConfig = req.payload.config.localization;
|
|
this.policies = {
|
|
collections: {},
|
|
globals: {},
|
|
};
|
|
this.errors = [];
|
|
|
|
// Get entity fields
|
|
if (globalSlug) {
|
|
const globalConfig = req.payload.globals.config.find(({ slug }) => slug === globalSlug);
|
|
this.fields = versionsFields || globalConfig.fields;
|
|
}
|
|
|
|
if (collectionSlug) {
|
|
const collectionConfig = req.payload.collections[collectionSlug].config;
|
|
this.fields = versionsFields || collectionConfig.fields;
|
|
}
|
|
}
|
|
|
|
// Entry point to the ParamParser class
|
|
|
|
async parse(): Promise<Record<string, unknown>> {
|
|
if (typeof this.where === 'object') {
|
|
const query = await this.parsePathOrRelation(this.where);
|
|
return query;
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
async parsePathOrRelation(object: Where): Promise<Record<string, unknown>> {
|
|
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({
|
|
fields: this.fields,
|
|
incomingPath: relationOrPath,
|
|
val: 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;
|
|
}
|
|
|
|
// Convert the Payload key / value / operator into a MongoDB query
|
|
async buildSearchParam({
|
|
fields,
|
|
incomingPath,
|
|
val,
|
|
operator,
|
|
}: {
|
|
fields: Field[],
|
|
incomingPath: string,
|
|
val: unknown,
|
|
operator: string
|
|
}): Promise<SearchParam> {
|
|
// Replace GraphQL nested field double underscore formatting
|
|
let sanitizedPath = incomingPath.replace(/__/gi, '.');
|
|
if (sanitizedPath === 'id') sanitizedPath = '_id';
|
|
|
|
let paths: PathToQuery[] = [];
|
|
|
|
let hasCustomID = false;
|
|
|
|
if (sanitizedPath === '_id') {
|
|
const customIDfield = this.req.payload.collections[this.collectionSlug]?.config.fields.find((field) => fieldAffectsData(field) && field.name === 'id');
|
|
|
|
let idFieldType: 'text' | 'number' = 'text';
|
|
|
|
if (customIDfield) {
|
|
if (customIDfield?.type === 'text' || customIDfield?.type === 'number') {
|
|
idFieldType = customIDfield.type;
|
|
}
|
|
|
|
hasCustomID = true;
|
|
}
|
|
|
|
paths.push({
|
|
path: '_id',
|
|
field: {
|
|
name: 'id',
|
|
type: idFieldType,
|
|
},
|
|
complete: true,
|
|
collectionSlug: this.collectionSlug,
|
|
});
|
|
} else {
|
|
paths = await this.getLocalizedPaths({
|
|
collectionSlug: this.collectionSlug,
|
|
globalSlug: this.globalSlug,
|
|
fields,
|
|
incomingPath: sanitizedPath,
|
|
});
|
|
}
|
|
|
|
const [{ path, field }] = paths;
|
|
|
|
if (path) {
|
|
const formattedValue = sanitizeQueryValue({
|
|
ctx: this,
|
|
field,
|
|
path,
|
|
operator,
|
|
val,
|
|
hasCustomID,
|
|
});
|
|
|
|
// If there are multiple collections to search through,
|
|
// Recursively build up a list of query constraints
|
|
if (paths.length > 1) {
|
|
// Remove top collection and reverse array
|
|
// to work backwards from top
|
|
const pathsToQuery = paths.slice(1).reverse();
|
|
|
|
const initialRelationshipQuery = {
|
|
value: {},
|
|
} as SearchParam;
|
|
|
|
const relationshipQuery = await pathsToQuery.reduce(async (priorQuery, { path: subPath, collectionSlug }, i) => {
|
|
const priorQueryResult = await priorQuery;
|
|
|
|
const SubModel = this.req.payload.collections[collectionSlug].Model;
|
|
|
|
// On the "deepest" collection,
|
|
// Search on the value passed through the query
|
|
if (i === 0) {
|
|
const subQuery = await SubModel.buildQuery({
|
|
where: {
|
|
[subPath]: {
|
|
[operator]: val,
|
|
},
|
|
},
|
|
req: this.req,
|
|
overrideAccess: this.overrideAccess,
|
|
});
|
|
|
|
const result = await SubModel.find(subQuery, subQueryOptions);
|
|
|
|
const $in = result.map((doc) => doc._id.toString());
|
|
|
|
if (pathsToQuery.length === 1) return { path, value: { $in } };
|
|
|
|
const nextSubPath = pathsToQuery[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 === pathsToQuery.length) {
|
|
return { path, value: { $in } };
|
|
}
|
|
|
|
return {
|
|
value: {
|
|
_id: { $in },
|
|
},
|
|
};
|
|
}, Promise.resolve(initialRelationshipQuery));
|
|
|
|
return relationshipQuery;
|
|
}
|
|
|
|
if (operator && validOperators.includes(operator)) {
|
|
const operatorKey = operatorMap[operator];
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Build up an array of auto-localized paths to search on
|
|
// Multiple paths may be possible if searching on properties of relationship fields
|
|
|
|
async getLocalizedPaths({
|
|
collectionSlug,
|
|
globalSlug,
|
|
fields,
|
|
incomingPath,
|
|
}: {
|
|
collectionSlug?: string
|
|
globalSlug?: string
|
|
fields: Field[]
|
|
incomingPath: string
|
|
}): Promise<PathToQuery[]> {
|
|
const pathSegments = incomingPath.split('.');
|
|
|
|
let paths: PathToQuery[] = [
|
|
{
|
|
path: '',
|
|
complete: false,
|
|
field: undefined,
|
|
fields: flattenFields(fields, false),
|
|
fieldPolicies: undefined,
|
|
collectionSlug,
|
|
},
|
|
];
|
|
|
|
if (!this.overrideAccess) {
|
|
if (collectionSlug) {
|
|
const collection = { ...this.req.payload.collections[collectionSlug].config };
|
|
collection.fields = fields;
|
|
|
|
if (!this.policies.collections[collectionSlug]) {
|
|
const [policy, promises] = getEntityPolicies({
|
|
req: this.req,
|
|
entity: collection,
|
|
operations: ['read'],
|
|
type: 'collection',
|
|
});
|
|
|
|
await Promise.all(promises);
|
|
this.policies.collections[collectionSlug] = policy;
|
|
}
|
|
|
|
paths[0].fieldPolicies = this.policies.collections[collectionSlug].fields;
|
|
|
|
if (['salt', 'hash'].includes(incomingPath) && collection.auth && !collection.auth?.disableLocalStrategy) {
|
|
this.errors.push({ path: incomingPath });
|
|
return [];
|
|
}
|
|
}
|
|
|
|
if (globalSlug) {
|
|
if (!this.policies.globals[globalSlug]) {
|
|
const global = { ...this.req.payload.globals.config.find(({ slug }) => slug === globalSlug) };
|
|
global.fields = fields;
|
|
|
|
const [policy, promises] = getEntityPolicies({
|
|
req: this.req,
|
|
entity: global,
|
|
operations: ['read'],
|
|
type: 'global',
|
|
});
|
|
|
|
await Promise.all(promises);
|
|
this.policies.globals[globalSlug] = policy;
|
|
}
|
|
|
|
paths[0].fieldPolicies = this.policies.globals[globalSlug].fields;
|
|
}
|
|
}
|
|
|
|
// Use a 'some' so that we can bail out
|
|
// if a relationship query is found
|
|
// or if Rich Text / JSON
|
|
|
|
let done = false;
|
|
|
|
for (let i = 0; i < pathSegments.length; i += 1) {
|
|
if (done) continue;
|
|
|
|
const segment = pathSegments[i];
|
|
|
|
const lastIncompletePath = paths.find(({ complete }) => !complete);
|
|
|
|
if (lastIncompletePath) {
|
|
const { path } = lastIncompletePath;
|
|
let currentPath = path ? `${path}.${segment}` : segment;
|
|
|
|
const matchedField = lastIncompletePath.fields.find((field) => fieldAffectsData(field) && field.name === segment);
|
|
lastIncompletePath.field = matchedField;
|
|
|
|
if (currentPath === 'globalType' && this.globalSlug) {
|
|
lastIncompletePath.path = currentPath;
|
|
lastIncompletePath.complete = true;
|
|
lastIncompletePath.field = {
|
|
name: 'globalType',
|
|
type: 'text',
|
|
};
|
|
|
|
done = true;
|
|
continue;
|
|
}
|
|
|
|
if (matchedField) {
|
|
if (!this.overrideAccess) {
|
|
const fieldAccess = lastIncompletePath.fieldPolicies[matchedField.name].read.permission;
|
|
|
|
if (!fieldAccess || ('hidden' in matchedField && matchedField.hidden)) {
|
|
this.errors.push({ path: currentPath });
|
|
done = true;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const nextSegment = pathSegments[i + 1];
|
|
const nextSegmentIsLocale = this.localizationConfig && this.localizationConfig.locales.includes(nextSegment);
|
|
|
|
if (nextSegmentIsLocale) {
|
|
// Skip the next iteration, because it's a locale
|
|
i += 1;
|
|
currentPath = `${currentPath}.${nextSegment}`;
|
|
} else if (this.localizationConfig && 'localized' in matchedField && matchedField.localized) {
|
|
currentPath = `${currentPath}.${this.req.locale}`;
|
|
}
|
|
|
|
switch (matchedField.type) {
|
|
case 'blocks':
|
|
case 'richText':
|
|
case 'json': {
|
|
const upcomingSegments = pathSegments.slice(i + 1).join('.');
|
|
lastIncompletePath.complete = true;
|
|
lastIncompletePath.path = upcomingSegments ? `${currentPath}.${upcomingSegments}` : currentPath;
|
|
done = true;
|
|
continue;
|
|
}
|
|
|
|
case 'relationship':
|
|
case 'upload': {
|
|
// If this is a polymorphic relation,
|
|
// We only support querying directly (no nested querying)
|
|
if (typeof matchedField.relationTo !== 'string') {
|
|
const lastSegmentIsValid = ['value', 'relationTo'].includes(pathSegments[pathSegments.length - 1]);
|
|
|
|
if (lastSegmentIsValid) {
|
|
lastIncompletePath.complete = true;
|
|
lastIncompletePath.path = pathSegments.join('.');
|
|
} else {
|
|
this.errors.push({ path: currentPath });
|
|
done = true;
|
|
continue;
|
|
}
|
|
} else {
|
|
lastIncompletePath.complete = true;
|
|
lastIncompletePath.collectionSlug = matchedField.relationTo;
|
|
lastIncompletePath.path = currentPath;
|
|
|
|
const nestedPathToQuery = pathSegments.slice(nextSegmentIsLocale ? i + 2 : i + 1).join('.');
|
|
|
|
if (nestedPathToQuery) {
|
|
const relatedCollection = this.req.payload.collections[matchedField.relationTo as string].config;
|
|
|
|
const remainingPaths = await this.getLocalizedPaths({
|
|
collectionSlug: relatedCollection.slug,
|
|
fields: relatedCollection.fields,
|
|
incomingPath: nestedPathToQuery,
|
|
});
|
|
|
|
paths = [
|
|
...paths,
|
|
...remainingPaths,
|
|
];
|
|
}
|
|
|
|
done = true;
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
if ('fields' in lastIncompletePath.field) {
|
|
lastIncompletePath.fields = flattenFields(lastIncompletePath.field.fields, false);
|
|
}
|
|
|
|
if (!this.overrideAccess && 'fields' in lastIncompletePath.fieldPolicies[lastIncompletePath.field.name]) {
|
|
lastIncompletePath.fieldPolicies = lastIncompletePath.fieldPolicies[lastIncompletePath.field.name].fields;
|
|
}
|
|
|
|
if (i + 1 === pathSegments.length) lastIncompletePath.complete = true;
|
|
lastIncompletePath.path = currentPath;
|
|
continue;
|
|
}
|
|
}
|
|
} else {
|
|
this.errors.push({ path: currentPath });
|
|
done = true;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
return paths;
|
|
}
|
|
}
|
|
|
|
type GetBuildQueryPluginArgs = {
|
|
collectionSlug?: string
|
|
versionsFields?: Field[]
|
|
}
|
|
|
|
export type BuildQueryArgs = {
|
|
req: PayloadRequest
|
|
where: Where
|
|
overrideAccess: boolean
|
|
globalSlug?: string
|
|
}
|
|
|
|
// This plugin asynchronously builds a list of Mongoose query constraints
|
|
// which can then be used in subsequent Mongoose queries.
|
|
const getBuildQueryPlugin = ({
|
|
collectionSlug,
|
|
versionsFields,
|
|
}: GetBuildQueryPluginArgs = {}) => {
|
|
return function buildQueryPlugin(schema) {
|
|
const modifiedSchema = schema;
|
|
async function buildQuery({ req, where, overrideAccess = false, globalSlug }: BuildQueryArgs): Promise<Record<string, unknown>> {
|
|
const paramParser = new ParamParser({
|
|
req,
|
|
collectionSlug,
|
|
globalSlug,
|
|
versionsFields,
|
|
model: this,
|
|
where,
|
|
overrideAccess,
|
|
});
|
|
const result = await paramParser.parse();
|
|
|
|
if (paramParser.errors.length > 0) {
|
|
throw new QueryError(paramParser.errors);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
modifiedSchema.statics.buildQuery = buildQuery;
|
|
};
|
|
};
|
|
|
|
export default getBuildQueryPlugin;
|