feat: optimizes field operations

* wip: beforeChange field op pattern

* feat: optimizes field-level beforeChange

* feat: optimizes beforeValidate

* feat: optimizes afterChange

* feat: optimizes afterRead

* chore: comment accuracy
This commit is contained in:
James Mikrut
2022-05-02 12:46:52 -04:00
committed by GitHub
parent 69d328d15e
commit 18489faceb
44 changed files with 1577 additions and 1110 deletions

View File

@@ -1,73 +0,0 @@
import { Payload } from '..';
import { HookName, FieldAffectingData } from './config/types';
import relationshipPopulationPromise from './relationshipPopulationPromise';
import { Operation } from '../types';
import { PayloadRequest } from '../express/types';
type Arguments = {
data: Record<string, unknown>
fullData: Record<string, unknown>
originalDoc: Record<string, unknown>
field: FieldAffectingData
operation: Operation
overrideAccess: boolean
req: PayloadRequest
id: string | number
relationshipPopulations: (() => Promise<void>)[]
depth: number
currentDepth: number
hook: HookName
payload: Payload
showHiddenFields: boolean
}
const accessPromise = async ({
data,
fullData,
field,
operation,
overrideAccess,
req,
id,
relationshipPopulations,
depth,
currentDepth,
hook,
payload,
showHiddenFields,
originalDoc,
}: Arguments): Promise<void> => {
const resultingData = data;
let accessOperation;
if (hook === 'afterRead') {
accessOperation = 'read';
} else if (hook === 'beforeValidate') {
if (operation === 'update') accessOperation = 'update';
if (operation === 'create') accessOperation = 'create';
}
if (field.access && field.access[accessOperation]) {
const result = overrideAccess ? true : await field.access[accessOperation]({ req, id, siblingData: data, data: fullData, doc: originalDoc });
if (!result) {
delete resultingData[field.name];
}
}
if ((field.type === 'relationship' || field.type === 'upload') && hook === 'afterRead') {
relationshipPopulations.push(relationshipPopulationPromise({
showHiddenFields,
data,
field,
depth,
currentDepth,
req,
overrideAccess,
payload,
}));
}
};
export default accessPromise;

View File

