feat: refactors buildQuery to rely on fields instead of mongoose

This commit is contained in:
James
2023-04-17 16:08:44 -04:00
parent 2d0441a72e
commit d187b809d7
14 changed files with 487 additions and 256 deletions

View File

@@ -78,7 +78,7 @@ export default function initCollectionsLocal(ctx: Payload): void {
); );
versionSchema.plugin(paginate, { useEstimatedCount: true }) versionSchema.plugin(paginate, { useEstimatedCount: true })
.plugin(getBuildQueryPlugin({ collectionSlug: collection.slug, isVersionsModel: true })); .plugin(getBuildQueryPlugin({ collectionSlug: collection.slug, versionsFields: versionCollectionFields }));
if (collection.versions?.drafts) { if (collection.versions?.drafts) {
versionSchema.plugin(mongooseAggregatePaginate); versionSchema.plugin(mongooseAggregatePaginate);

View File

@@ -8,7 +8,7 @@ const buildModel = (config: SanitizedConfig): GlobalModel | null => {
if (config.globals && config.globals.length > 0) { if (config.globals && config.globals.length > 0) {
const globalsSchema = new mongoose.Schema({}, { discriminatorKey: 'globalType', timestamps: true, minimize: false }); const globalsSchema = new mongoose.Schema({}, { discriminatorKey: 'globalType', timestamps: true, minimize: false });
globalsSchema.plugin(getBuildQueryPlugin({ isGlobalModel: true })); globalsSchema.plugin(getBuildQueryPlugin());
const Globals = mongoose.model('globals', globalsSchema) as unknown as GlobalModel; const Globals = mongoose.model('globals', globalsSchema) as unknown as GlobalModel;

View File

@@ -35,7 +35,7 @@ export default function initGlobalsLocal(ctx: Payload): void {
); );
versionSchema.plugin(paginate, { useEstimatedCount: true }) versionSchema.plugin(paginate, { useEstimatedCount: true })
.plugin(getBuildQueryPlugin({ globalSlug: global.slug, isVersionsModel: true })); .plugin(getBuildQueryPlugin({ versionsFields: versionGlobalFields }));
ctx.versions[global.slug] = mongoose.model(versionModelName, versionSchema) as CollectionModel; ctx.versions[global.slug] = mongoose.model(versionModelName, versionSchema) as CollectionModel;
} }

View File

@@ -64,6 +64,7 @@ async function findOne<T extends Record<string, unknown>>(args: Args): Promise<T
where: queryToBuild, where: queryToBuild,
req, req,
overrideAccess, overrideAccess,
globalSlug: slug,
}); });
// ///////////////////////////////////// // /////////////////////////////////////

View File

@@ -29,7 +29,6 @@ async function findVersionByID<T extends TypeWithVersion<T> = any>(args: Argumen
req: { req: {
t, t,
payload, payload,
locale,
}, },
disableErrors, disableErrors,
currentDepth, currentDepth,
@@ -68,6 +67,7 @@ async function findVersionByID<T extends TypeWithVersion<T> = any>(args: Argumen
where: queryToBuild, where: queryToBuild,
req, req,
overrideAccess, overrideAccess,
globalSlug: globalConfig.slug,
}); });
// ///////////////////////////////////// // /////////////////////////////////////

View File

@@ -88,6 +88,7 @@ async function findVersions<T extends TypeWithVersion<T>>(
where: queryToBuild, where: queryToBuild,
req, req,
overrideAccess, overrideAccess,
globalSlug: globalConfig.slug,
}); });
// ///////////////////////////////////// // /////////////////////////////////////

View File

@@ -33,7 +33,6 @@ async function update<TSlug extends keyof GeneratedTypes['globals']>(
slug, slug,
req, req,
req: { req: {
locale,
payload, payload,
payload: { payload: {
globals: { globals: {
@@ -80,6 +79,7 @@ async function update<TSlug extends keyof GeneratedTypes['globals']>(
where: queryToBuild, where: queryToBuild,
req, req,
overrideAccess, overrideAccess,
globalSlug: slug,
}); });
// ///////////////////////////////////// // /////////////////////////////////////

View File

@@ -1,13 +1,17 @@
/* eslint-disable no-continue */
/* eslint-disable no-await-in-loop */ /* eslint-disable no-await-in-loop */
/* eslint-disable no-restricted-syntax */ /* eslint-disable no-restricted-syntax */
import deepmerge from 'deepmerge'; import deepmerge from 'deepmerge';
import mongoose, { FilterQuery } from 'mongoose'; import { FilterQuery } from 'mongoose';
import { combineMerge } from '../utilities/combineMerge'; import { combineMerge } from '../utilities/combineMerge';
import { CollectionModel } from '../collections/config/types';
import { getSchemaTypeOptions } from './getSchemaTypeOptions';
import { operatorMap } from './operatorMap'; import { operatorMap } from './operatorMap';
import { sanitizeQueryValue } from './sanitizeFormattedValue'; import { sanitizeQueryValue } from './sanitizeQueryValue';
import { PayloadRequest, Where } from '../types'; import { PayloadRequest, Where } from '../types';
import { Field, FieldAffectingData, TabAsField, UIField, fieldAffectsData } 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';
const validOperators = ['like', 'contains', 'in', 'all', 'not_in', 'greater_than_equal', 'greater_than', 'less_than_equal', 'less_than', 'not_equals', 'equals', 'exists', 'near']; const validOperators = ['like', 'contains', 'in', 'all', 'not_in', 'greater_than_equal', 'greater_than', 'less_than_equal', 'less_than', 'not_equals', 'equals', 'exists', 'near'];
@@ -16,18 +20,15 @@ const subQueryOptions = {
lean: true, lean: true,
}; };
type ParseType = {
searchParams?:
{
[key: string]: any;
};
sort?: boolean;
};
type PathToQuery = { type PathToQuery = {
complete: boolean complete: boolean
collectionSlug?: string
path: string path: string
Model: CollectionModel field: Field | TabAsField
fields?: (FieldAffectingData | UIField | TabAsField)[]
fieldPolicies?: {
[field: string]: FieldPermissions
}
} }
type SearchParam = { type SearchParam = {
@@ -35,15 +36,23 @@ type SearchParam = {
value: unknown, value: unknown,
} }
class ParamParser { type ParamParserArgs = {
req: PayloadRequest
collectionSlug?: string
globalSlug?: string
versionsFields?: Field[]
model: any
where: Where
overrideAccess?: boolean
}
type QueryError = { path: string }
export class ParamParser {
collectionSlug?: string collectionSlug?: string
globalSlug?: string globalSlug?: string
isGlobalModel?: boolean
isVersionsModel?: boolean
overrideAccess: boolean overrideAccess: boolean
req: PayloadRequest req: PayloadRequest
@@ -52,49 +61,68 @@ class ParamParser {
model: any; model: any;
query: { fields: Field[]
searchParams: {
[key: string]: any; localizationConfig: SanitizedConfig['localization']
policies: {
collections?: {
[collectionSlug: string]: CollectionPermission;
}; };
sort: boolean; globals?: {
}; [globalSlug: string]: GlobalPermission;
};
}
errors: QueryError[]
constructor({ constructor({
req, req,
collectionSlug, collectionSlug,
globalSlug, globalSlug,
isGlobalModel, versionsFields,
isVersionsModel,
model, model,
where, where,
overrideAccess, overrideAccess,
}) { }: ParamParserArgs) {
this.req = req; this.req = req;
this.collectionSlug = collectionSlug; this.collectionSlug = collectionSlug;
this.globalSlug = globalSlug; this.globalSlug = globalSlug;
this.isGlobalModel = isGlobalModel;
this.isVersionsModel = isVersionsModel;
this.parse = this.parse.bind(this); this.parse = this.parse.bind(this);
this.model = model; this.model = model;
this.where = where; this.where = where;
this.overrideAccess = overrideAccess; this.overrideAccess = overrideAccess;
this.query = { this.localizationConfig = req.payload.config.localization;
searchParams: {}, this.policies = {
sort: false, 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 // Entry point to the ParamParser class
async parse(): Promise<ParseType> { async parse(): Promise<Record<string, unknown>> {
if (typeof this.where === 'object') { if (typeof this.where === 'object') {
this.query.searchParams = await this.parsePathOrRelation(this.where); const query = await this.parsePathOrRelation(this.where);
return this.query; return query;
} }
return {}; return {};
} }
async parsePathOrRelation(object) { async parsePathOrRelation(object: Where): Promise<Record<string, unknown>> {
let result = {} as FilterQuery<any>; let result = {} as FilterQuery<any>;
// We need to determine if the whereKey is an AND, OR, or a schema path // We need to determine if the whereKey is an AND, OR, or a schema path
for (const relationOrPath of Object.keys(object)) { for (const relationOrPath of Object.keys(object)) {
@@ -114,7 +142,12 @@ class ParamParser {
if (typeof pathOperators === 'object') { if (typeof pathOperators === 'object') {
for (const operator of Object.keys(pathOperators)) { for (const operator of Object.keys(pathOperators)) {
if (validOperators.includes(operator)) { if (validOperators.includes(operator)) {
const searchParam = await this.buildSearchParam(this.model.schema, relationOrPath, pathOperators[operator], operator); const searchParam = await this.buildSearchParam({
fields: this.fields,
incomingPath: relationOrPath,
val: pathOperators[operator],
operator,
});
if (searchParam?.value && searchParam?.path) { if (searchParam?.value && searchParam?.path) {
result = { result = {
@@ -148,123 +181,85 @@ class ParamParser {
return completedConditions; 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.req.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 // Convert the Payload key / value / operator into a MongoDB query
async buildSearchParam(schema, incomingPath, val, operator): Promise<SearchParam> { async buildSearchParam({
fields,
incomingPath,
val,
operator,
}: {
fields: Field[],
incomingPath: string,
val: unknown,
operator: string
}): Promise<SearchParam> {
// Replace GraphQL nested field double underscore formatting // Replace GraphQL nested field double underscore formatting
let sanitizedPath = incomingPath.replace(/__/gi, '.'); let sanitizedPath = incomingPath.replace(/__/gi, '.');
if (sanitizedPath === 'id') sanitizedPath = '_id'; if (sanitizedPath === 'id') sanitizedPath = '_id';
const collectionPaths = this.getLocalizedPaths(this.model, sanitizedPath, operator); let paths: PathToQuery[] = [];
const [{ path }] = collectionPaths;
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) { if (path) {
const schemaType = schema.path(path); const formattedValue = sanitizeQueryValue({
const schemaOptions = getSchemaTypeOptions(schemaType); ctx: this,
const formattedValue = sanitizeQueryValue(schemaType, path, operator, val); field,
path,
if (!this.overrideAccess && (['salt', 'hash'].includes(path) || schemaType?.options?.hidden)) { operator,
return undefined; val,
} hasCustomID,
});
// If there are multiple collections to search through, // If there are multiple collections to search through,
// Recursively build up a list of query constraints // Recursively build up a list of query constraints
if (collectionPaths.length > 1) { if (paths.length > 1) {
// Remove top collection and reverse array // Remove top collection and reverse array
// to work backwards from top // to work backwards from top
const collectionPathsToSearch = collectionPaths.slice(1).reverse(); const pathsToQuery = paths.slice(1).reverse();
const initialRelationshipQuery = { const initialRelationshipQuery = {
value: {}, value: {},
} as SearchParam; } as SearchParam;
const relationshipQuery = await collectionPathsToSearch.reduce(async (priorQuery, { Model: SubModel, path: subPath }, i) => { const relationshipQuery = await pathsToQuery.reduce(async (priorQuery, { path: subPath, collectionSlug }, i) => {
const priorQueryResult = await priorQuery; const priorQueryResult = await priorQuery;
const SubModel = this.req.payload.collections[collectionSlug].Model;
// On the "deepest" collection, // On the "deepest" collection,
// Search on the value passed through the query // Search on the value passed through the query
if (i === 0) { if (i === 0) {
@@ -282,9 +277,9 @@ class ParamParser {
const $in = result.map((doc) => doc._id.toString()); const $in = result.map((doc) => doc._id.toString());
if (collectionPathsToSearch.length === 1) return { path, value: { $in } }; if (pathsToQuery.length === 1) return { path, value: { $in } };
const nextSubPath = collectionPathsToSearch[i + 1].path; const nextSubPath = pathsToQuery[i + 1].path;
return { return {
value: { [nextSubPath]: { $in } }, value: { [nextSubPath]: { $in } },
@@ -298,7 +293,7 @@ class ParamParser {
// If it is the last recursion // If it is the last recursion
// then pass through the search param // then pass through the search param
if (i + 1 === collectionPathsToSearch.length) { if (i + 1 === pathsToQuery.length) {
return { path, value: { $in } }; return { path, value: { $in } };
} }
@@ -315,51 +310,6 @@ class ParamParser {
if (operator && validOperators.includes(operator)) { if (operator && validOperators.includes(operator)) {
const operatorKey = operatorMap[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 // Some operators like 'near' need to define a full query
// so if there is no operator key, just return the value // so if there is no operator key, just return the value
if (!operatorKey) { if (!operatorKey) {
@@ -377,44 +327,247 @@ class ParamParser {
} }
return undefined; 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 ('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 = { type GetBuildQueryPluginArgs = {
collectionSlug?: string collectionSlug?: string
globalSlug?: string versionsFields?: Field[]
isGlobalModel?: boolean
isVersionsModel?: boolean
} }
export type BuildQueryArgs = { export type BuildQueryArgs = {
req: PayloadRequest req: PayloadRequest
where: Where where: Where
overrideAccess: boolean overrideAccess: boolean
globalSlug?: string
} }
// This plugin asynchronously builds a list of Mongoose query constraints // This plugin asynchronously builds a list of Mongoose query constraints
// which can then be used in subsequent Mongoose queries. // which can then be used in subsequent Mongoose queries.
const getBuildQueryPlugin = ({ const getBuildQueryPlugin = ({
collectionSlug, collectionSlug,
globalSlug, versionsFields,
isGlobalModel, }: GetBuildQueryPluginArgs = {}) => {
isVersionsModel,
}: GetBuildQueryPluginArgs) => {
return function buildQueryPlugin(schema) { return function buildQueryPlugin(schema) {
const modifiedSchema = schema; const modifiedSchema = schema;
async function buildQuery({ req, where, overrideAccess = false }: BuildQueryArgs) { async function buildQuery({ req, where, overrideAccess = false, globalSlug }: BuildQueryArgs): Promise<Record<string, unknown>> {
const paramParser = new ParamParser({ const paramParser = new ParamParser({
req, req,
collectionSlug, collectionSlug,
globalSlug, globalSlug,
isGlobalModel, versionsFields,
isVersionsModel,
model: this, model: this,
where, where,
overrideAccess, overrideAccess,
}); });
const params = await paramParser.parse(); const result = await paramParser.parse();
return params.searchParams; // TODO: throw errors here
return result;
} }
modifiedSchema.statics.buildQuery = buildQuery; modifiedSchema.statics.buildQuery = buildQuery;
}; };

View File

@@ -1,27 +1,40 @@
import mongoose, { SchemaType } from 'mongoose'; import mongoose from 'mongoose';
import { createArrayFromCommaDelineated } from './createArrayFromCommaDelineated'; import { createArrayFromCommaDelineated } from './createArrayFromCommaDelineated';
import { getSchemaTypeOptions } from './getSchemaTypeOptions';
import wordBoundariesRegex from '../utilities/wordBoundariesRegex'; import wordBoundariesRegex from '../utilities/wordBoundariesRegex';
import { Field, TabAsField } from '../fields/config/types';
import { ParamParser } from './buildQuery';
export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operator: string, val: any): unknown => { type SanitizeQueryValueArgs = {
ctx: ParamParser,
field: Field | TabAsField,
path: string,
operator: string,
val: any
hasCustomID: boolean
}
export const sanitizeQueryValue = ({ ctx, field, path, operator, val, hasCustomID }: SanitizeQueryValueArgs): unknown => {
let formattedValue = val; let formattedValue = val;
const schemaOptions = getSchemaTypeOptions(schemaType);
// Disregard invalid _ids // Disregard invalid _ids
if (path === '_id' && typeof val === 'string' && val.split(',').length === 1) { if (path === '_id' && typeof val === 'string' && val.split(',').length === 1) {
if (schemaType?.instance === 'ObjectID') { if (!hasCustomID) {
const isValid = mongoose.Types.ObjectId.isValid(val); const isValid = mongoose.Types.ObjectId.isValid(val);
formattedValue = new mongoose.Types.ObjectId(val);
if (!isValid) { if (!isValid) {
ctx.errors.push({ path });
return undefined; return undefined;
} }
} }
if (schemaType?.instance === 'Number') { if (field.type === 'number') {
const parsedNumber = parseFloat(val); const parsedNumber = parseFloat(val);
if (Number.isNaN(parsedNumber)) { if (Number.isNaN(parsedNumber)) {
ctx.errors.push({ path });
return undefined; return undefined;
} }
} }
@@ -29,17 +42,34 @@ export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operato
// Cast incoming values as proper searchable types // Cast incoming values as proper searchable types
if (schemaType?.instance === 'Boolean' && typeof val === 'string') { if (field.type === 'checkbox' && typeof val === 'string') {
if (val.toLowerCase() === 'true') formattedValue = true; if (val.toLowerCase() === 'true') formattedValue = true;
if (val.toLowerCase() === 'false') formattedValue = false; if (val.toLowerCase() === 'false') formattedValue = false;
} }
if (schemaType?.instance === 'Number' && typeof val === 'string') { if (field.type === 'number' && typeof val === 'string') {
formattedValue = Number(val); formattedValue = Number(val);
} }
if ((schemaOptions?.ref || schemaOptions?.refPath) && val === 'null') { if (['relationship', 'upload'].includes(field.type) && val === 'null') {
formattedValue = null; if (val === 'null') {
formattedValue = null;
}
if (operator === 'in' && Array.isArray(formattedValue)) {
formattedValue = formattedValue.reduce((formattedValues, inVal) => {
const newValues = [inVal];
if (mongoose.Types.ObjectId.isValid(inVal)) newValues.push(new mongoose.Types.ObjectId(inVal));
const parsedNumber = parseFloat(inVal);
if (!Number.isNaN(parsedNumber)) newValues.push(parsedNumber);
return [
...formattedValues,
...newValues,
];
}, []);
}
} }
// Set up specific formatting necessary by operators // Set up specific formatting necessary by operators
@@ -74,23 +104,6 @@ export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operato
formattedValue = createArrayFromCommaDelineated(formattedValue); formattedValue = createArrayFromCommaDelineated(formattedValue);
} }
if (schemaOptions && (schemaOptions.ref || schemaOptions.refPath) && operator === 'in') {
if (Array.isArray(formattedValue)) {
formattedValue = formattedValue.reduce((formattedValues, inVal) => {
const newValues = [inVal];
if (mongoose.Types.ObjectId.isValid(inVal)) newValues.push(new mongoose.Types.ObjectId(inVal));
const parsedNumber = parseFloat(inVal);
if (!Number.isNaN(parsedNumber)) newValues.push(parsedNumber);
return [
...formattedValues,
...newValues,
];
}, []);
}
}
if (path !== '_id') { if (path !== '_id') {
if (operator === 'contains') { if (operator === 'contains') {
formattedValue = { $regex: formattedValue, $options: 'i' }; formattedValue = { $regex: formattedValue, $options: 'i' };

View File

@@ -1,23 +1,20 @@
import { Access } from '../config/types'; import { Access } from '../config/types';
import { AllOperations, Where, Document } from '../types'; import { AllOperations, Document, Where } from '../types';
import { FieldAccess, tabHasName } from '../fields/config/types'; import { FieldAccess, tabHasName } from '../fields/config/types';
import type { CollectionConfig } from '../collections/config/types'; import type { SanitizedCollectionConfig } from '../collections/config/types';
import type { GlobalConfig } from '../globals/config/types'; import { TypeWithID } from '../collections/config/types';
import type { SanitizedGlobalConfig } from '../globals/config/types';
import type { PayloadRequest } from '../express/types'; import type { PayloadRequest } from '../express/types';
import type { CollectionPermission, GlobalPermission } from '../auth/types'; import type { CollectionPermission, GlobalPermission } from '../auth/types';
import { TypeWithID } from '../collections/config/types';
type Args = ({ type Args = {
req: PayloadRequest req: PayloadRequest
operations: AllOperations[] operations: AllOperations[]
id?: string id?: string
} & ({ type: 'collection' | 'global'
type: 'collection' entity: SanitizedCollectionConfig | SanitizedGlobalConfig
entity: CollectionConfig }
} | {
type: 'global'
entity: GlobalConfig
}))
type ReturnType<T extends Args> = T['type'] extends 'global' ? [GlobalPermission, Promise<void>[]] : [CollectionPermission, Promise<void>[]] type ReturnType<T extends Args> = T['type'] extends 'global' ? [GlobalPermission, Promise<void>[]] : [CollectionPermission, Promise<void>[]]
type CreateAccessPromise = (args: { type CreateAccessPromise = (args: {
@@ -111,14 +108,15 @@ export function getEntityPolicies<T extends Args>(args: T): ReturnType<T> {
} }
}; };
const executeFieldPolicies = ({ const executeFieldPolicies = async ({
policiesObj = {}, policiesObj,
fields, fields,
operation, operation,
entityAccessPromise,
}) => { }) => {
const mutablePolicies = policiesObj; const mutablePolicies = policiesObj.fields;
fields.forEach((field) => { fields.forEach(async (field) => {
if (field.name) { if (field.name) {
if (!mutablePolicies[field.name]) mutablePolicies[field.name] = {}; if (!mutablePolicies[field.name]) mutablePolicies[field.name] = {};
@@ -131,17 +129,19 @@ export function getEntityPolicies<T extends Args>(args: T): ReturnType<T> {
accessLevel: 'field', accessLevel: 'field',
})); }));
} else { } else {
if (entityAccessPromise) await entityAccessPromise;
mutablePolicies[field.name][operation] = { mutablePolicies[field.name][operation] = {
permission: isLoggedIn, permission: policiesObj[operation]?.permission,
}; };
} }
if (field.fields) { if (field.fields) {
if (!mutablePolicies[field.name].fields) mutablePolicies[field.name].fields = {}; if (!mutablePolicies[field.name].fields) mutablePolicies[field.name].fields = {};
executeFieldPolicies({ executeFieldPolicies({
policiesObj: mutablePolicies[field.name].fields, policiesObj: mutablePolicies[field.name],
fields: field.fields, fields: field.fields,
operation, operation,
entityAccessPromise,
}); });
} }
} else if (field.fields) { } else if (field.fields) {
@@ -149,6 +149,7 @@ export function getEntityPolicies<T extends Args>(args: T): ReturnType<T> {
policiesObj: mutablePolicies, policiesObj: mutablePolicies,
fields: field.fields, fields: field.fields,
operation, operation,
entityAccessPromise,
}); });
} else if (field.type === 'tabs') { } else if (field.type === 'tabs') {
field.tabs.forEach((tab) => { field.tabs.forEach((tab) => {
@@ -158,12 +159,14 @@ export function getEntityPolicies<T extends Args>(args: T): ReturnType<T> {
policiesObj: mutablePolicies[tab.name].fields, policiesObj: mutablePolicies[tab.name].fields,
fields: tab.fields, fields: tab.fields,
operation, operation,
entityAccessPromise,
}); });
} else { } else {
executeFieldPolicies({ executeFieldPolicies({
policiesObj: mutablePolicies, policiesObj: mutablePolicies,
fields: tab.fields, fields: tab.fields,
operation, operation,
entityAccessPromise,
}); });
} }
}); });
@@ -172,24 +175,28 @@ export function getEntityPolicies<T extends Args>(args: T): ReturnType<T> {
}; };
operations.forEach((operation) => { operations.forEach((operation) => {
executeFieldPolicies({ let entityAccessPromise: Promise<void>;
policiesObj: policies.fields,
fields: entity.fields,
operation,
});
if (typeof entity.access[operation] === 'function') { if (typeof entity.access[operation] === 'function') {
promises.push(createAccessPromise({ entityAccessPromise = createAccessPromise({
policiesObj: policies, policiesObj: policies,
access: entity.access[operation], access: entity.access[operation],
operation, operation,
accessLevel: 'entity', accessLevel: 'entity',
})); });
promises.push(entityAccessPromise);
} else { } else {
policies[operation] = { policies[operation] = {
permission: isLoggedIn, permission: isLoggedIn,
}; };
} }
executeFieldPolicies({
policiesObj: policies,
fields: entity.fields,
operation,
entityAccessPromise,
});
}); });
return [policies, promises] as ReturnType<T>; return [policies, promises] as ReturnType<T>;

View File

@@ -63,6 +63,7 @@ const replaceWithDraftIfAvailable = async <T extends TypeWithID>({
where: queryToBuild, where: queryToBuild,
req, req,
overrideAccess, overrideAccess,
globalSlug: entityType === 'global' ? entity.slug : undefined,
}); });
let draft = await VersionModel.findOne(query, {}, { let draft = await VersionModel.findOne(query, {}, {

View File

@@ -4,6 +4,8 @@ import { buildConfig } from '../buildConfig';
export const slug = 'global'; export const slug = 'global';
export const arraySlug = 'array'; export const arraySlug = 'array';
export const accessControlSlug = 'access-control';
export const englishLocale = 'en'; export const englishLocale = 'en';
export const spanishLocale = 'es'; export const spanishLocale = 'es';
@@ -51,6 +53,33 @@ export default buildConfig({
}, },
], ],
}, },
{
slug: accessControlSlug,
access: {
read: ({ req: { user } }) => {
if (user) {
return true;
}
return {
enabled: {
equals: true,
},
};
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'enabled',
type: 'checkbox',
},
],
},
], ],
onInit: async (payload) => { onInit: async (payload) => {
await payload.create({ await payload.create({
@@ -60,5 +89,12 @@ export default buildConfig({
password: devUser.password, password: devUser.password,
}, },
}); });
await payload.updateGlobal({
slug: accessControlSlug,
data: {
title: 'hello',
},
});
}, },
}); });

View File

@@ -1,6 +1,6 @@
import { GraphQLClient } from 'graphql-request'; import { GraphQLClient } from 'graphql-request';
import { initPayloadTest } from '../helpers/configHelpers'; import { initPayloadTest } from '../helpers/configHelpers';
import configPromise, { arraySlug, englishLocale, slug, spanishLocale } from './config'; import configPromise, { accessControlSlug, arraySlug, englishLocale, slug, spanishLocale } from './config';
import payload from '../../src'; import payload from '../../src';
import { RESTClient } from '../helpers/rest'; import { RESTClient } from '../helpers/rest';
@@ -144,6 +144,29 @@ describe('globals', () => {
expect(en).toMatchObject(localized.en); expect(en).toMatchObject(localized.en);
expect(es).toMatchObject(localized.es); expect(es).toMatchObject(localized.es);
}); });
it('should respect valid access query constraint', async () => {
const emptyGlobal = await payload.findGlobal({
slug: accessControlSlug,
overrideAccess: false,
});
expect(Object.keys(emptyGlobal)).toHaveLength(0);
await payload.updateGlobal({
slug: accessControlSlug,
data: {
enabled: true,
},
});
const hasAccess = await payload.findGlobal({
slug: accessControlSlug,
overrideAccess: false,
});
expect(hasAccess.title).toBeDefined();
});
}); });
describe('graphql', () => { describe('graphql', () => {

View File

@@ -5,11 +5,7 @@ import payload from '../../src';
import type { import type {
LocalizedPost, LocalizedPost,
WithLocalizedRelationship, WithLocalizedRelationship,
LocalizedRequired,
RelationshipLocalized,
GlobalArray,
} from './payload-types'; } from './payload-types';
import type { LocalizedPostAllLocale } from './config';
import configPromise, { relationshipLocalizedSlug, slug, withLocalizedRelSlug, withRequiredLocalizedFields } from './config'; import configPromise, { relationshipLocalizedSlug, slug, withLocalizedRelSlug, withRequiredLocalizedFields } from './config';
import { import {
defaultLocale, defaultLocale,