@@ -22,7 +22,7 @@ export type FieldHook<T extends TypeWithID = any, P = any, S = any> = (args: Fie
export type FieldAccess<T extends TypeWithID = any, P = any> = (args: {
req: PayloadRequest
id?: string
id?: string | number
data?: Partial<T>
siblingData?: Partial<P>
doc?: T
@@ -228,6 +228,10 @@ export type ValueWithRelation = {
value: string | number
}
export function valueIsValueWithRelation(value: unknown): value is ValueWithRelation {
return typeof value === 'object' && 'relationTo' in value && 'value' in value;
}
export type RelationshipValue = (string | number)
| (string | number)[]
| ValueWithRelation

View File

@@ -1,94 +0,0 @@
import { PayloadRequest } from '../express/types';
import { Operation } from '../types';
import { HookName, FieldAffectingData, FieldHook } from './config/types';
type Arguments = {
data: Record<string, unknown>
field: FieldAffectingData
hook: HookName
req: PayloadRequest
operation: Operation
fullOriginalDoc: Record<string, unknown>
fullData: Record<string, unknown>
flattenLocales: boolean
isVersion: boolean
}
type ExecuteHookArguments = {
currentHook: FieldHook
value: unknown
} & Arguments;
const executeHook = async ({
currentHook,
fullOriginalDoc,
fullData,
data,
operation,
req,
value,
}: ExecuteHookArguments) => {
let hookedValue = await currentHook({
value,
originalDoc: fullOriginalDoc,
data: fullData,
siblingData: data,
operation,
req,
});
if (typeof hookedValue === 'undefined') {
hookedValue = value;
}
return hookedValue;
};
const hookPromise = async (args: Arguments): Promise<void> => {
const {
field,
hook,
req,
flattenLocales,
data,
} = args;
if (field.hooks && field.hooks[hook]) {
await field.hooks[hook].reduce(async (priorHook, currentHook) => {
await priorHook;
const shouldRunHookOnAllLocales = hook === 'afterRead'
&& field.localized
&& (req.locale === 'all' || !flattenLocales)
&& typeof data[field.name] === 'object';
if (shouldRunHookOnAllLocales) {
const hookPromises = Object.entries(data[field.name]).map(([locale, value]) => (async () => {
const hookedValue = await executeHook({
...args,
currentHook,
value,
});
if (hookedValue !== undefined) {
data[field.name][locale] = hookedValue;
}
})());
await Promise.all(hookPromises);
} else {
const hookedValue = await executeHook({
...args,
value: data[field.name],
currentHook,
});
if (hookedValue !== undefined) {
data[field.name] = hookedValue;
}
}
}, Promise.resolve());
}
};
export default hookPromise;

View File

@@ -0,0 +1,40 @@
import { SanitizedCollectionConfig } from '../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../globals/config/types';
import { PayloadRequest } from '../../../express/types';
import { traverseFields } from './traverseFields';
import deepCopyObject from '../../../utilities/deepCopyObject';
type Args = {
data: Record<string, unknown>
doc: Record<string, unknown>
entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig
operation: 'create' | 'update'
req: PayloadRequest
}
export const afterChange = async ({
data,
doc: incomingDoc,
entityConfig,
operation,
req,
}: Args): Promise<Record<string, unknown>> => {
const promises = [];
const doc = deepCopyObject(incomingDoc);
traverseFields({
data,
doc,
fields: entityConfig.fields,
operation,
promises,
req,
siblingDoc: doc,
siblingData: data,
});
await Promise.all(promises);
return doc;
};

View File

@@ -0,0 +1,133 @@
/* eslint-disable no-param-reassign */
import { PayloadRequest } from '../../../express/types';
import { Field, fieldAffectsData } from '../../config/types';
import { traverseFields } from './traverseFields';
type Args = {
data: Record<string, unknown>
doc: Record<string, unknown>
field: Field
operation: 'create' | 'update'
promises: Promise<void>[]
req: PayloadRequest
siblingData: Record<string, unknown>
siblingDoc: Record<string, unknown>
}
// This function is responsible for the following actions, in order:
// - Execute field hooks
export const promise = async ({
data,
doc,
field,
operation,
promises,
req,
siblingData,
siblingDoc,
}: Args): Promise<void> => {
if (fieldAffectsData(field)) {
// Execute hooks
if (field.hooks?.afterChange) {
await field.hooks.afterChange.reduce(async (priorHook, currentHook) => {
await priorHook;
const hookedValue = await currentHook({
value: siblingData[field.name],
originalDoc: doc,
data,
siblingData,
operation,
req,
});
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue;
}
}, Promise.resolve());
}
}
// Traverse subfields
switch (field.type) {
case 'group': {
traverseFields({
data,
doc,
fields: field.fields,
operation,
promises,
req,
siblingData: siblingData[field.name] as Record<string, unknown> || {},
siblingDoc: siblingDoc[field.name] as Record<string, unknown>,
});
break;
}
case 'array': {
const rows = siblingDoc[field.name];
if (Array.isArray(rows)) {
rows.forEach((row, i) => {
traverseFields({
data,
doc,
fields: field.fields,
operation,
promises,
req,
siblingData: siblingData[field.name]?.[i] || {},
siblingDoc: { ...row } || {},
});
});
}
break;
}
case 'blocks': {
const rows = siblingDoc[field.name];
if (Array.isArray(rows)) {
rows.forEach((row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType);
if (block) {
traverseFields({
data,
doc,
fields: block.fields,
operation,
promises,
req,
siblingData: siblingData[field.name]?.[i] || {},
siblingDoc: { ...row } || {},
});
}
});
}
break;
}
case 'row': {
traverseFields({
data,
doc,
fields: field.fields,
operation,
promises,
req,
siblingData: siblingData || {},
siblingDoc: { ...siblingDoc },
});
break;
}
default: {
break;
}
}
};

View File

@@ -0,0 +1,38 @@
import { Field } from '../../config/types';
import { promise } from './promise';
import { PayloadRequest } from '../../../express/types';
type Args = {
data: Record<string, unknown>
doc: Record<string, unknown>
fields: Field[]
operation: 'create' | 'update'
promises: Promise<void>[]
req: PayloadRequest
siblingData: Record<string, unknown>
siblingDoc: Record<string, unknown>
}
export const traverseFields = ({
data,
doc,
fields,
operation,
promises,
req,
siblingData,
siblingDoc,
}: Args): void => {
fields.forEach((field) => {
promises.push(promise({
data,
doc,
field,
operation,
promises,
req,
siblingData,
siblingDoc,
}));
});
};

View File

@@ -0,0 +1,62 @@
import { SanitizedCollectionConfig } from '../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../globals/config/types';
import { PayloadRequest } from '../../../express/types';
import { traverseFields } from './traverseFields';
import deepCopyObject from '../../../utilities/deepCopyObject';
type Args = {
currentDepth?: number
depth: number
doc: Record<string, unknown>
entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig
flattenLocales?: boolean
req: PayloadRequest
overrideAccess: boolean
showHiddenFields: boolean
}
export async function afterRead<T = any>(args: Args): Promise<T> {
const {
currentDepth: incomingCurrentDepth,
depth: incomingDepth,
doc: incomingDoc,
entityConfig,
flattenLocales = true,
req,
overrideAccess,
showHiddenFields,
} = args;
const doc = deepCopyObject(incomingDoc);
const fieldPromises = [];
const populationPromises = [];
let depth = 0;
if (req.payloadAPI === 'REST' || req.payloadAPI === 'local') {
depth = (incomingDepth || incomingDepth === 0) ? parseInt(String(incomingDepth), 10) : req.payload.config.defaultDepth;
if (depth > req.payload.config.maxDepth) depth = req.payload.config.maxDepth;
}
const currentDepth = incomingCurrentDepth || 1;
traverseFields({
currentDepth,
depth,
doc,
fields: entityConfig.fields,
fieldPromises,
flattenLocales,
overrideAccess,
populationPromises,
req,
siblingDoc: doc,
showHiddenFields,
});
await Promise.all(fieldPromises);
await Promise.all(populationPromises);
return doc;
}

View File

@@ -0,0 +1,265 @@
/* eslint-disable no-param-reassign */
import { Field, fieldAffectsData } from '../../config/types';
import { PayloadRequest } from '../../../express/types';
import { traverseFields } from './traverseFields';
import richTextRelationshipPromise from '../../richText/relationshipPromise';
import relationshipPopulationPromise from './relationshipPopulationPromise';
type Args = {
currentDepth: number
depth: number
doc: Record<string, unknown>
field: Field
fieldPromises: Promise<void>[]
flattenLocales: boolean
populationPromises: Promise<void>[]
req: PayloadRequest
overrideAccess: boolean
siblingDoc: Record<string, unknown>
showHiddenFields: boolean
}
// This function is responsible for the following actions, in order:
// - Remove hidden fields from response
// - Flatten locales into requested locale
// - Sanitize outgoing data (point field, etc)
// - Execute field hooks
// - Execute read access control
// - Populate relationships
export const promise = async ({
currentDepth,
depth,
doc,
field,
fieldPromises,
flattenLocales,
overrideAccess,
populationPromises,
req,
siblingDoc,
showHiddenFields,
}: Args): Promise<void> => {
if (fieldAffectsData(field) && field.hidden && typeof siblingDoc[field.name] !== 'undefined' && !showHiddenFields) {
delete siblingDoc[field.name];
}
const hasLocalizedValue = flattenLocales
&& fieldAffectsData(field)
&& (typeof siblingDoc[field.name] === 'object' && siblingDoc[field.name] !== null)
&& field.name
&& field.localized
&& req.locale !== 'all';
if (hasLocalizedValue) {
let localizedValue = siblingDoc[field.name][req.locale];
if (typeof localizedValue === 'undefined' && req.fallbackLocale) localizedValue = siblingDoc[field.name][req.fallbackLocale];
if (typeof localizedValue === 'undefined' && field.type === 'group') localizedValue = {};
if (typeof localizedValue === 'undefined') localizedValue = null;
siblingDoc[field.name] = localizedValue;
}
// Sanitize outgoing data
switch (field.type) {
case 'group': {
// Fill groups with empty objects so fields with hooks within groups can populate
// themselves virtually as necessary
if (typeof siblingDoc[field.name] === 'undefined') {
siblingDoc[field.name] = {};
}
break;
}
case 'richText': {
if (((field.admin?.elements?.includes('relationship') || field.admin?.elements?.includes('upload')) || !field?.admin?.elements)) {
populationPromises.push(richTextRelationshipPromise({
currentDepth,
depth,
field,
overrideAccess,
req,
siblingDoc,
showHiddenFields,
}));
}
break;
}
case 'point': {
const pointDoc = siblingDoc[field.name] as any;
if (Array.isArray(pointDoc?.coordinates) && pointDoc.coordinates.length === 2) {
siblingDoc[field.name] = pointDoc.coordinates;
}
break;
}
default: {
break;
}
}
if (fieldAffectsData(field)) {
// Execute hooks
if (field.hooks?.afterRead) {
await field.hooks.afterRead.reduce(async (priorHook, currentHook) => {
await priorHook;
const shouldRunHookOnAllLocales = field.localized
&& (req.locale === 'all' || !flattenLocales)
&& typeof siblingDoc[field.name] === 'object';
if (shouldRunHookOnAllLocales) {
const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) => (async () => {
const hookedValue = await currentHook({
value,
originalDoc: doc,
data: doc,
siblingData: siblingDoc[field.name],
operation: 'read',
req,
});
if (hookedValue !== undefined) {
siblingDoc[field.name][locale] = hookedValue;
}
})());
await Promise.all(hookPromises);
} else {
const hookedValue = await currentHook({
value: siblingDoc[field.name],
originalDoc: doc,
data: doc,
siblingData: siblingDoc[field.name],
operation: 'read',
req,
});
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue;
}
}
}, Promise.resolve());
}
// Execute access control
if (field.access && field.access.read) {
const result = overrideAccess ? true : await field.access.read({ req, id: doc.id as string | number, siblingData: siblingDoc, data: doc, doc });
if (!result) {
delete siblingDoc[field.name];
}
}
if (field.type === 'relationship' || field.type === 'upload') {
populationPromises.push(relationshipPopulationPromise({
currentDepth,
depth,
field,
overrideAccess,
req,
showHiddenFields,
siblingDoc,
}));
}
}
switch (field.type) {
case 'group': {
let groupDoc = siblingDoc[field.name] as Record<string, unknown>;
if (typeof siblingDoc[field.name] !== 'object') groupDoc = {};
traverseFields({
currentDepth,
depth,
doc,
fieldPromises,
fields: field.fields,
flattenLocales,
overrideAccess,
populationPromises,
req,
siblingDoc: groupDoc,
showHiddenFields,
});
break;
}
case 'array': {
const rows = siblingDoc[field.name];
if (Array.isArray(rows)) {
rows.forEach((row, i) => {
traverseFields({
currentDepth,
depth,
doc,
fields: field.fields,
fieldPromises,
flattenLocales,
overrideAccess,
populationPromises,
req,
siblingDoc: row || {},
showHiddenFields,
});
});
}
break;
}
case 'blocks': {
const rows = siblingDoc[field.name];
if (Array.isArray(rows)) {
rows.forEach((row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType);
if (block) {
traverseFields({
currentDepth,
depth,
doc,
fields: block.fields,
fieldPromises,
flattenLocales,
overrideAccess,
populationPromises,
req,
siblingDoc: row || {},
showHiddenFields,
});
}
});
}
break;
}
case 'row': {
traverseFields({
currentDepth,
depth,
doc,
fieldPromises,
fields: field.fields,
flattenLocales,
overrideAccess,
populationPromises,
req,
siblingDoc,
showHiddenFields,
});
break;
}
default: {
break;
}
}
};

View File

@@ -1,6 +1,5 @@
import { PayloadRequest } from '../express/types';
import { RelationshipField, fieldSupportsMany, fieldHasMaxDepth, UploadField } from './config/types';
import { Payload } from '..';
import { PayloadRequest } from '../../../express/types';
import { RelationshipField, fieldSupportsMany, fieldHasMaxDepth, UploadField } from '../../config/types';
type PopulateArgs = {
depth: number
@@ -11,7 +10,6 @@ type PopulateArgs = {
data: Record<string, unknown>
field: RelationshipField | UploadField
index?: number
payload: Payload
showHiddenFields: boolean
}
@@ -24,13 +22,12 @@ const populate = async ({
data,
field,
index,
payload,
showHiddenFields,
}: PopulateArgs) => {
const dataToUpdate = dataReference;
const relation = Array.isArray(field.relationTo) ? (data.relationTo as string) : field.relationTo;
const relatedCollection = payload.collections[relation];
const relatedCollection = req.payload.collections[relation];
if (relatedCollection) {
let idString = Array.isArray(field.relationTo) ? data.value : data;
@@ -42,7 +39,7 @@ const populate = async ({
let populatedRelationship;
if (depth && currentDepth <= depth) {
populatedRelationship = await payload.findByID({
populatedRelationship = await req.payload.findByID({
req,
collection: relatedCollection.config.slug,
id: idString as string,
@@ -72,33 +69,31 @@ const populate = async ({
};
type PromiseArgs = {
data: Record<string, any>
siblingDoc: Record<string, any>
field: RelationshipField | UploadField
depth: number
currentDepth: number
req: PayloadRequest
overrideAccess: boolean
payload: Payload
showHiddenFields: boolean
}
const relationshipPopulationPromise = ({
data,
const relationshipPopulationPromise = async ({
siblingDoc,
field,
depth,
currentDepth,
req,
overrideAccess,
payload,
showHiddenFields,
}: PromiseArgs) => async (): Promise<void> => {
const resultingData = data;
}: PromiseArgs): Promise<void> => {
const resultingDoc = siblingDoc;
const populateDepth = fieldHasMaxDepth(field) && field.maxDepth < depth ? field.maxDepth : depth;
if (fieldSupportsMany(field) && field.hasMany && Array.isArray(data[field.name])) {
if (fieldSupportsMany(field) && field.hasMany && Array.isArray(siblingDoc[field.name])) {
const rowPromises = [];
data[field.name].forEach((relatedDoc, index) => {
siblingDoc[field.name].forEach((relatedDoc, index) => {
const rowPromise = async () => {
if (relatedDoc) {
await populate({
@@ -107,10 +102,9 @@ const relationshipPopulationPromise = ({
req,
overrideAccess,
data: relatedDoc,
dataReference: resultingData,
dataReference: resultingDoc,
field,
index,
payload,
showHiddenFields,
});
}
@@ -120,16 +114,15 @@ const relationshipPopulationPromise = ({
});
await Promise.all(rowPromises);
} else if (data[field.name]) {
} else if (siblingDoc[field.name]) {
await populate({
depth: populateDepth,
currentDepth,
req,
overrideAccess,
dataReference: resultingData,
data: data[field.name],
dataReference: resultingDoc,
data: siblingDoc[field.name],
field,
payload,
showHiddenFields,
});
}

View File

@@ -0,0 +1,47 @@
import { Field } from '../../config/types';
import { promise } from './promise';
import { PayloadRequest } from '../../../express/types';
type Args = {
currentDepth: number
depth: number
doc: Record<string, unknown>
fieldPromises: Promise<void>[]
fields: Field[]
flattenLocales: boolean
populationPromises: Promise<void>[]
req: PayloadRequest
overrideAccess: boolean
siblingDoc: Record<string, unknown>
showHiddenFields: boolean
}
export const traverseFields = ({
currentDepth,
depth,
doc,
fieldPromises,
fields,
flattenLocales,
overrideAccess,
populationPromises,
req,
siblingDoc,
showHiddenFields,
}: Args): void => {
fields.forEach((field) => {
fieldPromises.push(promise({
currentDepth,
depth,
doc,
field,
fieldPromises,
flattenLocales,
overrideAccess,
populationPromises,
req,
siblingDoc,
showHiddenFields,
}));
});
};

View File

@@ -0,0 +1,62 @@
import { SanitizedCollectionConfig } from '../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../globals/config/types';
import { Operation } from '../../../types';
import { PayloadRequest } from '../../../express/types';
import { traverseFields } from './traverseFields';
import { ValidationError } from '../../../errors';
import deepCopyObject from '../../../utilities/deepCopyObject';
type Args = {
data: Record<string, unknown>
doc: Record<string, unknown>
docWithLocales: Record<string, unknown>
entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig
id?: string | number
operation: Operation
req: PayloadRequest
skipValidation?: boolean
}
export const beforeChange = async ({
data: incomingData,
doc,
docWithLocales,
entityConfig,
id,
operation,
req,
skipValidation,
}: Args): Promise<Record<string, unknown>> => {
const data = deepCopyObject(incomingData);
const promises = [];
const mergeLocaleActions = [];
const errors: { message: string, field: string }[] = [];
traverseFields({
data,
doc,
docWithLocales,
errors,
id,
operation,
path: '',
mergeLocaleActions,
promises,
req,
siblingData: data,
siblingDoc: doc,
siblingDocWithLocales: docWithLocales,
fields: entityConfig.fields,
skipValidation,
});
await Promise.all(promises);
if (errors.length > 0) {
throw new ValidationError(errors);
}
mergeLocaleActions.forEach((action) => action());
return data;
};

View File

@@ -0,0 +1,285 @@
/* eslint-disable no-param-reassign */
import merge from 'deepmerge';
import { Field, fieldAffectsData } from '../../config/types';
import { Operation } from '../../../types';
import { PayloadRequest } from '../../../express/types';
import getValueWithDefault from '../../getDefaultValue';
import { traverseFields } from './traverseFields';
type Args = {
data: Record<string, unknown>
doc: Record<string, unknown>
docWithLocales: Record<string, unknown>
errors: { message: string, field: string }[]
field: Field
id?: string | number
mergeLocaleActions: (() => void)[]
operation: Operation
path: string
promises: Promise<void>[]
req: PayloadRequest
siblingData: Record<string, unknown>
siblingDoc: Record<string, unknown>
siblingDocWithLocales?: Record<string, unknown>
skipValidation: boolean
}
// This function is responsible for the following actions, in order:
// - Run condition
// - Merge original document data into incoming data
// - Compute default values for undefined fields
// - Execute field hooks
// - Validate data
// - Transform data for storage
// - Unflatten locales
export const promise = async ({
data,
doc,
docWithLocales,
errors,
field,
id,
mergeLocaleActions,
operation,
path,
promises,
req,
siblingData,
siblingDoc,
siblingDocWithLocales,
skipValidation,
}: Args): Promise<void> => {
const passesCondition = (field.admin?.condition) ? field.admin.condition(data, siblingData) : true;
const skipValidationFromHere = skipValidation || !passesCondition;
if (fieldAffectsData(field)) {
if (typeof siblingData[field.name] === 'undefined') {
// If no incoming data, but existing document data is found, merge it in
if (typeof siblingDoc[field.name] !== 'undefined') {
if (field.localized && typeof siblingDoc[field.name] === 'object') {
siblingData[field.name] = siblingDoc[field.name][req.locale];
} else {
siblingData[field.name] = siblingDoc[field.name];
}
// Otherwise compute default value
} else if (typeof field.defaultValue !== 'undefined') {
siblingData[field.name] = await getValueWithDefault({
value: siblingData[field.name],
defaultValue: field.defaultValue,
locale: req.locale,
user: req.user,
});
}
}
// Execute hooks
if (field.hooks?.beforeChange) {
await field.hooks.beforeChange.reduce(async (priorHook, currentHook) => {
await priorHook;
const hookedValue = await currentHook({
value: siblingData[field.name],
originalDoc: doc,
data,
siblingData,
operation,
req,
});
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue;
}
}, Promise.resolve());
}
// Validate
if (!skipValidationFromHere && field.validate) {
let valueToValidate;
if (['array', 'blocks'].includes(field.type)) {
const rows = siblingData[field.name];
valueToValidate = Array.isArray(rows) ? rows.length : 0;
} else {
valueToValidate = siblingData[field.name];
}
const validationResult = await field.validate(valueToValidate, {
...field,
data: merge(doc, data),
siblingData: merge(siblingDoc, siblingData),
id,
operation,
user: req.user,
payload: req.payload,
});
if (typeof validationResult === 'string') {
errors.push({
message: validationResult,
field: `${path}${field.name}`,
});
}
}
// Push merge locale action if applicable
if (field.localized) {
mergeLocaleActions.push(() => {
const localeData = req.payload.config.localization.locales.reduce((locales, localeID) => {
let valueToSet = siblingData[field.name];
if (localeID !== req.locale) {
valueToSet = siblingDocWithLocales?.[field.name]?.[localeID];
}
if (typeof valueToSet !== 'undefined') {
return {
...locales,
[localeID]: valueToSet,
};
}
return locales;
}, {});
// If there are locales with data, set the data
if (Object.keys(localeData).length > 0) {
siblingData[field.name] = localeData;
}
});
}
}
switch (field.type) {
case 'point': {
// Transform point data for storage
if (Array.isArray(siblingData[field.name]) && siblingData[field.name][0] !== null && siblingData[field.name][1] !== null) {
siblingData[field.name] = {
type: 'Point',
coordinates: [
parseFloat(siblingData[field.name][0]),
parseFloat(siblingData[field.name][1]),
],
};
}
break;
}
case 'group': {
let groupData = siblingData[field.name] as Record<string, unknown>;
let groupDoc = siblingDoc[field.name] as Record<string, unknown>;
let groupDocWithLocales = siblingDocWithLocales[field.name] as Record<string, unknown>;
if (typeof siblingData[field.name] !== 'object') groupData = {};
if (typeof siblingDoc[field.name] !== 'object') groupDoc = {};
if (typeof siblingDocWithLocales[field.name] !== 'object') groupDocWithLocales = {};
traverseFields({
data,
doc,
docWithLocales,
errors,
fields: field.fields,
id,
mergeLocaleActions,
operation,
path: `${path}${field.name}.`,
promises,
req,
siblingData: groupData,
siblingDoc: groupDoc,
siblingDocWithLocales: groupDocWithLocales,
skipValidation: skipValidationFromHere,
});
break;
}
case 'array': {
const rows = siblingData[field.name];
if (Array.isArray(rows)) {
rows.forEach((row, i) => {
traverseFields({
data,
doc,
docWithLocales,
errors,
fields: field.fields,
id,
mergeLocaleActions,
operation,
path: `${path}${field.name}.${i}.`,
promises,
req,
siblingData: row,
siblingDoc: siblingDoc[field.name]?.[i] || {},
siblingDocWithLocales: siblingDocWithLocales[field.name]?.[i] || {},
skipValidation: skipValidationFromHere,
});
});
}
break;
}
case 'blocks': {
const rows = siblingData[field.name];
if (Array.isArray(rows)) {
rows.forEach((row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType);
if (block) {
traverseFields({
data,
doc,
docWithLocales,
errors,
fields: block.fields,
id,
mergeLocaleActions,
operation,
path: `${path}${field.name}.${i}.`,
promises,
req,
siblingData: row,
siblingDoc: siblingDoc[field.name]?.[i] || {},
siblingDocWithLocales: siblingDocWithLocales[field.name]?.[i] || {},
skipValidation: skipValidationFromHere,
});
}
});
}
break;
}
case 'row': {
traverseFields({
data,
doc,
docWithLocales,
errors,
fields: field.fields,
id,
mergeLocaleActions,
operation,
path,
promises,
req,
siblingData,
siblingDoc,
siblingDocWithLocales,
skipValidation: skipValidationFromHere,
});
break;
}
default: {
break;
}
}
};

View File

@@ -0,0 +1,60 @@
import { Field } from '../../config/types';
import { promise } from './promise';
import { Operation } from '../../../types';
import { PayloadRequest } from '../../../express/types';
type Args = {
data: Record<string, unknown>
doc: Record<string, unknown>
docWithLocales: Record<string, unknown>
errors: { message: string, field: string }[]
fields: Field[]
id?: string | number
mergeLocaleActions: (() => void)[]
operation: Operation
path: string
promises: Promise<void>[]
req: PayloadRequest
siblingData: Record<string, unknown>
siblingDoc: Record<string, unknown>
siblingDocWithLocales: Record<string, unknown>
skipValidation?: boolean
}
export const traverseFields = ({
data,
doc,
docWithLocales,
errors,
fields,
id,
mergeLocaleActions,
operation,
path,
promises,
req,
siblingData,
siblingDoc,
siblingDocWithLocales,
skipValidation,
}: Args): void => {
fields.forEach((field) => {
promises.push(promise({
data,
doc,
docWithLocales,
errors,
field,
id,
mergeLocaleActions,
operation,
path,
promises,
req,
siblingData,
siblingDoc,
siblingDocWithLocales,
skipValidation,
}));
});
};

View File

@@ -0,0 +1,45 @@
import { SanitizedCollectionConfig } from '../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../globals/config/types';
import { PayloadRequest } from '../../../express/types';
import { traverseFields } from './traverseFields';
import deepCopyObject from '../../../utilities/deepCopyObject';
type Args = {
data: Record<string, unknown>
doc: Record<string, unknown>
entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig
id?: string | number
operation: 'create' | 'update'
overrideAccess: boolean
req: PayloadRequest
}
export const beforeValidate = async ({
data: incomingData,
doc,
entityConfig,
id,
operation,
overrideAccess,
req,
}: Args): Promise<Record<string, unknown>> => {
const promises = [];
const data = deepCopyObject(incomingData);
traverseFields({
data,
doc,
fields: entityConfig.fields,
id,
operation,
overrideAccess,
promises,
req,
siblingData: data,
siblingDoc: doc,
});
await Promise.all(promises);
return data;
};

View File

@@ -0,0 +1,273 @@
/* eslint-disable no-param-reassign */
import { PayloadRequest } from '../../../express/types';
import { Field, fieldAffectsData, valueIsValueWithRelation } from '../../config/types';
import { traverseFields } from './traverseFields';
type Args = {
data: Record<string, unknown>
doc: Record<string, unknown>
field: Field
id?: string | number
operation: 'create' | 'update'
overrideAccess: boolean
promises: Promise<void>[]
req: PayloadRequest
siblingData: Record<string, unknown>
siblingDoc: Record<string, unknown>
}
// This function is responsible for the following actions, in order:
// - Sanitize incoming data
// - Execute field hooks
// - Execute field access control
export const promise = async ({
data,
doc,
field,
id,
operation,
overrideAccess,
promises,
req,
siblingData,
siblingDoc,
}: Args): Promise<void> => {
if (fieldAffectsData(field)) {
if (field.name === 'id') {
if (field.type === 'number' && typeof siblingData[field.name] === 'string') {
const value = siblingData[field.name] as string;
siblingData[field.name] = parseFloat(value);
}
if (field.type === 'text' && typeof siblingData[field.name]?.toString === 'function' && typeof siblingData[field.name] !== 'string') {
siblingData[field.name] = siblingData[field.name].toString();
}
}
// Sanitize incoming data
switch (field.type) {
case 'number': {
if (typeof siblingData[field.name] === 'string') {
const value = siblingData[field.name] as string;
const trimmed = value.trim();
siblingData[field.name] = (trimmed.length === 0) ? null : parseFloat(trimmed);
}
break;
}
case 'checkbox': {
if (siblingData[field.name] === 'true') siblingData[field.name] = true;
if (siblingData[field.name] === 'false') siblingData[field.name] = false;
if (siblingData[field.name] === '') siblingData[field.name] = false;
break;
}
case 'richText': {
if (typeof siblingData[field.name] === 'string') {
try {
const richTextJSON = JSON.parse(siblingData[field.name] as string);
siblingData[field.name] = richTextJSON;
} catch {
// Disregard this data as it is not valid.
// Will be reported to user by field validation
}
}
break;
}
case 'relationship':
case 'upload': {
if (siblingData[field.name] === '' || siblingData[field.name] === 'none' || siblingData[field.name] === 'null' || siblingData[field.name] === null) {
if (field.type === 'relationship' && field.hasMany === true) {
siblingData[field.name] = [];
} else {
siblingData[field.name] = null;
}
}
const value = siblingData[field.name];
if (Array.isArray(field.relationTo)) {
if (Array.isArray(value)) {
value.forEach((relatedDoc: { value: unknown, relationTo: string }, i) => {
const relatedCollection = req.payload.config.collections.find((collection) => collection.slug === relatedDoc.relationTo);
const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id');
if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = { ...relatedDoc, value: parseFloat(relatedDoc.value as string) };
}
});
}
if (field.type === 'relationship' && field.hasMany !== true && valueIsValueWithRelation(value)) {
const relatedCollection = req.payload.config.collections.find((collection) => collection.slug === value.relationTo);
const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id');
if (relationshipIDField?.type === 'number') {
siblingData[field.name] = { ...value, value: parseFloat(value.value as string) };
}
}
} else {
if (Array.isArray(value)) {
value.forEach((relatedDoc: unknown, i) => {
const relatedCollection = req.payload.config.collections.find((collection) => collection.slug === field.relationTo);
const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id');
if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = parseFloat(relatedDoc as string);
}
});
}
if (field.type === 'relationship' && field.hasMany !== true && value) {
const relatedCollection = req.payload.config.collections.find((collection) => collection.slug === field.relationTo);
const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id');
if (relationshipIDField?.type === 'number') {
siblingData[field.name] = parseFloat(value as string);
}
}
}
break;
}
case 'array':
case 'blocks': {
// Handle cases of arrays being intentionally set to 0
if (siblingData[field.name] === '0' || siblingData[field.name] === 0 || siblingData[field.name] === null) {
siblingData[field.name] = [];
}
break;
}
default: {
break;
}
}
// Execute hooks
if (field.hooks?.beforeValidate) {
await field.hooks.beforeValidate.reduce(async (priorHook, currentHook) => {
await priorHook;
const hookedValue = await currentHook({
value: siblingData[field.name],
originalDoc: doc,
data,
siblingData,
operation,
req,
});
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue;
}
}, Promise.resolve());
}
// Execute access control
if (field.access && field.access[operation]) {
const result = overrideAccess ? true : await field.access[operation]({ req, id, siblingData, data, doc });
if (!result) {
delete siblingData[field.name];
}
}
}
// Traverse subfields
switch (field.type) {
case 'group': {
let groupData = siblingData[field.name] as Record<string, unknown>;
let groupDoc = siblingDoc[field.name] as Record<string, unknown>;
if (typeof siblingData[field.name] !== 'object') groupData = {};
if (typeof siblingDoc[field.name] !== 'object') groupDoc = {};
traverseFields({
data,
doc,
fields: field.fields,
id,
operation,
overrideAccess,
promises,
req,
siblingData: groupData,
siblingDoc: groupDoc,
});
break;
}
case 'array': {
const rows = siblingData[field.name];
if (Array.isArray(rows)) {
rows.forEach((row, i) => {
traverseFields({
data,
doc,
fields: field.fields,
id,
operation,
overrideAccess,
promises,
req,
siblingData: row,
siblingDoc: siblingDoc[field.name]?.[i] || {},
});
});
}
break;
}
case 'blocks': {
const rows = siblingData[field.name];
if (Array.isArray(rows)) {
rows.forEach((row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType);
if (block) {
traverseFields({
data,
doc,
fields: block.fields,
id,
operation,
overrideAccess,
promises,
req,
siblingData: row,
siblingDoc: siblingDoc[field.name]?.[i] || {},
});
}
});
}
break;
}
case 'row': {
traverseFields({
data,
doc,
fields: field.fields,
id,
operation,
overrideAccess,
promises,
req,
siblingData,
siblingDoc,
});
break;
}
default: {
break;
}
}
};

View File

@@ -0,0 +1,44 @@
import { PayloadRequest } from '../../../express/types';
import { Field } from '../../config/types';
import { promise } from './promise';
type Args = {
data: Record<string, unknown>
doc: Record<string, unknown>
fields: Field[]
id?: string | number
operation: 'create' | 'update'
overrideAccess: boolean
promises: Promise<void>[]
req: PayloadRequest
siblingData: Record<string, unknown>
siblingDoc: Record<string, unknown>
}
export const traverseFields = ({
data,
doc,
fields,
id,
operation,
overrideAccess,
promises,
req,
siblingData,
siblingDoc,
}: Args): void => {
fields.forEach((field) => {
promises.push(promise({
data,
doc,
field,
id,
operation,
overrideAccess,
promises,
req,
siblingData,
siblingDoc,
}));
});
};

View File

@@ -1,144 +0,0 @@
import { Payload } from '..';
import { ValidationError } from '../errors';
import sanitizeFallbackLocale from '../localization/sanitizeFallbackLocale';
import traverseFields from './traverseFields';
import { SanitizedCollectionConfig } from '../collections/config/types';
import { SanitizedGlobalConfig } from '../globals/config/types';
import { Operation } from '../types';
import { PayloadRequest } from '../express/types';
import { HookName } from './config/types';
import deepCopyObject from '../utilities/deepCopyObject';
type Arguments = {
data: Record<string, unknown>
operation: Operation
hook?: HookName
req: PayloadRequest
overrideAccess: boolean
flattenLocales?: boolean
unflattenLocales?: boolean
originalDoc?: Record<string, unknown>
docWithLocales?: Record<string, unknown>
id?: string | number
showHiddenFields?: boolean
depth?: number
currentDepth?: number
isVersion?: boolean
skipValidation?: boolean
}
export default async function performFieldOperations(this: Payload, entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig, args: Arguments): Promise<any> {
const {
data,
originalDoc: fullOriginalDoc,
docWithLocales,
operation,
hook,
req,
id,
req: {
payloadAPI,
locale,
},
overrideAccess,
flattenLocales,
unflattenLocales = false,
showHiddenFields = false,
isVersion = false,
skipValidation = false,
} = args;
const fullData = deepCopyObject(data);
const fallbackLocale = sanitizeFallbackLocale(req.fallbackLocale);
let depth = 0;
if (payloadAPI === 'REST' || payloadAPI === 'local') {
depth = (args.depth || args.depth === 0) ? parseInt(String(args.depth), 10) : this.config.defaultDepth;
if (depth > this.config.maxDepth) depth = this.config.maxDepth;
}
const currentDepth = args.currentDepth || 1;
// Maintain a top-level list of promises
// so that all async field access / validations / hooks
// can run in parallel
const valuePromises = [];
const validationPromises = [];
const accessPromises = [];
const relationshipPopulations = [];
const hookPromises = [];
const unflattenLocaleActions = [];
const transformActions = [];
const errors: { message: string, field: string }[] = [];
// //////////////////////////////////////////
// Entry point for field validation
// //////////////////////////////////////////
traverseFields({
fields: entityConfig.fields,
data: fullData,
originalDoc: fullOriginalDoc,
path: '',
flattenLocales,
locale,
fallbackLocale,
accessPromises,
operation,
overrideAccess,
req,
id,
relationshipPopulations,
depth,
currentDepth,
hook,
hookPromises,
fullOriginalDoc,
fullData,
valuePromises,
validationPromises,
errors,
payload: this,
showHiddenFields,
unflattenLocales,
unflattenLocaleActions,
transformActions,
docWithLocales,
isVersion,
skipValidation,
});
if (hook === 'afterRead') {
transformActions.forEach((action) => action());
}
const hookResults = hookPromises.map((promise) => promise());
await Promise.all(hookResults);
const valueResults = valuePromises.map((promise) => promise());
await Promise.all(valueResults);
const validationResults = validationPromises.map((promise) => promise());
await Promise.all(validationResults);
if (errors.length > 0) {
throw new ValidationError(errors);
}
if (hook === 'beforeChange') {
transformActions.forEach((action) => action());
}
unflattenLocaleActions.forEach((action) => action());
const accessResults = accessPromises.map((promise) => promise());
await Promise.all(accessResults);
const relationshipPopulationResults = relationshipPopulations.map((population) => population());
await Promise.all(relationshipPopulationResults);
return fullData;
}

View File

@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import { Collection } from '../../collections/config/types';
import { Payload } from '../..';
import { RichTextField, Field } from '../config/types';
import { PayloadRequest } from '../../express/types';
@@ -10,7 +9,6 @@ type Arguments = {
key: string | number
depth: number
currentDepth?: number
payload: Payload
field: RichTextField
req: PayloadRequest
showHiddenFields: boolean
@@ -24,7 +22,6 @@ export const populate = async ({
overrideAccess,
depth,
currentDepth,
payload,
req,
showHiddenFields,
}: Omit<Arguments, 'field'> & {
@@ -34,7 +31,7 @@ export const populate = async ({
}): Promise<void> => {
const dataRef = data as Record<string, unknown>;
const doc = await payload.findByID({
const doc = await req.payload.findByID({
req,
collection: collection.config.slug,
id,

View File

@@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import { Payload } from '../..';
import { Field, fieldHasSubFields, fieldIsArrayType, fieldAffectsData } from '../config/types';
import { PayloadRequest } from '../../express/types';
import { populate } from './populate';
@@ -10,7 +9,6 @@ type NestedRichTextFieldsArgs = {
data: unknown
fields: Field[]
req: PayloadRequest
payload: Payload
overrideAccess: boolean
depth: number
currentDepth?: number
@@ -22,7 +20,6 @@ export const recurseNestedFields = ({
data,
fields,
req,
payload,
overrideAccess = false,
depth,
currentDepth = 0,
@@ -34,7 +31,7 @@ export const recurseNestedFields = ({
if (field.hasMany && Array.isArray(data[field.name])) {
if (Array.isArray(field.relationTo)) {
data[field.name].forEach(({ relationTo, value }, i) => {
const collection = payload.collections[relationTo];
const collection = req.payload.collections[relationTo];
if (collection) {
promises.push(populate({
id: value,
@@ -45,7 +42,6 @@ export const recurseNestedFields = ({
overrideAccess,
depth,
currentDepth,
payload,
req,
showHiddenFields,
}));
@@ -53,7 +49,7 @@ export const recurseNestedFields = ({
});
} else {
data[field.name].forEach((id, i) => {
const collection = payload.collections[field.relationTo as string];
const collection = req.payload.collections[field.relationTo as string];
if (collection) {
promises.push(populate({
id,
@@ -64,7 +60,6 @@ export const recurseNestedFields = ({
overrideAccess,
depth,
currentDepth,
payload,
req,
showHiddenFields,
}));
@@ -72,7 +67,7 @@ export const recurseNestedFields = ({
});
}
} else if (Array.isArray(field.relationTo) && data[field.name]?.value && data[field.name]?.relationTo) {
const collection = payload.collections[data[field.name].relationTo];
const collection = req.payload.collections[data[field.name].relationTo];
promises.push(populate({
id: data[field.name].value,
field,
@@ -82,14 +77,13 @@ export const recurseNestedFields = ({
overrideAccess,
depth,
currentDepth,
payload,
req,
showHiddenFields,
}));
}
}
if (typeof data[field.name] !== 'undefined' && typeof field.relationTo === 'string') {
const collection = payload.collections[field.relationTo];
const collection = req.payload.collections[field.relationTo];
promises.push(populate({
id: data[field.name],
field,
@@ -99,7 +93,6 @@ export const recurseNestedFields = ({
overrideAccess,
depth,
currentDepth,
payload,
req,
showHiddenFields,
}));
@@ -111,7 +104,6 @@ export const recurseNestedFields = ({
data: data[field.name],
fields: field.fields,
req,
payload,
overrideAccess,
depth,
currentDepth,
@@ -123,7 +115,6 @@ export const recurseNestedFields = ({
data,
fields: field.fields,
req,
payload,
overrideAccess,
depth,
currentDepth,
@@ -140,7 +131,6 @@ export const recurseNestedFields = ({
data: data[field.name][i],
fields: block.fields,
req,
payload,
overrideAccess,
depth,
currentDepth,
@@ -157,7 +147,6 @@ export const recurseNestedFields = ({
data: data[field.name][i],
fields: field.fields,
req,
payload,
overrideAccess,
depth,
currentDepth,
@@ -173,7 +162,6 @@ export const recurseNestedFields = ({
recurseRichText({
req,
children: node.children,
payload,
overrideAccess,
depth,
currentDepth,

View File

@@ -1,17 +1,15 @@
import { Payload } from '../..';
import { RichTextField } from '../config/types';
import { PayloadRequest } from '../../express/types';
import { recurseNestedFields } from './recurseNestedFields';
import { populate } from './populate';
type Arguments = {
data: unknown
overrideAccess?: boolean
depth: number
type Args = {
currentDepth?: number
payload: Payload
depth: number
field: RichTextField
overrideAccess?: boolean
req: PayloadRequest
siblingDoc: Record<string, unknown>
showHiddenFields: boolean
}
@@ -20,7 +18,6 @@ type RecurseRichTextArgs = {
overrideAccess: boolean
depth: number
currentDepth: number
payload: Payload
field: RichTextField
req: PayloadRequest
promises: Promise<void>[]
@@ -30,7 +27,6 @@ type RecurseRichTextArgs = {
export const recurseRichText = ({
req,
children,
payload,
overrideAccess = false,
depth,
currentDepth = 0,
@@ -40,7 +36,7 @@ export const recurseRichText = ({
}: RecurseRichTextArgs): void => {
if (Array.isArray(children)) {
(children as any[]).forEach((element) => {
const collection = payload.collections[element?.relationTo];
const collection = req.payload.collections[element?.relationTo];
if ((element.type === 'relationship' || element.type === 'upload')
&& element?.value?.id
@@ -52,7 +48,6 @@ export const recurseRichText = ({
data: element.fields || {},
fields: field.admin.upload.collections[element.relationTo].fields,
req,
payload,
overrideAccess,
depth,
currentDepth,
@@ -67,7 +62,6 @@ export const recurseRichText = ({
overrideAccess,
depth,
currentDepth,
payload,
field,
collection,
showHiddenFields,
@@ -76,14 +70,13 @@ export const recurseRichText = ({
if (element?.children) {
recurseRichText({
req,
children: element.children,
payload,
overrideAccess,
depth,
currentDepth,
depth,
field,
overrideAccess,
promises,
req,
showHiddenFields,
});
}
@@ -91,27 +84,25 @@ export const recurseRichText = ({
}
};
const richTextRelationshipPromise = ({
req,
data,
payload,
overrideAccess,
depth,
const richTextRelationshipPromise = async ({
currentDepth,
depth,
field,
overrideAccess,
req,
siblingDoc,
showHiddenFields,
}: Arguments) => async (): Promise<void> => {
}: Args): Promise<void> => {
const promises = [];
recurseRichText({
req,
children: data[field.name],
payload,
overrideAccess,
depth,
children: siblingDoc[field.name] as unknown[],
currentDepth,
depth,
field,
overrideAccess,
promises,
req,
showHiddenFields,
});

View File

@@ -1,427 +0,0 @@
import validationPromise from './validationPromise';
import accessPromise from './accessPromise';
import hookPromise from './hookPromise';
import {
Field,
fieldHasSubFields,
fieldIsArrayType,
fieldIsBlockType,
fieldAffectsData,
HookName,
} from './config/types';
import { Operation } from '../types';
import { PayloadRequest } from '../express/types';
import { Payload } from '..';
import richTextRelationshipPromise from './richText/relationshipPromise';
import getValueWithDefault from './getDefaultValue';
type Arguments = {
fields: Field[]
data: Record<string, any>
originalDoc: Record<string, any>
path: string
flattenLocales: boolean
locale: string
fallbackLocale: string
accessPromises: (() => Promise<void>)[]
operation: Operation
overrideAccess: boolean
req: PayloadRequest
id?: string | number
relationshipPopulations: (() => Promise<void>)[]
depth: number
currentDepth: number
hook: HookName
hookPromises: (() => Promise<void>)[]
fullOriginalDoc: Record<string, any>
fullData: Record<string, any>
valuePromises: (() => Promise<void>)[]
validationPromises: (() => Promise<string | boolean>)[]
errors: { message: string, field: string }[]
payload: Payload
showHiddenFields: boolean
unflattenLocales: boolean
unflattenLocaleActions: (() => void)[]
transformActions: (() => void)[]
docWithLocales?: Record<string, any>
skipValidation?: boolean
isVersion: boolean
}
const traverseFields = (args: Arguments): void => {
const {
fields,
data = {},
originalDoc = {},
path,
flattenLocales,
locale,
fallbackLocale,
accessPromises,
operation,
overrideAccess,
req,
id,
relationshipPopulations,
depth,
currentDepth,
hook,
hookPromises,
fullOriginalDoc,
fullData,
valuePromises,
validationPromises,
errors,
payload,
showHiddenFields,
unflattenLocaleActions,
unflattenLocales,
transformActions,
docWithLocales = {},
skipValidation,
isVersion,
} = args;
fields.forEach((field) => {
const dataCopy = data;
if (hook === 'afterRead') {
if (field.type === 'group') {
// Fill groups with empty objects so fields with hooks within groups can populate
// themselves virtually as necessary
if (typeof data[field.name] === 'undefined' && typeof originalDoc[field.name] === 'undefined') {
data[field.name] = {};
}
}
if (fieldAffectsData(field) && field.hidden && typeof data[field.name] !== 'undefined' && !showHiddenFields) {
delete data[field.name];
}
if (field.type === 'point') {
transformActions.push(() => {
if (data[field.name]?.coordinates && Array.isArray(data[field.name].coordinates) && data[field.name].coordinates.length === 2) {
data[field.name] = data[field.name].coordinates;
}
});
}
}
if ((field.type === 'upload' || field.type === 'relationship')
&& (data[field.name] === '' || data[field.name] === 'none' || data[field.name] === 'null')) {
if (field.type === 'relationship' && field.hasMany === true) {
dataCopy[field.name] = [];
} else {
dataCopy[field.name] = null;
}
}
if (field.type === 'relationship' && field.hasMany && (data[field.name] === '' || data[field.name] === 'none' || data[field.name] === 'null' || data[field.name] === null)) {
dataCopy[field.name] = [];
}
if (field.type === 'number' && typeof data[field.name] === 'string') {
const trimmed = data[field.name].trim();
dataCopy[field.name] = (trimmed.length === 0) ? null : parseFloat(trimmed);
}
if (fieldAffectsData(field) && field.name === 'id') {
if (field.type === 'number' && typeof data[field.name] === 'string') {
dataCopy[field.name] = parseFloat(data[field.name]);
}
if (field.type === 'text' && typeof data[field.name]?.toString === 'function' && typeof data[field.name] !== 'string') {
dataCopy[field.name] = dataCopy[field.name].toString();
}
}
if (field.type === 'checkbox') {
if (data[field.name] === 'true') dataCopy[field.name] = true;
if (data[field.name] === 'false') dataCopy[field.name] = false;
if (data[field.name] === '') dataCopy[field.name] = false;
}
if (field.type === 'richText') {
if (typeof data[field.name] === 'string') {
try {
const richTextJSON = JSON.parse(data[field.name] as string);
dataCopy[field.name] = richTextJSON;
} catch {
// Disregard this data as it is not valid.
// Will be reported to user by field validation
}
}
if (((field.admin?.elements?.includes('relationship') || field.admin?.elements?.includes('upload')) || !field?.admin?.elements) && hook === 'afterRead') {
relationshipPopulations.push(richTextRelationshipPromise({
req,
data,
payload,
overrideAccess,
depth,
field,
currentDepth,
showHiddenFields,
}));
}
}
const hasLocalizedValue = fieldAffectsData(field)
&& (typeof data?.[field.name] === 'object' && data?.[field.name] !== null)
&& field.name
&& field.localized
&& locale !== 'all'
&& flattenLocales;
if (hasLocalizedValue) {
let localizedValue = data[field.name][locale];
if (typeof localizedValue === 'undefined' && fallbackLocale) localizedValue = data[field.name][fallbackLocale];
if (typeof localizedValue === 'undefined' && field.type === 'group') localizedValue = {};
if (typeof localizedValue === 'undefined') localizedValue = null;
dataCopy[field.name] = localizedValue;
}
if (fieldAffectsData(field) && field.localized && unflattenLocales) {
unflattenLocaleActions.push(() => {
const localeData = payload.config.localization.locales.reduce((locales, localeID) => {
let valueToSet;
if (localeID === locale) {
if (typeof data[field.name] !== 'undefined') {
valueToSet = data[field.name];
} else if (docWithLocales?.[field.name]?.[localeID]) {
valueToSet = docWithLocales?.[field.name]?.[localeID];
}
} else {
valueToSet = docWithLocales?.[field.name]?.[localeID];
}
if (typeof valueToSet !== 'undefined') {
return {
...locales,
[localeID]: valueToSet,
};
}
return locales;
}, {});
// If there are locales with data, set the data
if (Object.keys(localeData).length > 0) {
data[field.name] = localeData;
}
});
}
if (fieldAffectsData(field)) {
accessPromises.push(() => accessPromise({
data,
fullData,
originalDoc,
field,
operation,
overrideAccess,
req,
id,
relationshipPopulations,
depth,
currentDepth,
hook,
payload,
showHiddenFields,
}));
hookPromises.push(() => hookPromise({
data,
field,
hook,
req,
operation,
fullOriginalDoc,
fullData,
flattenLocales,
isVersion,
}));
}
const passesCondition = (field.admin?.condition && hook === 'beforeChange') ? field.admin.condition(fullData, data) : true;
const skipValidationFromHere = skipValidation || !passesCondition;
if (fieldHasSubFields(field)) {
if (!fieldAffectsData(field)) {
traverseFields({
...args,
fields: field.fields,
skipValidation: skipValidationFromHere,
});
} else if (fieldIsArrayType(field)) {
if (Array.isArray(data[field.name])) {
for (let i = 0; i < data[field.name].length; i += 1) {
if (typeof (data[field.name][i]) === 'undefined') {
data[field.name][i] = {};
}
traverseFields({
...args,
fields: field.fields,
data: data[field.name][i] || {},
originalDoc: originalDoc?.[field.name]?.[i],
docWithLocales: docWithLocales?.[field.name]?.[i],
path: `${path}${field.name}.${i}.`,
skipValidation: skipValidationFromHere,
showHiddenFields,
});
}
}
} else {
traverseFields({
...args,
fields: field.fields,
data: data[field.name] as Record<string, unknown>,
originalDoc: originalDoc[field.name],
docWithLocales: docWithLocales?.[field.name],
path: `${path}${field.name}.`,
skipValidation: skipValidationFromHere,
showHiddenFields,
});
}
}
if (fieldIsBlockType(field)) {
if (Array.isArray(data[field.name])) {
(data[field.name] as Record<string, unknown>[]).forEach((rowData, i) => {
const block = field.blocks.find((blockType) => blockType.slug === rowData.blockType);
if (block) {
traverseFields({
...args,
fields: block.fields,
data: rowData || {},
originalDoc: originalDoc?.[field.name]?.[i],
docWithLocales: docWithLocales?.[field.name]?.[i],
path: `${path}${field.name}.${i}.`,
skipValidation: skipValidationFromHere,
showHiddenFields,
});
}
});
}
}
if (hook === 'beforeChange' && fieldAffectsData(field)) {
const updatedData = data;
if (data?.[field.name] === undefined && originalDoc?.[field.name] === undefined && field.defaultValue) {
valuePromises.push(async () => {
let valueToUpdate = data?.[field.name];
if (typeof valueToUpdate === 'undefined' && typeof originalDoc?.[field.name] !== 'undefined') {
valueToUpdate = originalDoc?.[field.name];
}
const value = await getValueWithDefault({ value: valueToUpdate, defaultValue: field.defaultValue, locale, user: req.user });
updatedData[field.name] = value;
});
}
if (field.type === 'relationship' || field.type === 'upload') {
if (Array.isArray(field.relationTo)) {
if (Array.isArray(dataCopy[field.name])) {
dataCopy[field.name].forEach((relatedDoc: { value: unknown, relationTo: string }, i) => {
const relatedCollection = payload.config.collections.find((collection) => collection.slug === relatedDoc.relationTo);
const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id');
if (relationshipIDField?.type === 'number') {
dataCopy[field.name][i] = { ...relatedDoc, value: parseFloat(relatedDoc.value as string) };
}
});
}
if (field.type === 'relationship' && field.hasMany !== true && dataCopy[field.name]?.relationTo) {
const relatedCollection = payload.config.collections.find((collection) => collection.slug === dataCopy[field.name].relationTo);
const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id');
if (relationshipIDField?.type === 'number') {
dataCopy[field.name] = { ...dataCopy[field.name], value: parseFloat(dataCopy[field.name].value as string) };
}
}
} else {
if (Array.isArray(dataCopy[field.name])) {
dataCopy[field.name].forEach((relatedDoc: unknown, i) => {
const relatedCollection = payload.config.collections.find((collection) => collection.slug === field.relationTo);
const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id');
if (relationshipIDField?.type === 'number') {
dataCopy[field.name][i] = parseFloat(relatedDoc as string);
}
});
}
if (field.type === 'relationship' && field.hasMany !== true && dataCopy[field.name]) {
const relatedCollection = payload.config.collections.find((collection) => collection.slug === field.relationTo);
const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id');
if (relationshipIDField?.type === 'number') {
dataCopy[field.name] = parseFloat(dataCopy[field.name]);
}
}
}
}
if (field.type === 'point' && data[field.name]) {
transformActions.push(() => {
if (Array.isArray(data[field.name]) && data[field.name][0] !== null && data[field.name][1] !== null) {
data[field.name] = {
type: 'Point',
coordinates: [
parseFloat(data[field.name][0]),
parseFloat(data[field.name][1]),
],
};
}
});
}
if (field.type === 'array' || field.type === 'blocks') {
const hasRowsOfNewData = Array.isArray(data[field.name]);
const newRowCount = hasRowsOfNewData ? (data[field.name] as Record<string, unknown>[]).length : undefined;
// Handle cases of arrays being intentionally set to 0
if (data[field.name] === '0' || data[field.name] === 0 || data[field.name] === null) {
updatedData[field.name] = [];
}
const hasRowsOfExistingData = Array.isArray(originalDoc[field.name]);
const existingRowCount = hasRowsOfExistingData ? originalDoc[field.name].length : 0;
validationPromises.push(() => validationPromise({
errors,
hook,
data: { [field.name]: newRowCount },
fullData,
originalDoc: { [field.name]: existingRowCount },
fullOriginalDoc,
field,
path,
skipValidation: skipValidationFromHere,
payload: req.payload,
user: req.user,
operation,
id,
}));
} else if (fieldAffectsData(field)) {
validationPromises.push(() => validationPromise({
errors,
hook,
data,
fullData,
originalDoc,
fullOriginalDoc,
field,
path,
skipValidation: skipValidationFromHere,
user: req.user,
operation,
id,
payload: req.payload,
}));
}
}
});
};
export default traverseFields;

View File

@@ -1,67 +0,0 @@
import merge from 'deepmerge';
import { Payload } from '..';
import { User } from '../auth';
import { Operation } from '../types';
import { HookName, FieldAffectingData } from './config/types';
type Arguments = {
hook: HookName
field: FieldAffectingData
path: string
errors: {message: string, field: string}[]
data: Record<string, unknown>
fullData: Record<string, unknown>
originalDoc: Record<string, unknown>
fullOriginalDoc: Record<string, unknown>
id?: string | number
skipValidation?: boolean
user: User
operation: Operation
payload: Payload
}
const validationPromise = async ({
errors,
hook,
originalDoc,
fullOriginalDoc,
data,
fullData,
id,
field,
path,
skipValidation,
user,
operation,
payload,
}: Arguments): Promise<string | boolean> => {
if (hook !== 'beforeChange' || skipValidation) return true;
const hasCondition = field.admin && field.admin.condition;
const shouldValidate = field.validate && !hasCondition;
let valueToValidate = data?.[field.name];
if (valueToValidate === undefined) valueToValidate = originalDoc?.[field.name];
if (valueToValidate === undefined) valueToValidate = field.defaultValue;
const result = shouldValidate ? await field.validate(valueToValidate, {
...field,
data: merge(fullOriginalDoc, fullData),
siblingData: merge(originalDoc, data),
id,
operation,
user,
payload,
}) : true;
if (typeof result === 'string') {
errors.push({
message: result,
field: `${path}${field.name}`,
});
}
return result;
};
export default validationPromise;