Compare commits

...

12 Commits

Author SHA1 Message Date
Alessio Gravili
504ecd1c43 add more tsconfig paths for madge to find everything 2024-11-19 13:52:02 -07:00
Alessio Gravili
c98d392c73 fix remaining circular dependencies 2024-11-19 13:30:50 -07:00
Alessio Gravili
949486607b fix graphql circular dependencies 2024-11-19 13:13:04 -07:00
Alessio Gravili
6bc743b9a7 fix remaining circular dependencies 2024-11-19 13:04:45 -07:00
Alessio Gravili
54269f2dba fix mongodb circular dependencies and slate build 2024-11-19 12:54:13 -07:00
Alessio Gravili
49f91a45f3 add package.json scripts to analyze dependencies 2024-11-19 12:51:38 -07:00
Alessio Gravili
99623838cd fix slate circular dependencies 2024-11-19 12:47:11 -07:00
Alessio Gravili
a94d98dd92 fix all payload circular imports 2024-11-19 12:41:54 -07:00
Alessio Gravili
d003f2b800 field hooks 2024-11-19 12:22:45 -07:00
Alessio Gravili
ba643f8b7d progress 2024-11-19 11:59:07 -07:00
Alessio Gravili
5e1c7b511d commit .madgerc 2024-11-19 11:57:38 -07:00
Alessio Gravili
d948f4b819 fix circular type import in translations 2024-11-19 11:08:10 -07:00
67 changed files with 4831 additions and 4903 deletions

17
.madgerc Normal file
View File

@@ -0,0 +1,17 @@
{
"detectiveOptions": {
"ts": {
"skipTypeImports": true
}
},
"dependencyFilter": false,
"includeNpm": true,
"tsConfig": "/Users/alessio/Documents/GitHub/payload20/tsconfig.json",
"fileExtensions": [
"ts",
"tsx",
"js",
"jsx"
],
"baseDir": "/Users/alessio/Documents/GitHub/payload20"
}

View File

@@ -4,6 +4,18 @@
"private": true,
"type": "module",
"scripts": {
"analyze-circular-dependencies": "madge test/dev.ts --circular --warning",
"analyze-circular-dependencies-fields": "madge test/fields/config.ts --circular --warning",
"analyze-circular-dependencies-lexical": "madge packages/richtext-lexical/src/index.ts --circular --warning",
"analyze-circular-dependencies-lexical-client": "madge packages/richtext-lexical/src/exports/client/index.ts --circular --warning",
"analyze-circular-dependencies-lexical-migrate": "madge packages/richtext-lexical/src/exports/server/migrate.ts --circular --warning",
"analyze-circular-dependencies-mongodb": "madge packages/db-mongodb/src/index.ts --circular --warning",
"analyze-circular-dependencies-postgres": "madge packages/db-postgres/src/index.ts --circular --warning",
"analyze-circular-dependencies-drizzle": "madge packages/drizzle/src/index.ts --circular --warning",
"analyze-circular-dependencies-drizzle-postgres": "madge packages/drizzle/src/exports/postgres.ts --circular --warning",
"analyze-circular-dependencies-graphql": "madge packages/grapqhl/src/index.ts --circular --warning",
"analyze-circular-dependencies-fieldSchemasToFormState": "madge packages/ui/src/forms/fieldSchemasToFormState/index.tsx --circular --warning",
"analyze-circular-dependencies-buildFieldSchemaMap": "madge packages/ui/src/utilities/buildFieldSchemaMap/index.ts --circular --warning",
"bf": "pnpm run build:force",
"build": "pnpm run build:core",
"build:all": "turbo build",

View File

@@ -1,41 +0,0 @@
import type { Field, Payload, Where } from 'payload'
import { parseParams } from './parseParams.js'
export async function buildAndOrConditions({
collectionSlug,
fields,
globalSlug,
locale,
payload,
where,
}: {
collectionSlug?: string
fields: Field[]
globalSlug?: string
locale?: string
payload: Payload
where: Where[]
}): Promise<Record<string, unknown>[]> {
const completedConditions = []
// Loop over all AND / OR operations and add them to the AND / OR query param
// Operations should come through as an array
for (const condition of where) {
// If the operation is properly formatted as an object
if (typeof condition === 'object') {
const result = await parseParams({
collectionSlug,
fields,
globalSlug,
locale,
payload,
where: condition,
})
if (Object.keys(result).length > 0) {
completedConditions.push(result)
}
}
}
return completedConditions
}

View File

@@ -4,7 +4,6 @@ import type { Field, Operator, Payload, Where } from 'payload'
import { deepMergeWithCombinedArrays } from 'payload'
import { validOperators } from 'payload/shared'
import { buildAndOrConditions } from './buildAndOrConditions.js'
import { buildSearchParam } from './buildSearchParams.js'
export async function parseParams({
@@ -85,3 +84,41 @@ export async function parseParams({
return result
}
export async function buildAndOrConditions({
collectionSlug,
fields,
globalSlug,
locale,
payload,
where,
}: {
collectionSlug?: string
fields: Field[]
globalSlug?: string
locale?: string
payload: Payload
where: Where[]
}): Promise<Record<string, unknown>[]> {
const completedConditions = []
// Loop over all AND / OR operations and add them to the AND / OR query param
// Operations should come through as an array
for (const condition of where) {
// If the operation is properly formatted as an object
if (typeof condition === 'object') {
const result = await parseParams({
collectionSlug,
fields,
globalSlug,
locale,
payload,
where: condition,
})
if (Object.keys(result).length > 0) {
completedConditions.push(result)
}
}
}
return completedConditions
}

View File

@@ -5,19 +5,27 @@ import type {
PgColumnBuilder,
PgTableWithColumns,
} from 'drizzle-orm/pg-core'
import type { Field, SanitizedJoins } from 'payload'
import type { Field, TabAsField } from 'payload'
import { relations } from 'drizzle-orm'
import {
boolean,
foreignKey,
index,
integer,
jsonb,
numeric,
PgNumericBuilder,
PgUUIDBuilder,
PgVarcharBuilder,
serial,
text,
timestamp,
unique,
varchar,
} from 'drizzle-orm/pg-core'
import { InvalidConfiguration } from 'payload'
import { fieldAffectsData, fieldIsVirtual, optionIsObject } from 'payload/shared'
import toSnakeCase from 'to-snake-case'
import type {
@@ -31,10 +39,14 @@ import type {
import { createTableName } from '../../createTableName.js'
import { buildIndexName } from '../../utilities/buildIndexName.js'
import { hasLocalesTable } from '../../utilities/hasLocalesTable.js'
import { validateExistingBlockIsIdentical } from '../../utilities/validateExistingBlockIsIdentical.js'
import { createIndex } from './createIndex.js'
import { geometryColumn } from './geometryColumn.js'
import { idToUUID } from './idToUUID.js'
import { parentIDColumnMap } from './parentIDColumnMap.js'
import { setColumnID } from './setColumnID.js'
import { traverseFields } from './traverseFields.js'
import { withDefault } from './withDefault.js'
type Args = {
adapter: BasePostgresAdapter
@@ -520,3 +532,925 @@ export const buildTable = ({
relationsToBuild,
}
}
type TraverseFieldsArgs = {
adapter: BasePostgresAdapter
columnPrefix?: string
columns: Record<string, PgColumnBuilder>
disableNotNull: boolean
disableRelsTableUnique?: boolean
disableUnique?: boolean
fieldPrefix?: string
fields: (Field | TabAsField)[]
forceLocalized?: boolean
indexes: Record<string, (cols: GenericColumns) => IndexBuilder>
localesColumns: Record<string, PgColumnBuilder>
localesIndexes: Record<string, (cols: GenericColumns) => IndexBuilder>
newTableName: string
parentTableName: string
relationships: Set<string>
relationsToBuild: RelationMap
rootRelationsToBuild?: RelationMap
rootTableIDColType: string
rootTableName: string
uniqueRelationships: Set<string>
versions: boolean
/**
* Tracks whether or not this table is built
* from the result of a localized array or block field at some point
*/
withinLocalizedArrayOrBlock?: boolean
}
type TraverseFieldsResult = {
hasLocalizedField: boolean
hasLocalizedManyNumberField: boolean
hasLocalizedManyTextField: boolean
hasLocalizedRelationshipField: boolean
hasManyNumberField: 'index' | boolean
hasManyTextField: 'index' | boolean
}
export const traverseFields = ({
adapter,
columnPrefix,
columns,
disableNotNull,
disableRelsTableUnique,
disableUnique = false,
fieldPrefix,
fields,
forceLocalized,
indexes,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
}: TraverseFieldsArgs): TraverseFieldsResult => {
const throwValidationError = true
let hasLocalizedField = false
let hasLocalizedRelationshipField = false
let hasManyTextField: 'index' | boolean = false
let hasLocalizedManyTextField = false
let hasManyNumberField: 'index' | boolean = false
let hasLocalizedManyNumberField = false
let parentIDColType: IDType = 'integer'
if (columns.id instanceof PgUUIDBuilder) {
parentIDColType = 'uuid'
}
if (columns.id instanceof PgNumericBuilder) {
parentIDColType = 'numeric'
}
if (columns.id instanceof PgVarcharBuilder) {
parentIDColType = 'varchar'
}
fields.forEach((field) => {
if ('name' in field && field.name === 'id') {
return
}
if (fieldIsVirtual(field)) {
return
}
let columnName: string
let fieldName: string
let targetTable = columns
let targetIndexes = indexes
if (fieldAffectsData(field)) {
columnName = `${columnPrefix || ''}${field.name[0] === '_' ? '_' : ''}${toSnakeCase(
field.name,
)}`
fieldName = `${fieldPrefix?.replace('.', '_') || ''}${field.name}`
// If field is localized,
// add the column to the locale table instead of main table
if (
adapter.payload.config.localization &&
(field.localized || forceLocalized) &&
field.type !== 'array' &&
field.type !== 'blocks' &&
(('hasMany' in field && field.hasMany !== true) || !('hasMany' in field))
) {
hasLocalizedField = true
targetTable = localesColumns
targetIndexes = localesIndexes
}
if (
(field.unique || field.index || ['relationship', 'upload'].includes(field.type)) &&
!['array', 'blocks', 'group'].includes(field.type) &&
!('hasMany' in field && field.hasMany === true) &&
!('relationTo' in field && Array.isArray(field.relationTo))
) {
const unique = disableUnique !== true && field.unique
if (unique) {
const constraintValue = `${fieldPrefix || ''}${field.name}`
if (!adapter.fieldConstraints?.[rootTableName]) {
adapter.fieldConstraints[rootTableName] = {}
}
adapter.fieldConstraints[rootTableName][`${columnName}_idx`] = constraintValue
}
const indexName = buildIndexName({ name: `${newTableName}_${columnName}`, adapter })
targetIndexes[indexName] = createIndex({
name: field.localized ? [fieldName, '_locale'] : fieldName,
indexName,
unique,
})
}
}
switch (field.type) {
case 'array': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const arrayTableName = createTableName({
adapter,
config: field,
parentTableName: newTableName,
prefix: `${newTableName}_`,
throwValidationError,
versionsCustomName: versions,
})
const baseColumns: Record<string, PgColumnBuilder> = {
_order: integer('_order').notNull(),
_parentID: parentIDColumnMap[parentIDColType]('_parent_id').notNull(),
}
const baseExtraConfig: BaseExtraConfig = {
_orderIdx: (cols) => index(`${arrayTableName}_order_idx`).on(cols._order),
_parentIDFk: (cols) =>
foreignKey({
name: `${arrayTableName}_parent_id_fk`,
columns: [cols['_parentID']],
foreignColumns: [adapter.tables[parentTableName].id],
}).onDelete('cascade'),
_parentIDIdx: (cols) => index(`${arrayTableName}_parent_id_idx`).on(cols._parentID),
}
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
baseColumns._locale = adapter.enums.enum__locales('_locale').notNull()
baseExtraConfig._localeIdx = (cols) =>
index(`${arrayTableName}_locale_idx`).on(cols._locale)
}
const {
hasLocalizedManyNumberField: subHasLocalizedManyNumberField,
hasLocalizedManyTextField: subHasLocalizedManyTextField,
hasLocalizedRelationshipField: subHasLocalizedRelationshipField,
hasManyNumberField: subHasManyNumberField,
hasManyTextField: subHasManyTextField,
relationsToBuild: subRelationsToBuild,
} = buildTable({
adapter,
baseColumns,
baseExtraConfig,
disableNotNull: disableNotNullFromHere,
disableRelsTableUnique: true,
disableUnique,
fields: disableUnique ? idToUUID(field.fields) : field.fields,
rootRelationships: relationships,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
rootUniqueRelationships: uniqueRelationships,
tableName: arrayTableName,
versions,
withinLocalizedArrayOrBlock: isLocalized,
})
if (subHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = subHasLocalizedManyNumberField
}
if (subHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = subHasLocalizedRelationshipField
}
if (subHasLocalizedManyTextField) {
hasLocalizedManyTextField = subHasLocalizedManyTextField
}
if (subHasManyTextField) {
if (!hasManyTextField || subHasManyTextField === 'index') {
hasManyTextField = subHasManyTextField
}
}
if (subHasManyNumberField) {
if (!hasManyNumberField || subHasManyNumberField === 'index') {
hasManyNumberField = subHasManyNumberField
}
}
relationsToBuild.set(fieldName, {
type: 'many',
// arrays have their own localized table, independent of the base table.
localized: false,
target: arrayTableName,
})
adapter.relations[`relations_${arrayTableName}`] = relations(
adapter.tables[arrayTableName],
({ many, one }) => {
const result: Record<string, Relation<string>> = {
_parentID: one(adapter.tables[parentTableName], {
fields: [adapter.tables[arrayTableName]._parentID],
references: [adapter.tables[parentTableName].id],
relationName: fieldName,
}),
}
if (hasLocalesTable(field.fields)) {
result._locales = many(adapter.tables[`${arrayTableName}${adapter.localesSuffix}`], {
relationName: '_locales',
})
}
subRelationsToBuild.forEach(({ type, localized, target }, key) => {
if (type === 'one') {
const arrayWithLocalized = localized
? `${arrayTableName}${adapter.localesSuffix}`
: arrayTableName
result[key] = one(adapter.tables[target], {
fields: [adapter.tables[arrayWithLocalized][key]],
references: [adapter.tables[target].id],
relationName: key,
})
}
if (type === 'many') {
result[key] = many(adapter.tables[target], { relationName: key })
}
})
return result
},
)
break
}
case 'blocks': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
field.blocks.forEach((block) => {
const blockTableName = createTableName({
adapter,
config: block,
parentTableName: rootTableName,
prefix: `${rootTableName}_blocks_`,
throwValidationError,
versionsCustomName: versions,
})
if (!adapter.tables[blockTableName]) {
const baseColumns: Record<string, PgColumnBuilder> = {
_order: integer('_order').notNull(),
_parentID: parentIDColumnMap[rootTableIDColType]('_parent_id').notNull(),
_path: text('_path').notNull(),
}
const baseExtraConfig: BaseExtraConfig = {
_orderIdx: (cols) => index(`${blockTableName}_order_idx`).on(cols._order),
_parentIdFk: (cols) =>
foreignKey({
name: `${blockTableName}_parent_id_fk`,
columns: [cols._parentID],
foreignColumns: [adapter.tables[rootTableName].id],
}).onDelete('cascade'),
_parentIDIdx: (cols) => index(`${blockTableName}_parent_id_idx`).on(cols._parentID),
_pathIdx: (cols) => index(`${blockTableName}_path_idx`).on(cols._path),
}
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
baseColumns._locale = adapter.enums.enum__locales('_locale').notNull()
baseExtraConfig._localeIdx = (cols) =>
index(`${blockTableName}_locale_idx`).on(cols._locale)
}
const {
hasLocalizedManyNumberField: subHasLocalizedManyNumberField,
hasLocalizedManyTextField: subHasLocalizedManyTextField,
hasLocalizedRelationshipField: subHasLocalizedRelationshipField,
hasManyNumberField: subHasManyNumberField,
hasManyTextField: subHasManyTextField,
relationsToBuild: subRelationsToBuild,
} = buildTable({
adapter,
baseColumns,
baseExtraConfig,
disableNotNull: disableNotNullFromHere,
disableRelsTableUnique: true,
disableUnique,
fields: disableUnique ? idToUUID(block.fields) : block.fields,
rootRelationships: relationships,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
rootUniqueRelationships: uniqueRelationships,
tableName: blockTableName,
versions,
withinLocalizedArrayOrBlock: isLocalized,
})
if (subHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = subHasLocalizedManyNumberField
}
if (subHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = subHasLocalizedRelationshipField
}
if (subHasLocalizedManyTextField) {
hasLocalizedManyTextField = subHasLocalizedManyTextField
}
if (subHasManyTextField) {
if (!hasManyTextField || subHasManyTextField === 'index') {
hasManyTextField = subHasManyTextField
}
}
if (subHasManyNumberField) {
if (!hasManyNumberField || subHasManyNumberField === 'index') {
hasManyNumberField = subHasManyNumberField
}
}
adapter.relations[`relations_${blockTableName}`] = relations(
adapter.tables[blockTableName],
({ many, one }) => {
const result: Record<string, Relation<string>> = {
_parentID: one(adapter.tables[rootTableName], {
fields: [adapter.tables[blockTableName]._parentID],
references: [adapter.tables[rootTableName].id],
relationName: `_blocks_${block.slug}`,
}),
}
if (hasLocalesTable(block.fields)) {
result._locales = many(
adapter.tables[`${blockTableName}${adapter.localesSuffix}`],
{ relationName: '_locales' },
)
}
subRelationsToBuild.forEach(({ type, localized, target }, key) => {
if (type === 'one') {
const blockWithLocalized = localized
? `${blockTableName}${adapter.localesSuffix}`
: blockTableName
result[key] = one(adapter.tables[target], {
fields: [adapter.tables[blockWithLocalized][key]],
references: [adapter.tables[target].id],
relationName: key,
})
}
if (type === 'many') {
result[key] = many(adapter.tables[target], { relationName: key })
}
})
return result
},
)
} else if (process.env.NODE_ENV !== 'production' && !versions) {
validateExistingBlockIsIdentical({
block,
localized: field.localized,
rootTableName,
table: adapter.tables[blockTableName],
tableLocales: adapter.tables[`${blockTableName}${adapter.localesSuffix}`],
})
}
// blocks relationships are defined from the collection or globals table down to the block, bypassing any subBlocks
rootRelationsToBuild.set(`_blocks_${block.slug}`, {
type: 'many',
// blocks are not localized on the parent table
localized: false,
target: blockTableName,
})
})
break
}
case 'checkbox': {
targetTable[fieldName] = withDefault(boolean(columnName), field)
break
}
case 'code':
case 'email':
case 'textarea': {
targetTable[fieldName] = withDefault(varchar(columnName), field)
break
}
case 'collapsible':
case 'row': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const {
hasLocalizedField: rowHasLocalizedField,
hasLocalizedManyNumberField: rowHasLocalizedManyNumberField,
hasLocalizedManyTextField: rowHasLocalizedManyTextField,
hasLocalizedRelationshipField: rowHasLocalizedRelationshipField,
hasManyNumberField: rowHasManyNumberField,
hasManyTextField: rowHasManyTextField,
} = traverseFields({
adapter,
columnPrefix,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix,
fields: field.fields,
forceLocalized,
indexes,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
})
if (rowHasLocalizedField) {
hasLocalizedField = true
}
if (rowHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (rowHasManyTextField) {
hasManyTextField = true
}
if (rowHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (rowHasManyNumberField) {
hasManyNumberField = true
}
if (rowHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
}
break
}
case 'date': {
targetTable[fieldName] = withDefault(
timestamp(columnName, {
mode: 'string',
precision: 3,
withTimezone: true,
}),
field,
)
break
}
case 'group':
case 'tab': {
if (!('name' in field)) {
const {
hasLocalizedField: groupHasLocalizedField,
hasLocalizedManyNumberField: groupHasLocalizedManyNumberField,
hasLocalizedManyTextField: groupHasLocalizedManyTextField,
hasLocalizedRelationshipField: groupHasLocalizedRelationshipField,
hasManyNumberField: groupHasManyNumberField,
hasManyTextField: groupHasManyTextField,
} = traverseFields({
adapter,
columnPrefix,
columns,
disableNotNull,
disableUnique,
fieldPrefix,
fields: field.fields,
forceLocalized,
indexes,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
})
if (groupHasLocalizedField) {
hasLocalizedField = true
}
if (groupHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (groupHasManyTextField) {
hasManyTextField = true
}
if (groupHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (groupHasManyNumberField) {
hasManyNumberField = true
}
if (groupHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
}
break
}
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const {
hasLocalizedField: groupHasLocalizedField,
hasLocalizedManyNumberField: groupHasLocalizedManyNumberField,
hasLocalizedManyTextField: groupHasLocalizedManyTextField,
hasLocalizedRelationshipField: groupHasLocalizedRelationshipField,
hasManyNumberField: groupHasManyNumberField,
hasManyTextField: groupHasManyTextField,
} = traverseFields({
adapter,
columnPrefix: `${columnName}_`,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix: `${fieldName}.`,
fields: field.fields,
forceLocalized: field.localized,
indexes,
localesColumns,
localesIndexes,
newTableName: `${parentTableName}_${columnName}`,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock: withinLocalizedArrayOrBlock || field.localized,
})
if (groupHasLocalizedField) {
hasLocalizedField = true
}
if (groupHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (groupHasManyTextField) {
hasManyTextField = true
}
if (groupHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (groupHasManyNumberField) {
hasManyNumberField = true
}
if (groupHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
}
break
}
case 'json':
case 'richText': {
targetTable[fieldName] = withDefault(jsonb(columnName), field)
break
}
case 'number': {
if (field.hasMany) {
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
hasLocalizedManyNumberField = true
}
if (field.index) {
hasManyNumberField = 'index'
} else if (!hasManyNumberField) {
hasManyNumberField = true
}
if (field.unique) {
throw new InvalidConfiguration(
'Unique is not supported in Postgres for hasMany number fields.',
)
}
} else {
targetTable[fieldName] = withDefault(numeric(columnName), field)
}
break
}
case 'point': {
targetTable[fieldName] = withDefault(geometryColumn(columnName), field)
if (!adapter.extensions.postgis) {
adapter.extensions.postgis = true
}
break
}
case 'radio':
case 'select': {
const enumName = createTableName({
adapter,
config: field,
parentTableName: newTableName,
prefix: `enum_${newTableName}_`,
target: 'enumName',
throwValidationError,
})
adapter.enums[enumName] = adapter.pgSchema.enum(
enumName,
field.options.map((option) => {
if (optionIsObject(option)) {
return option.value
}
return option
}) as [string, ...string[]],
)
if (field.type === 'select' && field.hasMany) {
const selectTableName = createTableName({
adapter,
config: field,
parentTableName: newTableName,
prefix: `${newTableName}_`,
throwValidationError,
versionsCustomName: versions,
})
const baseColumns: Record<string, PgColumnBuilder> = {
order: integer('order').notNull(),
parent: parentIDColumnMap[parentIDColType]('parent_id').notNull(),
value: adapter.enums[enumName]('value'),
}
const baseExtraConfig: BaseExtraConfig = {
orderIdx: (cols) => index(`${selectTableName}_order_idx`).on(cols.order),
parentFk: (cols) =>
foreignKey({
name: `${selectTableName}_parent_fk`,
columns: [cols.parent],
foreignColumns: [adapter.tables[parentTableName].id],
}).onDelete('cascade'),
parentIdx: (cols) => index(`${selectTableName}_parent_idx`).on(cols.parent),
}
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
baseColumns.locale = adapter.enums.enum__locales('locale').notNull()
baseExtraConfig.localeIdx = (cols) =>
index(`${selectTableName}_locale_idx`).on(cols.locale)
}
if (field.index) {
baseExtraConfig.value = (cols) => index(`${selectTableName}_value_idx`).on(cols.value)
}
buildTable({
adapter,
baseColumns,
baseExtraConfig,
disableNotNull,
disableUnique,
fields: [],
rootTableName,
tableName: selectTableName,
versions,
})
relationsToBuild.set(fieldName, {
type: 'many',
// selects have their own localized table, independent of the base table.
localized: false,
target: selectTableName,
})
adapter.relations[`relations_${selectTableName}`] = relations(
adapter.tables[selectTableName],
({ one }) => ({
parent: one(adapter.tables[parentTableName], {
fields: [adapter.tables[selectTableName].parent],
references: [adapter.tables[parentTableName].id],
relationName: fieldName,
}),
}),
)
} else {
targetTable[fieldName] = withDefault(adapter.enums[enumName](columnName), field)
}
break
}
case 'relationship':
case 'upload':
if (Array.isArray(field.relationTo)) {
field.relationTo.forEach((relation) => {
relationships.add(relation)
if (field.unique && !disableUnique && !disableRelsTableUnique) {
uniqueRelationships.add(relation)
}
})
} else if (field.hasMany) {
relationships.add(field.relationTo)
if (field.unique && !disableUnique && !disableRelsTableUnique) {
uniqueRelationships.add(field.relationTo)
}
} else {
// simple relationships get a column on the targetTable with a foreign key to the relationTo table
const relationshipConfig = adapter.payload.collections[field.relationTo].config
const tableName = adapter.tableNameMap.get(toSnakeCase(field.relationTo))
// get the id type of the related collection
let colType = adapter.idType === 'uuid' ? 'uuid' : 'integer'
const relatedCollectionCustomID = relationshipConfig.fields.find(
(field) => fieldAffectsData(field) && field.name === 'id',
)
if (relatedCollectionCustomID?.type === 'number') {
colType = 'numeric'
}
if (relatedCollectionCustomID?.type === 'text') {
colType = 'varchar'
}
// make the foreign key column for relationship using the correct id column type
targetTable[fieldName] = parentIDColumnMap[colType](`${columnName}_id`).references(
() => adapter.tables[tableName].id,
{ onDelete: 'set null' },
)
// add relationship to table
relationsToBuild.set(fieldName, {
type: 'one',
localized: adapter.payload.config.localization && (field.localized || forceLocalized),
target: tableName,
})
// add notNull when not required
if (!disableNotNull && field.required && !field.admin?.condition) {
targetTable[fieldName].notNull()
}
break
}
if (
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock
) {
hasLocalizedRelationshipField = true
}
break
case 'tabs': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const {
hasLocalizedField: tabHasLocalizedField,
hasLocalizedManyNumberField: tabHasLocalizedManyNumberField,
hasLocalizedManyTextField: tabHasLocalizedManyTextField,
hasLocalizedRelationshipField: tabHasLocalizedRelationshipField,
hasManyNumberField: tabHasManyNumberField,
hasManyTextField: tabHasManyTextField,
} = traverseFields({
adapter,
columnPrefix,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
forceLocalized,
indexes,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
})
if (tabHasLocalizedField) {
hasLocalizedField = true
}
if (tabHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (tabHasManyTextField) {
hasManyTextField = true
}
if (tabHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (tabHasManyNumberField) {
hasManyNumberField = true
}
if (tabHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
}
break
}
case 'text': {
if (field.hasMany) {
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
hasLocalizedManyTextField = true
}
if (field.index) {
hasManyTextField = 'index'
} else if (!hasManyTextField) {
hasManyTextField = true
}
if (field.unique) {
throw new InvalidConfiguration(
'Unique is not supported in Postgres for hasMany text fields.',
)
}
} else {
targetTable[fieldName] = withDefault(varchar(columnName), field)
}
break
}
default:
break
}
const condition = field.admin && field.admin.condition
if (
!disableNotNull &&
targetTable[fieldName] &&
'required' in field &&
field.required &&
!condition
) {
targetTable[fieldName].notNull()
}
})
return {
hasLocalizedField,
hasLocalizedManyNumberField,
hasLocalizedManyTextField,
hasLocalizedRelationshipField,
hasManyNumberField,
hasManyTextField,
}
}

View File

@@ -1,964 +0,0 @@
import type { Relation } from 'drizzle-orm'
import type { IndexBuilder, PgColumnBuilder } from 'drizzle-orm/pg-core'
import type { Field, SanitizedJoins, TabAsField } from 'payload'
import { relations } from 'drizzle-orm'
import {
boolean,
foreignKey,
geometry,
index,
integer,
jsonb,
numeric,
PgNumericBuilder,
PgUUIDBuilder,
PgVarcharBuilder,
text,
timestamp,
varchar,
} from 'drizzle-orm/pg-core'
import { InvalidConfiguration } from 'payload'
import { fieldAffectsData, fieldIsVirtual, optionIsObject } from 'payload/shared'
import toSnakeCase from 'to-snake-case'
import type {
BaseExtraConfig,
BasePostgresAdapter,
GenericColumns,
IDType,
RelationMap,
} from '../types.js'
import { createTableName } from '../../createTableName.js'
import { buildIndexName } from '../../utilities/buildIndexName.js'
import { hasLocalesTable } from '../../utilities/hasLocalesTable.js'
import { validateExistingBlockIsIdentical } from '../../utilities/validateExistingBlockIsIdentical.js'
import { buildTable } from './build.js'
import { createIndex } from './createIndex.js'
import { geometryColumn } from './geometryColumn.js'
import { idToUUID } from './idToUUID.js'
import { parentIDColumnMap } from './parentIDColumnMap.js'
import { withDefault } from './withDefault.js'
type Args = {
adapter: BasePostgresAdapter
columnPrefix?: string
columns: Record<string, PgColumnBuilder>
disableNotNull: boolean
disableRelsTableUnique?: boolean
disableUnique?: boolean
fieldPrefix?: string
fields: (Field | TabAsField)[]
forceLocalized?: boolean
indexes: Record<string, (cols: GenericColumns) => IndexBuilder>
localesColumns: Record<string, PgColumnBuilder>
localesIndexes: Record<string, (cols: GenericColumns) => IndexBuilder>
newTableName: string
parentTableName: string
relationships: Set<string>
relationsToBuild: RelationMap
rootRelationsToBuild?: RelationMap
rootTableIDColType: string
rootTableName: string
uniqueRelationships: Set<string>
versions: boolean
/**
* Tracks whether or not this table is built
* from the result of a localized array or block field at some point
*/
withinLocalizedArrayOrBlock?: boolean
}
type Result = {
hasLocalizedField: boolean
hasLocalizedManyNumberField: boolean
hasLocalizedManyTextField: boolean
hasLocalizedRelationshipField: boolean
hasManyNumberField: 'index' | boolean
hasManyTextField: 'index' | boolean
}
export const traverseFields = ({
adapter,
columnPrefix,
columns,
disableNotNull,
disableRelsTableUnique,
disableUnique = false,
fieldPrefix,
fields,
forceLocalized,
indexes,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
}: Args): Result => {
const throwValidationError = true
let hasLocalizedField = false
let hasLocalizedRelationshipField = false
let hasManyTextField: 'index' | boolean = false
let hasLocalizedManyTextField = false
let hasManyNumberField: 'index' | boolean = false
let hasLocalizedManyNumberField = false
let parentIDColType: IDType = 'integer'
if (columns.id instanceof PgUUIDBuilder) {
parentIDColType = 'uuid'
}
if (columns.id instanceof PgNumericBuilder) {
parentIDColType = 'numeric'
}
if (columns.id instanceof PgVarcharBuilder) {
parentIDColType = 'varchar'
}
fields.forEach((field) => {
if ('name' in field && field.name === 'id') {
return
}
if (fieldIsVirtual(field)) {
return
}
let columnName: string
let fieldName: string
let targetTable = columns
let targetIndexes = indexes
if (fieldAffectsData(field)) {
columnName = `${columnPrefix || ''}${field.name[0] === '_' ? '_' : ''}${toSnakeCase(
field.name,
)}`
fieldName = `${fieldPrefix?.replace('.', '_') || ''}${field.name}`
// If field is localized,
// add the column to the locale table instead of main table
if (
adapter.payload.config.localization &&
(field.localized || forceLocalized) &&
field.type !== 'array' &&
field.type !== 'blocks' &&
(('hasMany' in field && field.hasMany !== true) || !('hasMany' in field))
) {
hasLocalizedField = true
targetTable = localesColumns
targetIndexes = localesIndexes
}
if (
(field.unique || field.index || ['relationship', 'upload'].includes(field.type)) &&
!['array', 'blocks', 'group'].includes(field.type) &&
!('hasMany' in field && field.hasMany === true) &&
!('relationTo' in field && Array.isArray(field.relationTo))
) {
const unique = disableUnique !== true && field.unique
if (unique) {
const constraintValue = `${fieldPrefix || ''}${field.name}`
if (!adapter.fieldConstraints?.[rootTableName]) {
adapter.fieldConstraints[rootTableName] = {}
}
adapter.fieldConstraints[rootTableName][`${columnName}_idx`] = constraintValue
}
const indexName = buildIndexName({ name: `${newTableName}_${columnName}`, adapter })
targetIndexes[indexName] = createIndex({
name: field.localized ? [fieldName, '_locale'] : fieldName,
indexName,
unique,
})
}
}
switch (field.type) {
case 'array': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const arrayTableName = createTableName({
adapter,
config: field,
parentTableName: newTableName,
prefix: `${newTableName}_`,
throwValidationError,
versionsCustomName: versions,
})
const baseColumns: Record<string, PgColumnBuilder> = {
_order: integer('_order').notNull(),
_parentID: parentIDColumnMap[parentIDColType]('_parent_id').notNull(),
}
const baseExtraConfig: BaseExtraConfig = {
_orderIdx: (cols) => index(`${arrayTableName}_order_idx`).on(cols._order),
_parentIDFk: (cols) =>
foreignKey({
name: `${arrayTableName}_parent_id_fk`,
columns: [cols['_parentID']],
foreignColumns: [adapter.tables[parentTableName].id],
}).onDelete('cascade'),
_parentIDIdx: (cols) => index(`${arrayTableName}_parent_id_idx`).on(cols._parentID),
}
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
baseColumns._locale = adapter.enums.enum__locales('_locale').notNull()
baseExtraConfig._localeIdx = (cols) =>
index(`${arrayTableName}_locale_idx`).on(cols._locale)
}
const {
hasLocalizedManyNumberField: subHasLocalizedManyNumberField,
hasLocalizedManyTextField: subHasLocalizedManyTextField,
hasLocalizedRelationshipField: subHasLocalizedRelationshipField,
hasManyNumberField: subHasManyNumberField,
hasManyTextField: subHasManyTextField,
relationsToBuild: subRelationsToBuild,
} = buildTable({
adapter,
baseColumns,
baseExtraConfig,
disableNotNull: disableNotNullFromHere,
disableRelsTableUnique: true,
disableUnique,
fields: disableUnique ? idToUUID(field.fields) : field.fields,
rootRelationships: relationships,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
rootUniqueRelationships: uniqueRelationships,
tableName: arrayTableName,
versions,
withinLocalizedArrayOrBlock: isLocalized,
})
if (subHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = subHasLocalizedManyNumberField
}
if (subHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = subHasLocalizedRelationshipField
}
if (subHasLocalizedManyTextField) {
hasLocalizedManyTextField = subHasLocalizedManyTextField
}
if (subHasManyTextField) {
if (!hasManyTextField || subHasManyTextField === 'index') {
hasManyTextField = subHasManyTextField
}
}
if (subHasManyNumberField) {
if (!hasManyNumberField || subHasManyNumberField === 'index') {
hasManyNumberField = subHasManyNumberField
}
}
relationsToBuild.set(fieldName, {
type: 'many',
// arrays have their own localized table, independent of the base table.
localized: false,
target: arrayTableName,
})
adapter.relations[`relations_${arrayTableName}`] = relations(
adapter.tables[arrayTableName],
({ many, one }) => {
const result: Record<string, Relation<string>> = {
_parentID: one(adapter.tables[parentTableName], {
fields: [adapter.tables[arrayTableName]._parentID],
references: [adapter.tables[parentTableName].id],
relationName: fieldName,
}),
}
if (hasLocalesTable(field.fields)) {
result._locales = many(adapter.tables[`${arrayTableName}${adapter.localesSuffix}`], {
relationName: '_locales',
})
}
subRelationsToBuild.forEach(({ type, localized, target }, key) => {
if (type === 'one') {
const arrayWithLocalized = localized
? `${arrayTableName}${adapter.localesSuffix}`
: arrayTableName
result[key] = one(adapter.tables[target], {
fields: [adapter.tables[arrayWithLocalized][key]],
references: [adapter.tables[target].id],
relationName: key,
})
}
if (type === 'many') {
result[key] = many(adapter.tables[target], { relationName: key })
}
})
return result
},
)
break
}
case 'blocks': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
field.blocks.forEach((block) => {
const blockTableName = createTableName({
adapter,
config: block,
parentTableName: rootTableName,
prefix: `${rootTableName}_blocks_`,
throwValidationError,
versionsCustomName: versions,
})
if (!adapter.tables[blockTableName]) {
const baseColumns: Record<string, PgColumnBuilder> = {
_order: integer('_order').notNull(),
_parentID: parentIDColumnMap[rootTableIDColType]('_parent_id').notNull(),
_path: text('_path').notNull(),
}
const baseExtraConfig: BaseExtraConfig = {
_orderIdx: (cols) => index(`${blockTableName}_order_idx`).on(cols._order),
_parentIdFk: (cols) =>
foreignKey({
name: `${blockTableName}_parent_id_fk`,
columns: [cols._parentID],
foreignColumns: [adapter.tables[rootTableName].id],
}).onDelete('cascade'),
_parentIDIdx: (cols) => index(`${blockTableName}_parent_id_idx`).on(cols._parentID),
_pathIdx: (cols) => index(`${blockTableName}_path_idx`).on(cols._path),
}
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
baseColumns._locale = adapter.enums.enum__locales('_locale').notNull()
baseExtraConfig._localeIdx = (cols) =>
index(`${blockTableName}_locale_idx`).on(cols._locale)
}
const {
hasLocalizedManyNumberField: subHasLocalizedManyNumberField,
hasLocalizedManyTextField: subHasLocalizedManyTextField,
hasLocalizedRelationshipField: subHasLocalizedRelationshipField,
hasManyNumberField: subHasManyNumberField,
hasManyTextField: subHasManyTextField,
relationsToBuild: subRelationsToBuild,
} = buildTable({
adapter,
baseColumns,
baseExtraConfig,
disableNotNull: disableNotNullFromHere,
disableRelsTableUnique: true,
disableUnique,
fields: disableUnique ? idToUUID(block.fields) : block.fields,
rootRelationships: relationships,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
rootUniqueRelationships: uniqueRelationships,
tableName: blockTableName,
versions,
withinLocalizedArrayOrBlock: isLocalized,
})
if (subHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = subHasLocalizedManyNumberField
}
if (subHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = subHasLocalizedRelationshipField
}
if (subHasLocalizedManyTextField) {
hasLocalizedManyTextField = subHasLocalizedManyTextField
}
if (subHasManyTextField) {
if (!hasManyTextField || subHasManyTextField === 'index') {
hasManyTextField = subHasManyTextField
}
}
if (subHasManyNumberField) {
if (!hasManyNumberField || subHasManyNumberField === 'index') {
hasManyNumberField = subHasManyNumberField
}
}
adapter.relations[`relations_${blockTableName}`] = relations(
adapter.tables[blockTableName],
({ many, one }) => {
const result: Record<string, Relation<string>> = {
_parentID: one(adapter.tables[rootTableName], {
fields: [adapter.tables[blockTableName]._parentID],
references: [adapter.tables[rootTableName].id],
relationName: `_blocks_${block.slug}`,
}),
}
if (hasLocalesTable(block.fields)) {
result._locales = many(
adapter.tables[`${blockTableName}${adapter.localesSuffix}`],
{ relationName: '_locales' },
)
}
subRelationsToBuild.forEach(({ type, localized, target }, key) => {
if (type === 'one') {
const blockWithLocalized = localized
? `${blockTableName}${adapter.localesSuffix}`
: blockTableName
result[key] = one(adapter.tables[target], {
fields: [adapter.tables[blockWithLocalized][key]],
references: [adapter.tables[target].id],
relationName: key,
})
}
if (type === 'many') {
result[key] = many(adapter.tables[target], { relationName: key })
}
})
return result
},
)
} else if (process.env.NODE_ENV !== 'production' && !versions) {
validateExistingBlockIsIdentical({
block,
localized: field.localized,
rootTableName,
table: adapter.tables[blockTableName],
tableLocales: adapter.tables[`${blockTableName}${adapter.localesSuffix}`],
})
}
// blocks relationships are defined from the collection or globals table down to the block, bypassing any subBlocks
rootRelationsToBuild.set(`_blocks_${block.slug}`, {
type: 'many',
// blocks are not localized on the parent table
localized: false,
target: blockTableName,
})
})
break
}
case 'checkbox': {
targetTable[fieldName] = withDefault(boolean(columnName), field)
break
}
case 'code':
case 'email':
case 'textarea': {
targetTable[fieldName] = withDefault(varchar(columnName), field)
break
}
case 'collapsible':
case 'row': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const {
hasLocalizedField: rowHasLocalizedField,
hasLocalizedManyNumberField: rowHasLocalizedManyNumberField,
hasLocalizedManyTextField: rowHasLocalizedManyTextField,
hasLocalizedRelationshipField: rowHasLocalizedRelationshipField,
hasManyNumberField: rowHasManyNumberField,
hasManyTextField: rowHasManyTextField,
} = traverseFields({
adapter,
columnPrefix,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix,
fields: field.fields,
forceLocalized,
indexes,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
})
if (rowHasLocalizedField) {
hasLocalizedField = true
}
if (rowHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (rowHasManyTextField) {
hasManyTextField = true
}
if (rowHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (rowHasManyNumberField) {
hasManyNumberField = true
}
if (rowHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
}
break
}
case 'date': {
targetTable[fieldName] = withDefault(
timestamp(columnName, {
mode: 'string',
precision: 3,
withTimezone: true,
}),
field,
)
break
}
case 'group':
case 'tab': {
if (!('name' in field)) {
const {
hasLocalizedField: groupHasLocalizedField,
hasLocalizedManyNumberField: groupHasLocalizedManyNumberField,
hasLocalizedManyTextField: groupHasLocalizedManyTextField,
hasLocalizedRelationshipField: groupHasLocalizedRelationshipField,
hasManyNumberField: groupHasManyNumberField,
hasManyTextField: groupHasManyTextField,
} = traverseFields({
adapter,
columnPrefix,
columns,
disableNotNull,
disableUnique,
fieldPrefix,
fields: field.fields,
forceLocalized,
indexes,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
})
if (groupHasLocalizedField) {
hasLocalizedField = true
}
if (groupHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (groupHasManyTextField) {
hasManyTextField = true
}
if (groupHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (groupHasManyNumberField) {
hasManyNumberField = true
}
if (groupHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
}
break
}
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const {
hasLocalizedField: groupHasLocalizedField,
hasLocalizedManyNumberField: groupHasLocalizedManyNumberField,
hasLocalizedManyTextField: groupHasLocalizedManyTextField,
hasLocalizedRelationshipField: groupHasLocalizedRelationshipField,
hasManyNumberField: groupHasManyNumberField,
hasManyTextField: groupHasManyTextField,
} = traverseFields({
adapter,
columnPrefix: `${columnName}_`,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix: `${fieldName}.`,
fields: field.fields,
forceLocalized: field.localized,
indexes,
localesColumns,
localesIndexes,
newTableName: `${parentTableName}_${columnName}`,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock: withinLocalizedArrayOrBlock || field.localized,
})
if (groupHasLocalizedField) {
hasLocalizedField = true
}
if (groupHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (groupHasManyTextField) {
hasManyTextField = true
}
if (groupHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (groupHasManyNumberField) {
hasManyNumberField = true
}
if (groupHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
}
break
}
case 'json':
case 'richText': {
targetTable[fieldName] = withDefault(jsonb(columnName), field)
break
}
case 'number': {
if (field.hasMany) {
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
hasLocalizedManyNumberField = true
}
if (field.index) {
hasManyNumberField = 'index'
} else if (!hasManyNumberField) {
hasManyNumberField = true
}
if (field.unique) {
throw new InvalidConfiguration(
'Unique is not supported in Postgres for hasMany number fields.',
)
}
} else {
targetTable[fieldName] = withDefault(numeric(columnName), field)
}
break
}
case 'point': {
targetTable[fieldName] = withDefault(geometryColumn(columnName), field)
if (!adapter.extensions.postgis) {
adapter.extensions.postgis = true
}
break
}
case 'radio':
case 'select': {
const enumName = createTableName({
adapter,
config: field,
parentTableName: newTableName,
prefix: `enum_${newTableName}_`,
target: 'enumName',
throwValidationError,
})
adapter.enums[enumName] = adapter.pgSchema.enum(
enumName,
field.options.map((option) => {
if (optionIsObject(option)) {
return option.value
}
return option
}) as [string, ...string[]],
)
if (field.type === 'select' && field.hasMany) {
const selectTableName = createTableName({
adapter,
config: field,
parentTableName: newTableName,
prefix: `${newTableName}_`,
throwValidationError,
versionsCustomName: versions,
})
const baseColumns: Record<string, PgColumnBuilder> = {
order: integer('order').notNull(),
parent: parentIDColumnMap[parentIDColType]('parent_id').notNull(),
value: adapter.enums[enumName]('value'),
}
const baseExtraConfig: BaseExtraConfig = {
orderIdx: (cols) => index(`${selectTableName}_order_idx`).on(cols.order),
parentFk: (cols) =>
foreignKey({
name: `${selectTableName}_parent_fk`,
columns: [cols.parent],
foreignColumns: [adapter.tables[parentTableName].id],
}).onDelete('cascade'),
parentIdx: (cols) => index(`${selectTableName}_parent_idx`).on(cols.parent),
}
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
baseColumns.locale = adapter.enums.enum__locales('locale').notNull()
baseExtraConfig.localeIdx = (cols) =>
index(`${selectTableName}_locale_idx`).on(cols.locale)
}
if (field.index) {
baseExtraConfig.value = (cols) => index(`${selectTableName}_value_idx`).on(cols.value)
}
buildTable({
adapter,
baseColumns,
baseExtraConfig,
disableNotNull,
disableUnique,
fields: [],
rootTableName,
tableName: selectTableName,
versions,
})
relationsToBuild.set(fieldName, {
type: 'many',
// selects have their own localized table, independent of the base table.
localized: false,
target: selectTableName,
})
adapter.relations[`relations_${selectTableName}`] = relations(
adapter.tables[selectTableName],
({ one }) => ({
parent: one(adapter.tables[parentTableName], {
fields: [adapter.tables[selectTableName].parent],
references: [adapter.tables[parentTableName].id],
relationName: fieldName,
}),
}),
)
} else {
targetTable[fieldName] = withDefault(adapter.enums[enumName](columnName), field)
}
break
}
case 'relationship':
case 'upload':
if (Array.isArray(field.relationTo)) {
field.relationTo.forEach((relation) => {
relationships.add(relation)
if (field.unique && !disableUnique && !disableRelsTableUnique) {
uniqueRelationships.add(relation)
}
})
} else if (field.hasMany) {
relationships.add(field.relationTo)
if (field.unique && !disableUnique && !disableRelsTableUnique) {
uniqueRelationships.add(field.relationTo)
}
} else {
// simple relationships get a column on the targetTable with a foreign key to the relationTo table
const relationshipConfig = adapter.payload.collections[field.relationTo].config
const tableName = adapter.tableNameMap.get(toSnakeCase(field.relationTo))
// get the id type of the related collection
let colType = adapter.idType === 'uuid' ? 'uuid' : 'integer'
const relatedCollectionCustomID = relationshipConfig.fields.find(
(field) => fieldAffectsData(field) && field.name === 'id',
)
if (relatedCollectionCustomID?.type === 'number') {
colType = 'numeric'
}
if (relatedCollectionCustomID?.type === 'text') {
colType = 'varchar'
}
// make the foreign key column for relationship using the correct id column type
targetTable[fieldName] = parentIDColumnMap[colType](`${columnName}_id`).references(
() => adapter.tables[tableName].id,
{ onDelete: 'set null' },
)
// add relationship to table
relationsToBuild.set(fieldName, {
type: 'one',
localized: adapter.payload.config.localization && (field.localized || forceLocalized),
target: tableName,
})
// add notNull when not required
if (!disableNotNull && field.required && !field.admin?.condition) {
targetTable[fieldName].notNull()
}
break
}
if (
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock
) {
hasLocalizedRelationshipField = true
}
break
case 'tabs': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
const {
hasLocalizedField: tabHasLocalizedField,
hasLocalizedManyNumberField: tabHasLocalizedManyNumberField,
hasLocalizedManyTextField: tabHasLocalizedManyTextField,
hasLocalizedRelationshipField: tabHasLocalizedRelationshipField,
hasManyNumberField: tabHasManyNumberField,
hasManyTextField: tabHasManyTextField,
} = traverseFields({
adapter,
columnPrefix,
columns,
disableNotNull: disableNotNullFromHere,
disableUnique,
fieldPrefix,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
forceLocalized,
indexes,
localesColumns,
localesIndexes,
newTableName,
parentTableName,
relationships,
relationsToBuild,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
uniqueRelationships,
versions,
withinLocalizedArrayOrBlock,
})
if (tabHasLocalizedField) {
hasLocalizedField = true
}
if (tabHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = true
}
if (tabHasManyTextField) {
hasManyTextField = true
}
if (tabHasLocalizedManyTextField) {
hasLocalizedManyTextField = true
}
if (tabHasManyNumberField) {
hasManyNumberField = true
}
if (tabHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = true
}
break
}
case 'text': {
if (field.hasMany) {
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
hasLocalizedManyTextField = true
}
if (field.index) {
hasManyTextField = 'index'
} else if (!hasManyTextField) {
hasManyTextField = true
}
if (field.unique) {
throw new InvalidConfiguration(
'Unique is not supported in Postgres for hasMany text fields.',
)
}
} else {
targetTable[fieldName] = withDefault(varchar(columnName), field)
}
break
}
default:
break
}
const condition = field.admin && field.admin.condition
if (
!disableNotNull &&
targetTable[fieldName] &&
'required' in field &&
field.required &&
!condition
) {
targetTable[fieldName].notNull()
}
})
return {
hasLocalizedField,
hasLocalizedManyNumberField,
hasLocalizedManyTextField,
hasLocalizedRelationshipField,
hasManyNumberField,
hasManyTextField,
}
}

View File

@@ -1,50 +0,0 @@
import type { SQL } from 'drizzle-orm'
import type { Field, Where } from 'payload'
import type { DrizzleAdapter, GenericColumn } from '../types.js'
import type { BuildQueryJoinAliases } from './buildQuery.js'
import { parseParams } from './parseParams.js'
export function buildAndOrConditions({
adapter,
fields,
joins,
locale,
selectFields,
tableName,
where,
}: {
adapter: DrizzleAdapter
collectionSlug?: string
fields: Field[]
globalSlug?: string
joins: BuildQueryJoinAliases
locale?: string
selectFields: Record<string, GenericColumn>
tableName: string
where: Where[]
}): SQL[] {
const completedConditions = []
// Loop over all AND / OR operations and add them to the AND / OR query param
// Operations should come through as an array
for (const condition of where) {
// If the operation is properly formatted as an object
if (typeof condition === 'object') {
const result = parseParams({
adapter,
fields,
joins,
locale,
selectFields,
tableName,
where: condition,
})
if (result && Object.keys(result).length > 0) {
completedConditions.push(result)
}
}
}
return completedConditions
}

View File

@@ -9,7 +9,6 @@ import { validOperators } from 'payload/shared'
import type { DrizzleAdapter, GenericColumn } from '../types.js'
import type { BuildQueryJoinAliases } from './buildQuery.js'
import { buildAndOrConditions } from './buildAndOrConditions.js'
import { getTableColumnFromPath } from './getTableColumnFromPath.js'
import { sanitizeQueryValue } from './sanitizeQueryValue.js'
@@ -349,3 +348,46 @@ export function parseParams({
return result
}
export function buildAndOrConditions({
adapter,
fields,
joins,
locale,
selectFields,
tableName,
where,
}: {
adapter: DrizzleAdapter
collectionSlug?: string
fields: Field[]
globalSlug?: string
joins: BuildQueryJoinAliases
locale?: string
selectFields: Record<string, GenericColumn>
tableName: string
where: Where[]
}): SQL[] {
const completedConditions = []
// Loop over all AND / OR operations and add them to the AND / OR query param
// Operations should come through as an array
for (const condition of where) {
// If the operation is properly formatted as an object
if (typeof condition === 'object') {
const result = parseParams({
adapter,
fields,
joins,
locale,
selectFields,
tableName,
where: condition,
})
if (result && Object.keys(result).length > 0) {
completedConditions.push(result)
}
}
}
return completedConditions
}

View File

@@ -1,117 +0,0 @@
import type { ArrayField } from 'payload'
import type { DrizzleAdapter } from '../../types.js'
import type { ArrayRowToInsert, BlockRowToInsert, RelationshipToDelete } from './types.js'
import { isArrayOfRows } from '../../utilities/isArrayOfRows.js'
import { traverseFields } from './traverseFields.js'
type Args = {
adapter: DrizzleAdapter
arrayTableName: string
baseTableName: string
blocks: {
[blockType: string]: BlockRowToInsert[]
}
blocksToDelete: Set<string>
data: unknown
field: ArrayField
locale?: string
numbers: Record<string, unknown>[]
path: string
relationships: Record<string, unknown>[]
relationshipsToDelete: RelationshipToDelete[]
selects: {
[tableName: string]: Record<string, unknown>[]
}
texts: Record<string, unknown>[]
/**
* Set to a locale code if this set of fields is traversed within a
* localized array or block field
*/
withinArrayOrBlockLocale?: string
}
export const transformArray = ({
adapter,
arrayTableName,
baseTableName,
blocks,
blocksToDelete,
data,
field,
locale,
numbers,
path,
relationships,
relationshipsToDelete,
selects,
texts,
withinArrayOrBlockLocale,
}: Args) => {
const newRows: ArrayRowToInsert[] = []
const hasUUID = adapter.tables[arrayTableName]._uuid
if (isArrayOfRows(data)) {
data.forEach((arrayRow, i) => {
const newRow: ArrayRowToInsert = {
arrays: {},
locales: {},
row: {
_order: i + 1,
},
}
// If we have declared a _uuid field on arrays,
// that means the ID has to be unique,
// and our ids within arrays are not unique.
// So move the ID to a uuid field for storage
// and allow the database to generate a serial id automatically
if (hasUUID) {
newRow.row._uuid = arrayRow.id
delete arrayRow.id
}
if (locale) {
newRow.locales[locale] = {
_locale: locale,
}
}
if (field.localized) {
newRow.row._locale = locale
}
if (withinArrayOrBlockLocale) {
newRow.row._locale = withinArrayOrBlockLocale
}
traverseFields({
adapter,
arrays: newRow.arrays,
baseTableName,
blocks,
blocksToDelete,
columnPrefix: '',
data: arrayRow,
fieldPrefix: '',
fields: field.fields,
locales: newRow.locales,
numbers,
parentTableName: arrayTableName,
path: `${path || ''}${field.name}.${i}.`,
relationships,
relationshipsToDelete,
row: newRow.row,
selects,
texts,
withinArrayOrBlockLocale,
})
newRows.push(newRow)
})
}
return newRows
}

View File

@@ -1,118 +0,0 @@
import type { BlocksField } from 'payload'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from '../../types.js'
import type { BlockRowToInsert, RelationshipToDelete } from './types.js'
import { traverseFields } from './traverseFields.js'
type Args = {
adapter: DrizzleAdapter
baseTableName: string
blocks: {
[blockType: string]: BlockRowToInsert[]
}
blocksToDelete: Set<string>
data: Record<string, unknown>[]
field: BlocksField
locale?: string
numbers: Record<string, unknown>[]
path: string
relationships: Record<string, unknown>[]
relationshipsToDelete: RelationshipToDelete[]
selects: {
[tableName: string]: Record<string, unknown>[]
}
texts: Record<string, unknown>[]
/**
* Set to a locale code if this set of fields is traversed within a
* localized array or block field
*/
withinArrayOrBlockLocale?: string
}
export const transformBlocks = ({
adapter,
baseTableName,
blocks,
blocksToDelete,
data,
field,
locale,
numbers,
path,
relationships,
relationshipsToDelete,
selects,
texts,
withinArrayOrBlockLocale,
}: Args) => {
data.forEach((blockRow, i) => {
if (typeof blockRow.blockType !== 'string') {
return
}
const matchedBlock = field.blocks.find(({ slug }) => slug === blockRow.blockType)
if (!matchedBlock) {
return
}
const blockType = toSnakeCase(blockRow.blockType)
if (!blocks[blockType]) {
blocks[blockType] = []
}
const newRow: BlockRowToInsert = {
arrays: {},
locales: {},
row: {
_order: i + 1,
_path: `${path}${field.name}`,
},
}
if (field.localized && locale) {
newRow.row._locale = locale
}
if (withinArrayOrBlockLocale) {
newRow.row._locale = withinArrayOrBlockLocale
}
const blockTableName = adapter.tableNameMap.get(`${baseTableName}_blocks_${blockType}`)
const hasUUID = adapter.tables[blockTableName]._uuid
// If we have declared a _uuid field on arrays,
// that means the ID has to be unique,
// and our ids within arrays are not unique.
// So move the ID to a uuid field for storage
// and allow the database to generate a serial id automatically
if (hasUUID) {
newRow.row._uuid = blockRow.id
delete blockRow.id
}
traverseFields({
adapter,
arrays: newRow.arrays,
baseTableName,
blocks,
blocksToDelete,
columnPrefix: '',
data: blockRow,
fieldPrefix: '',
fields: matchedBlock.fields,
locales: newRow.locales,
numbers,
parentTableName: blockTableName,
path: `${path || ''}${field.name}.${i}.`,
relationships,
relationshipsToDelete,
row: newRow.row,
selects,
texts,
withinArrayOrBlockLocale,
})
blocks[blockType].push(newRow)
})
}

View File

@@ -1,4 +1,4 @@
import type { Field } from 'payload'
import type { ArrayField, BlocksField, Field } from 'payload'
import { sql } from 'drizzle-orm'
import { fieldAffectsData, fieldIsVirtual } from 'payload/shared'
@@ -8,8 +8,6 @@ import type { DrizzleAdapter } from '../../types.js'
import type { ArrayRowToInsert, BlockRowToInsert, RelationshipToDelete } from './types.js'
import { isArrayOfRows } from '../../utilities/isArrayOfRows.js'
import { transformArray } from './array.js'
import { transformBlocks } from './blocks.js'
import { transformNumbers } from './numbers.js'
import { transformRelationship } from './relationships.js'
import { transformSelects } from './selects.js'
@@ -617,3 +615,226 @@ export const traverseFields = ({
}
})
}
type TransformArrayArgs = {
adapter: DrizzleAdapter
arrayTableName: string
baseTableName: string
blocks: {
[blockType: string]: BlockRowToInsert[]
}
blocksToDelete: Set<string>
data: unknown
field: ArrayField
locale?: string
numbers: Record<string, unknown>[]
path: string
relationships: Record<string, unknown>[]
relationshipsToDelete: RelationshipToDelete[]
selects: {
[tableName: string]: Record<string, unknown>[]
}
texts: Record<string, unknown>[]
/**
* Set to a locale code if this set of fields is traversed within a
* localized array or block field
*/
withinArrayOrBlockLocale?: string
}
// Can't be in a separate file to avoid circular import between traverseFields and transformArray
export const transformArray = ({
adapter,
arrayTableName,
baseTableName,
blocks,
blocksToDelete,
data,
field,
locale,
numbers,
path,
relationships,
relationshipsToDelete,
selects,
texts,
withinArrayOrBlockLocale,
}: TransformArrayArgs) => {
const newRows: ArrayRowToInsert[] = []
const hasUUID = adapter.tables[arrayTableName]._uuid
if (isArrayOfRows(data)) {
data.forEach((arrayRow, i) => {
const newRow: ArrayRowToInsert = {
arrays: {},
locales: {},
row: {
_order: i + 1,
},
}
// If we have declared a _uuid field on arrays,
// that means the ID has to be unique,
// and our ids within arrays are not unique.
// So move the ID to a uuid field for storage
// and allow the database to generate a serial id automatically
if (hasUUID) {
newRow.row._uuid = arrayRow.id
delete arrayRow.id
}
if (locale) {
newRow.locales[locale] = {
_locale: locale,
}
}
if (field.localized) {
newRow.row._locale = locale
}
if (withinArrayOrBlockLocale) {
newRow.row._locale = withinArrayOrBlockLocale
}
traverseFields({
adapter,
arrays: newRow.arrays,
baseTableName,
blocks,
blocksToDelete,
columnPrefix: '',
data: arrayRow,
fieldPrefix: '',
fields: field.fields,
locales: newRow.locales,
numbers,
parentTableName: arrayTableName,
path: `${path || ''}${field.name}.${i}.`,
relationships,
relationshipsToDelete,
row: newRow.row,
selects,
texts,
withinArrayOrBlockLocale,
})
newRows.push(newRow)
})
}
return newRows
}
type TransformBlocksArgs = {
adapter: DrizzleAdapter
baseTableName: string
blocks: {
[blockType: string]: BlockRowToInsert[]
}
blocksToDelete: Set<string>
data: Record<string, unknown>[]
field: BlocksField
locale?: string
numbers: Record<string, unknown>[]
path: string
relationships: Record<string, unknown>[]
relationshipsToDelete: RelationshipToDelete[]
selects: {
[tableName: string]: Record<string, unknown>[]
}
texts: Record<string, unknown>[]
/**
* Set to a locale code if this set of fields is traversed within a
* localized array or block field
*/
withinArrayOrBlockLocale?: string
}
// Can't be in a separate file to avoid circular import between traverseFields and transformBlocks
export const transformBlocks = ({
adapter,
baseTableName,
blocks,
blocksToDelete,
data,
field,
locale,
numbers,
path,
relationships,
relationshipsToDelete,
selects,
texts,
withinArrayOrBlockLocale,
}: TransformBlocksArgs) => {
data.forEach((blockRow, i) => {
if (typeof blockRow.blockType !== 'string') {
return
}
const matchedBlock = field.blocks.find(({ slug }) => slug === blockRow.blockType)
if (!matchedBlock) {
return
}
const blockType = toSnakeCase(blockRow.blockType)
if (!blocks[blockType]) {
blocks[blockType] = []
}
const newRow: BlockRowToInsert = {
arrays: {},
locales: {},
row: {
_order: i + 1,
_path: `${path}${field.name}`,
},
}
if (field.localized && locale) {
newRow.row._locale = locale
}
if (withinArrayOrBlockLocale) {
newRow.row._locale = withinArrayOrBlockLocale
}
const blockTableName = adapter.tableNameMap.get(`${baseTableName}_blocks_${blockType}`)
const hasUUID = adapter.tables[blockTableName]._uuid
// If we have declared a _uuid field on arrays,
// that means the ID has to be unique,
// and our ids within arrays are not unique.
// So move the ID to a uuid field for storage
// and allow the database to generate a serial id automatically
if (hasUUID) {
newRow.row._uuid = blockRow.id
delete blockRow.id
}
traverseFields({
adapter,
arrays: newRow.arrays,
baseTableName,
blocks,
blocksToDelete,
columnPrefix: '',
data: blockRow,
fieldPrefix: '',
fields: matchedBlock.fields,
locales: newRow.locales,
numbers,
parentTableName: blockTableName,
path: `${path || ''}${field.name}.${i}.`,
relationships,
relationshipsToDelete,
row: newRow.row,
selects,
texts,
withinArrayOrBlockLocale,
})
blocks[blockType].push(newRow)
})
}

View File

@@ -5,6 +5,7 @@ import type {
CollapsibleField,
DateField,
EmailField,
FieldWithSubFields,
GroupField,
JSONField,
NumberField,
@@ -21,11 +22,11 @@ import type {
} from 'payload'
import { GraphQLEnumType, GraphQLInputObjectType } from 'graphql'
import { fieldAffectsData, fieldIsPresentationalOnly } from 'payload/shared'
import { GraphQLJSON } from '../packages/graphql-type-json/index.js'
import { combineParentName } from '../utilities/combineParentName.js'
import { formatName } from '../utilities/formatName.js'
import { recursivelyBuildNestedPaths } from './recursivelyBuildNestedPaths.js'
import { withOperators } from './withOperators.js'
type Args = {
@@ -161,3 +162,82 @@ export const fieldToSchemaMap = ({ nestedFieldName, parentName }: Args): any =>
}
},
})
type RecursivelyBuildNestedPathsArgs = {
field: FieldWithSubFields | TabsField
nestedFieldName2: string
parentName: string
}
export const recursivelyBuildNestedPaths = ({
field,
nestedFieldName2,
parentName,
}: RecursivelyBuildNestedPathsArgs) => {
const fieldName = fieldAffectsData(field) ? field.name : undefined
const nestedFieldName = fieldName || nestedFieldName2
if (field.type === 'tabs') {
// if the tab has a name, treat it as a group
// otherwise, treat it as a row
return field.tabs.reduce((tabSchema, tab: any) => {
tabSchema.push(
...recursivelyBuildNestedPaths({
field: {
...tab,
type: 'name' in tab ? 'group' : 'row',
},
nestedFieldName2: nestedFieldName,
parentName,
}),
)
return tabSchema
}, [])
}
const nestedPaths = field.fields.reduce((nestedFields, nestedField) => {
if (!fieldIsPresentationalOnly(nestedField)) {
if (!fieldAffectsData(nestedField)) {
return [
...nestedFields,
...recursivelyBuildNestedPaths({
field: nestedField,
nestedFieldName2: nestedFieldName,
parentName,
}),
]
}
const nestedPathName = fieldAffectsData(nestedField)
? `${nestedFieldName ? `${nestedFieldName}__` : ''}${nestedField.name}`
: undefined
const getFieldSchema = fieldToSchemaMap({
nestedFieldName,
parentName,
})[nestedField.type]
if (getFieldSchema) {
const fieldSchema = getFieldSchema({
...nestedField,
name: nestedPathName,
})
if (Array.isArray(fieldSchema)) {
return [...nestedFields, ...fieldSchema]
}
return [
...nestedFields,
{
type: fieldSchema,
key: nestedPathName,
},
]
}
}
return nestedFields
}, [])
return nestedPaths
}

View File

@@ -1,80 +0,0 @@
import type { FieldWithSubFields, TabsField } from 'payload'
import { fieldAffectsData, fieldIsPresentationalOnly } from 'payload/shared'
import { fieldToSchemaMap } from './fieldToWhereInputSchemaMap.js'
type Args = {
field: FieldWithSubFields | TabsField
nestedFieldName2: string
parentName: string
}
export const recursivelyBuildNestedPaths = ({ field, nestedFieldName2, parentName }: Args) => {
const fieldName = fieldAffectsData(field) ? field.name : undefined
const nestedFieldName = fieldName || nestedFieldName2
if (field.type === 'tabs') {
// if the tab has a name, treat it as a group
// otherwise, treat it as a row
return field.tabs.reduce((tabSchema, tab: any) => {
tabSchema.push(
...recursivelyBuildNestedPaths({
field: {
...tab,
type: 'name' in tab ? 'group' : 'row',
},
nestedFieldName2: nestedFieldName,
parentName,
}),
)
return tabSchema
}, [])
}
const nestedPaths = field.fields.reduce((nestedFields, nestedField) => {
if (!fieldIsPresentationalOnly(nestedField)) {
if (!fieldAffectsData(nestedField)) {
return [
...nestedFields,
...recursivelyBuildNestedPaths({
field: nestedField,
nestedFieldName2: nestedFieldName,
parentName,
}),
]
}
const nestedPathName = fieldAffectsData(nestedField)
? `${nestedFieldName ? `${nestedFieldName}__` : ''}${nestedField.name}`
: undefined
const getFieldSchema = fieldToSchemaMap({
nestedFieldName,
parentName,
})[nestedField.type]
if (getFieldSchema) {
const fieldSchema = getFieldSchema({
...nestedField,
name: nestedPathName,
})
if (Array.isArray(fieldSchema)) {
return [...nestedFields, ...fieldSchema]
}
return [
...nestedFields,
{
type: fieldSchema,
key: nestedPathName,
},
]
}
}
return nestedFields
}, [])
return nestedPaths
}

View File

@@ -2,7 +2,7 @@ import type { CollectionConfig } from '../../index.js'
import { InvalidConfiguration } from '../../errors/InvalidConfiguration.js'
import { fieldAffectsData, fieldIsVirtual } from '../../fields/config/types.js'
import flattenFields from '../../utilities/flattenTopLevelFields.js'
import { flattenTopLevelFields } from '../../utilities/flattenTopLevelFields.js'
/**
* Validate useAsTitle for collections.
@@ -15,7 +15,7 @@ export const validateUseAsTitle = (config: CollectionConfig) => {
}
if (config?.admin && config.admin?.useAsTitle && config.admin.useAsTitle !== 'id') {
const fields = flattenFields(config.fields)
const fields = flattenTopLevelFields(config.fields)
const useAsTitleField = fields.find((field) => {
if (fieldAffectsData(field) && config.admin) {
return field.name === config.admin.useAsTitle

View File

@@ -5,7 +5,7 @@ import type { Collection } from '../config/types.js'
import executeAccess from '../../auth/executeAccess.js'
import { combineQueries } from '../../database/combineQueries.js'
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
import { validateQueryPaths } from '../../database/queryValidation/validateSearchParams.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { buildAfterOperation } from './utils.js'

View File

@@ -1,12 +1,13 @@
import type { AccessResult } from '../../config/types.js'
import type { CollectionSlug } from '../../index.js'
import type { PayloadRequest, Where } from '../../types/index.js'
import type { Collection } from '../config/types.js'
import executeAccess from '../../auth/executeAccess.js'
import { combineQueries } from '../../database/combineQueries.js'
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
import { buildVersionCollectionFields, type CollectionSlug } from '../../index.js'
import { validateQueryPaths } from '../../database/queryValidation/validateSearchParams.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js'
import { buildAfterOperation } from './utils.js'
export type Arguments = {

View File

@@ -13,7 +13,7 @@ import type {
import executeAccess from '../../auth/executeAccess.js'
import { combineQueries } from '../../database/combineQueries.js'
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
import { validateQueryPaths } from '../../database/queryValidation/validateSearchParams.js'
import { APIError } from '../../errors/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { deleteUserPreferences } from '../../preferences/deleteUserPreferences.js'

View File

@@ -17,7 +17,7 @@ import type {
import executeAccess from '../../auth/executeAccess.js'
import { combineQueries } from '../../database/combineQueries.js'
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
import { validateQueryPaths } from '../../database/queryValidation/validateSearchParams.js'
import { sanitizeJoinQuery } from '../../database/sanitizeJoinQuery.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { killTransaction } from '../../utilities/killTransaction.js'

View File

@@ -15,10 +15,10 @@ import type {
import executeAccess from '../../auth/executeAccess.js'
import { combineQueries } from '../../database/combineQueries.js'
import { validateQueryPaths } from '../../database/queryValidation/validateSearchParams.js'
import { sanitizeJoinQuery } from '../../database/sanitizeJoinQuery.js'
import { NotFound } from '../../errors/index.js'
import { NotFound } from '../../errors/NotFound.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { validateQueryPaths } from '../../index.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import replaceWithDraftIfAvailable from '../../versions/drafts/replaceWithDraftIfAvailable.js'
import { buildAfterOperation } from './utils.js'

View File

@@ -5,7 +5,7 @@ import type { Collection } from '../config/types.js'
import executeAccess from '../../auth/executeAccess.js'
import { combineQueries } from '../../database/combineQueries.js'
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
import { validateQueryPaths } from '../../database/queryValidation/validateSearchParams.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields.js'

View File

@@ -16,8 +16,8 @@ import type {
import { ensureUsernameOrEmail } from '../../auth/ensureUsernameOrEmail.js'
import executeAccess from '../../auth/executeAccess.js'
import { combineQueries } from '../../database/combineQueries.js'
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
import { APIError } from '../../errors/index.js'
import { validateQueryPaths } from '../../database/queryValidation/validateSearchParams.js'
import { APIError } from '../../errors/APIError.js'
import { afterChange } from '../../fields/hooks/afterChange/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { beforeChange } from '../../fields/hooks/beforeChange/index.js'

View File

@@ -3,7 +3,7 @@ import type { Payload } from '../index.js'
import type { PathToQuery } from './queryValidation/types.js'
import { fieldAffectsData } from '../fields/config/types.js'
import flattenFields from '../utilities/flattenTopLevelFields.js'
import { flattenTopLevelFields } from '../utilities/flattenTopLevelFields.js'
export async function getLocalizedPaths({
collectionSlug,
@@ -30,7 +30,7 @@ export async function getLocalizedPaths({
collectionSlug,
complete: false,
field: undefined,
fields: flattenFields(fields, false),
fields: flattenTopLevelFields(fields, false),
globalSlug,
invalid: false,
path: '',
@@ -151,7 +151,10 @@ export async function getLocalizedPaths({
default: {
if ('fields' in lastIncompletePath.field) {
lastIncompletePath.fields = flattenFields(lastIncompletePath.field.fields, false)
lastIncompletePath.fields = flattenTopLevelFields(
lastIncompletePath.field.fields,
false,
)
}
if (i + 1 === pathSegments.length) {

View File

@@ -2,8 +2,8 @@ import fs from 'fs'
import type { CreateMigration } from '../types.js'
import { writeMigrationIndex } from '../../index.js'
import { migrationTemplate } from './migrationTemplate.js'
import { writeMigrationIndex } from './writeMigrationIndex.js'
export const createMigration: CreateMigration = function createMigration({
migrationName,

View File

@@ -1,92 +0,0 @@
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { Field, FieldAffectingData } from '../../fields/config/types.js'
import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
import type { Operator, PayloadRequest, Where, WhereField } from '../../types/index.js'
import type { EntityPolicies } from './types.js'
import { QueryError } from '../../errors/QueryError.js'
import { validOperators } from '../../types/constants.js'
import { deepCopyObject } from '../../utilities/deepCopyObject.js'
import flattenFields from '../../utilities/flattenTopLevelFields.js'
import { validateSearchParam } from './validateSearchParams.js'
type Args = {
errors?: { path: string }[]
overrideAccess: boolean
policies?: EntityPolicies
req: PayloadRequest
versionFields?: Field[]
where: Where
} & (
| {
collectionConfig: SanitizedCollectionConfig
globalConfig?: never | undefined
}
| {
collectionConfig?: never | undefined
globalConfig: SanitizedGlobalConfig
}
)
const flattenWhere = (query: Where): WhereField[] =>
Object.entries(query).reduce((flattenedConstraints, [key, val]) => {
if ((key === 'and' || key === 'or') && Array.isArray(val)) {
const subWhereConstraints: Where[] = val.reduce((acc, subVal) => {
const subWhere = flattenWhere(subVal)
return [...acc, ...subWhere]
}, [])
return [...flattenedConstraints, ...subWhereConstraints]
}
return [...flattenedConstraints, { [key]: val }]
}, [])
export async function validateQueryPaths({
collectionConfig,
errors = [],
globalConfig,
overrideAccess,
policies = {
collections: {},
globals: {},
},
req,
versionFields,
where,
}: Args): Promise<void> {
const fields = flattenFields(
versionFields || (globalConfig || collectionConfig).fields,
) as FieldAffectingData[]
if (typeof where === 'object') {
const whereFields = flattenWhere(where)
// We need to determine if the whereKey is an AND, OR, or a schema path
const promises = []
void whereFields.map((constraint) => {
void Object.keys(constraint).map((path) => {
void Object.entries(constraint[path]).map(([operator, val]) => {
if (validOperators.includes(operator as Operator)) {
promises.push(
validateSearchParam({
collectionConfig: deepCopyObject(collectionConfig),
errors,
fields: fields as Field[],
globalConfig: deepCopyObject(globalConfig),
operator,
overrideAccess,
path,
policies,
req,
val,
versionFields,
}),
)
}
})
})
})
await Promise.all(promises)
if (errors.length > 0) {
throw new QueryError(errors)
}
}
}

View File

@@ -1,14 +1,17 @@
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { Field } from '../../fields/config/types.js'
import type { Field, FieldAffectingData } from '../../fields/config/types.js'
import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
import type { PayloadRequest } from '../../types/index.js'
import type { Operator, PayloadRequest, Where, WhereField } from '../../types/index.js'
import type { EntityPolicies, PathToQuery } from './types.js'
import { QueryError } from '../../errors/QueryError.js'
import { fieldAffectsData, fieldIsVirtual } from '../../fields/config/types.js'
import { validOperators } from '../../types/constants.js'
import { deepCopyObject } from '../../utilities/deepCopyObject.js'
import { flattenTopLevelFields } from '../../utilities/flattenTopLevelFields.js'
import { getEntityPolicies } from '../../utilities/getEntityPolicies.js'
import isolateObjectProperty from '../../utilities/isolateObjectProperty.js'
import { getLocalizedPaths } from '../getLocalizedPaths.js'
import { validateQueryPaths } from './validateQueryPaths.js'
type Args = {
collectionConfig?: SanitizedCollectionConfig
@@ -193,3 +196,84 @@ export async function validateSearchParam({
)
await Promise.all(promises)
}
type ValidateQueryPathsArgs = {
errors?: { path: string }[]
overrideAccess: boolean
policies?: EntityPolicies
req: PayloadRequest
versionFields?: Field[]
where: Where
} & (
| {
collectionConfig: SanitizedCollectionConfig
globalConfig?: never | undefined
}
| {
collectionConfig?: never | undefined
globalConfig: SanitizedGlobalConfig
}
)
const flattenWhere = (query: Where): WhereField[] =>
Object.entries(query).reduce((flattenedConstraints, [key, val]) => {
if ((key === 'and' || key === 'or') && Array.isArray(val)) {
const subWhereConstraints: Where[] = val.reduce((acc, subVal) => {
const subWhere = flattenWhere(subVal)
return [...acc, ...subWhere]
}, [])
return [...flattenedConstraints, ...subWhereConstraints]
}
return [...flattenedConstraints, { [key]: val }]
}, [])
export async function validateQueryPaths({
collectionConfig,
errors = [],
globalConfig,
overrideAccess,
policies = {
collections: {},
globals: {},
},
req,
versionFields,
where,
}: ValidateQueryPathsArgs): Promise<void> {
const fields = flattenTopLevelFields(
versionFields || (globalConfig || collectionConfig).fields,
) as FieldAffectingData[]
if (typeof where === 'object') {
const whereFields = flattenWhere(where)
// We need to determine if the whereKey is an AND, OR, or a schema path
const promises = []
void whereFields.map((constraint) => {
void Object.keys(constraint).map((path) => {
void Object.entries(constraint[path]).map(([operator, val]) => {
if (validOperators.includes(operator as Operator)) {
promises.push(
validateSearchParam({
collectionConfig: deepCopyObject(collectionConfig),
errors,
fields: fields as Field[],
globalConfig: deepCopyObject(globalConfig),
operator,
overrideAccess,
path,
policies,
req,
val,
versionFields,
}),
)
}
})
})
})
await Promise.all(promises)
if (errors.length > 0) {
throw new QueryError(errors)
}
}
}

View File

@@ -4,7 +4,7 @@ import type { JoinQuery, PayloadRequest } from '../types/index.js'
import executeAccess from '../auth/executeAccess.js'
import { QueryError } from '../errors/QueryError.js'
import { combineQueries } from './combineQueries.js'
import { validateQueryPaths } from './queryValidation/validateQueryPaths.js'
import { validateQueryPaths } from './queryValidation/validateSearchParams.js'
type Args = {
collectionConfig: SanitizedCollectionConfig

View File

@@ -14,17 +14,17 @@ import type {
SelectFieldClient,
TabsFieldClient,
} from '../../fields/config/types.js'
import type { ImportMap } from '../../index.js'
import type { Payload } from '../../types/index.js'
import { getFromImportMap } from '../../bin/generateImportMap/getFromImportMap.js'
import { MissingEditorProp } from '../../errors/MissingEditorProp.js'
import { fieldAffectsData } from '../../fields/config/types.js'
import { flattenTopLevelFields, type ImportMap } from '../../index.js'
import { flattenTopLevelFields } from '../../utilities/flattenTopLevelFields.js'
import { removeUndefined } from '../../utilities/removeUndefined.js'
// Should not be used - ClientField should be used instead. This is why we don't export ClientField, we don't want people
// to accidentally use it instead of ClientField and get confused
export { ClientField }
export type ServerOnlyFieldProperties =

View File

@@ -1,305 +0,0 @@
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, PayloadRequest } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { traverseFields } from './traverseFields.js'
type Args = {
collection: null | SanitizedCollectionConfig
context: RequestContext
data: JsonObject
doc: JsonObject
field: Field | TabAsField
fieldIndex: number
global: null | SanitizedGlobalConfig
operation: 'create' | 'update'
/**
* The parent's path
*/
parentPath: (number | string)[]
/**
* The parent's schemaPath (path without indexes).
*/
parentSchemaPath: string[]
previousDoc: JsonObject
previousSiblingDoc: JsonObject
req: PayloadRequest
siblingData: JsonObject
siblingDoc: JsonObject
}
// This function is responsible for the following actions, in order:
// - Execute field hooks
export const promise = async ({
collection,
context,
data,
doc,
field,
fieldIndex,
global,
operation,
parentPath,
parentSchemaPath,
previousDoc,
previousSiblingDoc,
req,
siblingData,
siblingDoc,
}: Args): Promise<void> => {
const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({
field,
index: fieldIndex,
parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data
parentPath: parentPath.join('.'),
parentSchemaPath: parentSchemaPath.join('.'),
})
const fieldPath = _fieldPath ? _fieldPath.split('.') : []
const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : []
if (fieldAffectsData(field)) {
// Execute hooks
if (field.hooks?.afterChange) {
await field.hooks.afterChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
path: fieldPath,
previousDoc,
previousSiblingDoc,
previousValue: previousDoc[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}, Promise.resolve())
}
}
// Traverse subfields
switch (field.type) {
case 'array': {
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
promises.push(
traverseFields({
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
path: [...fieldPath, i],
previousDoc,
previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as JsonObject),
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData?.[field.name]?.[i] || {},
siblingDoc: row ? { ...row } : {},
}),
)
})
await Promise.all(promises)
}
break
}
case 'blocks': {
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
const block = field.blocks.find(
(blockType) => blockType.slug === (row as JsonObject).blockType,
)
if (block) {
promises.push(
traverseFields({
collection,
context,
data,
doc,
fields: block.fields,
global,
operation,
path: [...fieldPath, i],
previousDoc,
previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as JsonObject),
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData?.[field.name]?.[i] || {},
siblingDoc: row ? { ...row } : {},
}),
)
}
})
await Promise.all(promises)
}
break
}
case 'collapsible':
case 'row': {
await traverseFields({
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: { ...previousSiblingDoc },
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData || {},
siblingDoc: { ...siblingDoc },
})
break
}
case 'group': {
await traverseFields({
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: previousDoc[field.name] as JsonObject,
req,
schemaPath: fieldSchemaPath,
siblingData: (siblingData?.[field.name] as JsonObject) || {},
siblingDoc: siblingDoc[field.name] as JsonObject,
})
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.afterChange?.length) {
await editor.hooks.afterChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
path: fieldPath,
previousDoc,
previousSiblingDoc,
previousValue: previousDoc[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}, Promise.resolve())
}
break
}
case 'tab': {
let tabSiblingData = siblingData
let tabSiblingDoc = siblingDoc
let tabPreviousSiblingDoc = siblingDoc
if (tabHasName(field)) {
tabSiblingData = siblingData[field.name] as JsonObject
tabSiblingDoc = siblingDoc[field.name] as JsonObject
tabPreviousSiblingDoc = previousDoc[field.name] as JsonObject
}
await traverseFields({
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: tabPreviousSiblingDoc,
req,
schemaPath: fieldSchemaPath,
siblingData: tabSiblingData,
siblingDoc: tabSiblingDoc,
})
break
}
case 'tabs': {
await traverseFields({
collection,
context,
data,
doc,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: { ...previousSiblingDoc },
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData || {},
siblingDoc: { ...siblingDoc },
})
break
}
default: {
break
}
}
}

View File

@@ -1,10 +1,13 @@
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, PayloadRequest } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
import { MissingEditorProp } from '../../../errors/MissingEditorProp.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import { getFieldPaths } from '../../getFieldPaths.js'
type Args = {
collection: null | SanitizedCollectionConfig
@@ -65,3 +68,296 @@ export const traverseFields = async ({
await Promise.all(promises)
}
type PromiseArgs = {
collection: null | SanitizedCollectionConfig
context: RequestContext
data: JsonObject
doc: JsonObject
field: Field | TabAsField
fieldIndex: number
global: null | SanitizedGlobalConfig
operation: 'create' | 'update'
/**
* The parent's path
*/
parentPath: (number | string)[]
/**
* The parent's schemaPath (path without indexes).
*/
parentSchemaPath: string[]
previousDoc: JsonObject
previousSiblingDoc: JsonObject
req: PayloadRequest
siblingData: JsonObject
siblingDoc: JsonObject
}
// This function is responsible for the following actions, in order:
// - Execute field hooks
export const promise = async ({
collection,
context,
data,
doc,
field,
fieldIndex,
global,
operation,
parentPath,
parentSchemaPath,
previousDoc,
previousSiblingDoc,
req,
siblingData,
siblingDoc,
}: PromiseArgs): Promise<void> => {
const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({
field,
index: fieldIndex,
parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data
parentPath: parentPath.join('.'),
parentSchemaPath: parentSchemaPath.join('.'),
})
const fieldPath = _fieldPath ? _fieldPath.split('.') : []
const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : []
if (fieldAffectsData(field)) {
// Execute hooks
if (field.hooks?.afterChange) {
await field.hooks.afterChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
path: fieldPath,
previousDoc,
previousSiblingDoc,
previousValue: previousDoc[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}, Promise.resolve())
}
}
// Traverse subfields
switch (field.type) {
case 'array': {
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
promises.push(
traverseFields({
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
path: [...fieldPath, i],
previousDoc,
previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as JsonObject),
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData?.[field.name]?.[i] || {},
siblingDoc: row ? { ...row } : {},
}),
)
})
await Promise.all(promises)
}
break
}
case 'blocks': {
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
const block = field.blocks.find(
(blockType) => blockType.slug === (row as JsonObject).blockType,
)
if (block) {
promises.push(
traverseFields({
collection,
context,
data,
doc,
fields: block.fields,
global,
operation,
path: [...fieldPath, i],
previousDoc,
previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as JsonObject),
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData?.[field.name]?.[i] || {},
siblingDoc: row ? { ...row } : {},
}),
)
}
})
await Promise.all(promises)
}
break
}
case 'collapsible':
case 'row': {
await traverseFields({
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: { ...previousSiblingDoc },
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData || {},
siblingDoc: { ...siblingDoc },
})
break
}
case 'group': {
await traverseFields({
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: previousDoc[field.name] as JsonObject,
req,
schemaPath: fieldSchemaPath,
siblingData: (siblingData?.[field.name] as JsonObject) || {},
siblingDoc: siblingDoc[field.name] as JsonObject,
})
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.afterChange?.length) {
await editor.hooks.afterChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
path: fieldPath,
previousDoc,
previousSiblingDoc,
previousValue: previousDoc[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}, Promise.resolve())
}
break
}
case 'tab': {
let tabSiblingData = siblingData
let tabSiblingDoc = siblingDoc
let tabPreviousSiblingDoc = siblingDoc
if (tabHasName(field)) {
tabSiblingData = siblingData[field.name] as JsonObject
tabSiblingDoc = siblingDoc[field.name] as JsonObject
tabPreviousSiblingDoc = previousDoc[field.name] as JsonObject
}
await traverseFields({
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: tabPreviousSiblingDoc,
req,
schemaPath: fieldSchemaPath,
siblingData: tabSiblingData,
siblingDoc: tabSiblingDoc,
})
break
}
case 'tabs': {
await traverseFields({
collection,
context,
data,
doc,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: { ...previousSiblingDoc },
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData || {},
siblingDoc: { ...siblingDoc },
})
break
}
default: {
break
}
}
}

View File

@@ -1,788 +0,0 @@
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { RequestContext } from '../../../index.js'
import type {
JsonObject,
PayloadRequest,
PopulateType,
SelectMode,
SelectType,
} from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import { getDefaultValue } from '../../getDefaultValue.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { relationshipPopulationPromise } from './relationshipPopulationPromise.js'
import { traverseFields } from './traverseFields.js'
type Args = {
collection: null | SanitizedCollectionConfig
context: RequestContext
currentDepth: number
depth: number
doc: JsonObject
draft: boolean
fallbackLocale: null | string
field: Field | TabAsField
fieldIndex: number
/**
* fieldPromises are used for things like field hooks. They should be awaited before awaiting populationPromises
*/
fieldPromises: Promise<void>[]
findMany: boolean
flattenLocales: boolean
global: null | SanitizedGlobalConfig
locale: null | string
overrideAccess: boolean
/**
* The parent's path.
*/
parentPath: (number | string)[]
/**
* The parent's schemaPath (path without indexes).
*/
parentSchemaPath: string[]
populate?: PopulateType
populationPromises: Promise<void>[]
req: PayloadRequest
select?: SelectType
selectMode?: SelectMode
showHiddenFields: boolean
siblingDoc: JsonObject
triggerAccessControl?: boolean
triggerHooks?: 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 ({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
field,
fieldIndex,
fieldPromises,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
parentPath,
parentSchemaPath,
populate,
populationPromises,
req,
select,
selectMode,
showHiddenFields,
siblingDoc,
triggerAccessControl = true,
triggerHooks = true,
}: Args): Promise<void> => {
const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({
field,
index: fieldIndex,
parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data
parentPath: parentPath.join('.'),
parentSchemaPath: parentSchemaPath.join('.'),
})
const fieldPath = _fieldPath ? _fieldPath.split('.') : []
const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : []
if (
fieldAffectsData(field) &&
field.hidden &&
typeof siblingDoc[field.name] !== 'undefined' &&
!showHiddenFields
) {
delete siblingDoc[field.name]
}
if (fieldAffectsData(field) && select && selectMode) {
if (selectMode === 'include') {
if (!select[field.name]) {
delete siblingDoc[field.name]
return
}
}
if (selectMode === 'exclude') {
if (select[field.name] === false) {
delete siblingDoc[field.name]
return
}
}
}
const shouldHoistLocalizedValue =
flattenLocales &&
fieldAffectsData(field) &&
typeof siblingDoc[field.name] === 'object' &&
siblingDoc[field.name] !== null &&
field.localized &&
locale !== 'all' &&
req.payload.config.localization
if (shouldHoistLocalizedValue) {
// replace actual value with localized value before sanitizing
// { [locale]: fields } -> fields
const value = siblingDoc[field.name][locale]
let hoistedValue = value
if (fallbackLocale && fallbackLocale !== locale) {
const fallbackValue = siblingDoc[field.name][fallbackLocale]
const isNullOrUndefined = typeof value === 'undefined' || value === null
if (fallbackValue) {
switch (field.type) {
case 'text':
case 'textarea': {
if (value === '' || isNullOrUndefined) {
hoistedValue = fallbackValue
}
break
}
default: {
if (isNullOrUndefined) {
hoistedValue = fallbackValue
}
break
}
}
}
}
siblingDoc[field.name] = hoistedValue
}
// Sanitize outgoing field value
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 'point': {
const pointDoc = siblingDoc[field.name] as Record<string, unknown>
if (Array.isArray(pointDoc?.coordinates) && pointDoc.coordinates.length === 2) {
siblingDoc[field.name] = pointDoc.coordinates
} else {
siblingDoc[field.name] = undefined
}
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
// Rich Text fields should use afterRead hooks to do population. The previous editor.populationPromises have been renamed to editor.graphQLPopulationPromises
break
}
case 'tabs': {
field.tabs.forEach((tab) => {
if (
tabHasName(tab) &&
(typeof siblingDoc[tab.name] === 'undefined' || siblingDoc[tab.name] === null)
) {
siblingDoc[tab.name] = {}
}
})
break
}
default: {
break
}
}
if (fieldAffectsData(field)) {
// Execute hooks
if (triggerHooks && field.hooks?.afterRead) {
await field.hooks.afterRead.reduce(async (priorHook, currentHook) => {
await priorHook
const shouldRunHookOnAllLocales =
field.localized &&
(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({
collection,
context,
currentDepth,
data: doc,
depth,
draft,
field,
findMany,
global,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingData: siblingDoc,
value,
})
if (hookedValue !== undefined) {
siblingDoc[field.name][locale] = hookedValue
}
})(),
)
await Promise.all(hookPromises)
} else {
const hookedValue = await currentHook({
collection,
context,
currentDepth,
data: doc,
depth,
draft,
field,
findMany,
global,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingData: siblingDoc,
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}
}, Promise.resolve())
}
// Execute access control
let allowDefaultValue = true
if (triggerAccessControl && field.access && field.access.read) {
const result = overrideAccess
? true
: await field.access.read({
id: doc.id as number | string,
data: doc,
doc,
req,
siblingData: siblingDoc,
})
if (!result) {
allowDefaultValue = false
delete siblingDoc[field.name]
}
}
// Set defaultValue on the field for globals being returned without being first created
// or collection documents created prior to having a default
if (
allowDefaultValue &&
typeof siblingDoc[field.name] === 'undefined' &&
typeof field.defaultValue !== 'undefined'
) {
siblingDoc[field.name] = await getDefaultValue({
defaultValue: field.defaultValue,
locale,
user: req.user,
value: siblingDoc[field.name],
})
}
if (field.type === 'relationship' || field.type === 'upload' || field.type === 'join') {
populationPromises.push(
relationshipPopulationPromise({
currentDepth,
depth,
draft,
fallbackLocale,
field,
locale,
overrideAccess,
populate,
req,
showHiddenFields,
siblingDoc,
}),
)
}
}
switch (field.type) {
case 'array': {
const rows = siblingDoc[field.name] as JsonObject
const arraySelect = select?.[field.name]
if (selectMode === 'include' && typeof arraySelect === 'object') {
arraySelect.id = true
}
if (Array.isArray(rows)) {
rows.forEach((row, i) => {
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
select: typeof arraySelect === 'object' ? arraySelect : undefined,
selectMode,
showHiddenFields,
siblingDoc: row || {},
triggerAccessControl,
triggerHooks,
})
})
} else if (!shouldHoistLocalizedValue && typeof rows === 'object' && rows !== null) {
Object.values(rows).forEach((localeRows) => {
if (Array.isArray(localeRows)) {
localeRows.forEach((row, i) => {
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: (row as JsonObject) || {},
triggerAccessControl,
triggerHooks,
})
})
}
})
} else {
siblingDoc[field.name] = []
}
break
}
case 'blocks': {
const rows = siblingDoc[field.name]
const blocksSelect = select?.[field.name]
if (Array.isArray(rows)) {
rows.forEach((row, i) => {
const block = field.blocks.find(
(blockType) => blockType.slug === (row as JsonObject).blockType,
)
let blockSelectMode = selectMode
if (typeof blocksSelect === 'object') {
// sanitize blocks: {cta: false} to blocks: {cta: {id: true, blockType: true}}
if (selectMode === 'exclude' && blocksSelect[block.slug] === false) {
blockSelectMode = 'include'
blocksSelect[block.slug] = {
id: true,
blockType: true,
}
} else if (selectMode === 'include') {
if (!blocksSelect[block.slug]) {
blocksSelect[block.slug] = {}
}
if (typeof blocksSelect[block.slug] === 'object') {
blocksSelect[block.slug]['id'] = true
blocksSelect[block.slug]['blockType'] = true
}
}
}
const blockSelect = blocksSelect?.[block.slug]
if (block) {
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: block.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
select: typeof blockSelect === 'object' ? blockSelect : undefined,
selectMode: blockSelectMode,
showHiddenFields,
siblingDoc: (row as JsonObject) || {},
triggerAccessControl,
triggerHooks,
})
}
})
} else if (!shouldHoistLocalizedValue && typeof rows === 'object' && rows !== null) {
Object.values(rows).forEach((localeRows) => {
if (Array.isArray(localeRows)) {
localeRows.forEach((row, i) => {
const block = field.blocks.find(
(blockType) => blockType.slug === (row as JsonObject).blockType,
)
if (block) {
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: block.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: (row as JsonObject) || {},
triggerAccessControl,
triggerHooks,
})
}
})
}
})
} else {
siblingDoc[field.name] = []
}
break
}
case 'collapsible':
case 'row': {
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: fieldPath,
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
select,
selectMode,
showHiddenFields,
siblingDoc,
triggerAccessControl,
triggerHooks,
})
break
}
case 'group': {
let groupDoc = siblingDoc[field.name] as JsonObject
if (typeof siblingDoc[field.name] !== 'object') {
groupDoc = {}
}
const groupSelect = select?.[field.name]
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: fieldPath,
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
select: typeof groupSelect === 'object' ? groupSelect : undefined,
selectMode,
showHiddenFields,
siblingDoc: groupDoc,
triggerAccessControl,
triggerHooks,
})
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.afterRead?.length) {
await editor.hooks.afterRead.reduce(async (priorHook, currentHook) => {
await priorHook
const shouldRunHookOnAllLocales =
field.localized &&
(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({
collection,
context,
currentDepth,
data: doc,
depth,
draft,
fallbackLocale,
field,
fieldPromises,
findMany,
flattenLocales,
global,
locale,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingData: siblingDoc,
triggerAccessControl,
triggerHooks,
value,
})
if (hookedValue !== undefined) {
siblingDoc[field.name][locale] = hookedValue
}
})(),
)
await Promise.all(hookPromises)
} else {
const hookedValue = await currentHook({
collection,
context,
currentDepth,
data: doc,
depth,
draft,
fallbackLocale,
field,
fieldPromises,
findMany,
flattenLocales,
global,
locale,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingData: siblingDoc,
triggerAccessControl,
triggerHooks,
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}
}, Promise.resolve())
}
break
}
case 'tab': {
let tabDoc = siblingDoc
let tabSelect: SelectType | undefined
if (tabHasName(field)) {
tabDoc = siblingDoc[field.name] as JsonObject
if (typeof siblingDoc[field.name] !== 'object') {
tabDoc = {}
}
if (typeof select?.[field.name] === 'object') {
tabSelect = select?.[field.name] as SelectType
}
} else {
tabSelect = select
}
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: fieldPath,
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
select: tabSelect,
selectMode,
showHiddenFields,
siblingDoc: tabDoc,
triggerAccessControl,
triggerHooks,
})
break
}
case 'tabs': {
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: fieldPath,
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
select,
selectMode,
showHiddenFields,
siblingDoc,
triggerAccessControl,
triggerHooks,
})
break
}
default: {
break
}
}
}

View File

@@ -1,3 +1,4 @@
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { RequestContext } from '../../../index.js'
@@ -10,7 +11,11 @@ import type {
} from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import { getDefaultValue } from '../../getDefaultValue.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { relationshipPopulationPromise } from './relationshipPopulationPromise.js'
type Args = {
collection: null | SanitizedCollectionConfig
@@ -103,3 +108,772 @@ export const traverseFields = ({
)
})
}
type PromiseArgs = {
collection: null | SanitizedCollectionConfig
context: RequestContext
currentDepth: number
depth: number
doc: JsonObject
draft: boolean
fallbackLocale: null | string
field: Field | TabAsField
fieldIndex: number
/**
* fieldPromises are used for things like field hooks. They should be awaited before awaiting populationPromises
*/
fieldPromises: Promise<void>[]
findMany: boolean
flattenLocales: boolean
global: null | SanitizedGlobalConfig
locale: null | string
overrideAccess: boolean
/**
* The parent's path.
*/
parentPath: (number | string)[]
/**
* The parent's schemaPath (path without indexes).
*/
parentSchemaPath: string[]
populate?: PopulateType
populationPromises: Promise<void>[]
req: PayloadRequest
select?: SelectType
selectMode?: SelectMode
showHiddenFields: boolean
siblingDoc: JsonObject
triggerAccessControl?: boolean
triggerHooks?: 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 ({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
field,
fieldIndex,
fieldPromises,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
parentPath,
parentSchemaPath,
populate,
populationPromises,
req,
select,
selectMode,
showHiddenFields,
siblingDoc,
triggerAccessControl = true,
triggerHooks = true,
}: PromiseArgs): Promise<void> => {
const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({
field,
index: fieldIndex,
parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data
parentPath: parentPath.join('.'),
parentSchemaPath: parentSchemaPath.join('.'),
})
const fieldPath = _fieldPath ? _fieldPath.split('.') : []
const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : []
if (
fieldAffectsData(field) &&
field.hidden &&
typeof siblingDoc[field.name] !== 'undefined' &&
!showHiddenFields
) {
delete siblingDoc[field.name]
}
if (fieldAffectsData(field) && select && selectMode) {
if (selectMode === 'include') {
if (!select[field.name]) {
delete siblingDoc[field.name]
return
}
}
if (selectMode === 'exclude') {
if (select[field.name] === false) {
delete siblingDoc[field.name]
return
}
}
}
const shouldHoistLocalizedValue =
flattenLocales &&
fieldAffectsData(field) &&
typeof siblingDoc[field.name] === 'object' &&
siblingDoc[field.name] !== null &&
field.localized &&
locale !== 'all' &&
req.payload.config.localization
if (shouldHoistLocalizedValue) {
// replace actual value with localized value before sanitizing
// { [locale]: fields } -> fields
const value = siblingDoc[field.name][locale]
let hoistedValue = value
if (fallbackLocale && fallbackLocale !== locale) {
const fallbackValue = siblingDoc[field.name][fallbackLocale]
const isNullOrUndefined = typeof value === 'undefined' || value === null
if (fallbackValue) {
switch (field.type) {
case 'text':
case 'textarea': {
if (value === '' || isNullOrUndefined) {
hoistedValue = fallbackValue
}
break
}
default: {
if (isNullOrUndefined) {
hoistedValue = fallbackValue
}
break
}
}
}
}
siblingDoc[field.name] = hoistedValue
}
// Sanitize outgoing field value
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 'point': {
const pointDoc = siblingDoc[field.name] as Record<string, unknown>
if (Array.isArray(pointDoc?.coordinates) && pointDoc.coordinates.length === 2) {
siblingDoc[field.name] = pointDoc.coordinates
} else {
siblingDoc[field.name] = undefined
}
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
// Rich Text fields should use afterRead hooks to do population. The previous editor.populationPromises have been renamed to editor.graphQLPopulationPromises
break
}
case 'tabs': {
field.tabs.forEach((tab) => {
if (
tabHasName(tab) &&
(typeof siblingDoc[tab.name] === 'undefined' || siblingDoc[tab.name] === null)
) {
siblingDoc[tab.name] = {}
}
})
break
}
default: {
break
}
}
if (fieldAffectsData(field)) {
// Execute hooks
if (triggerHooks && field.hooks?.afterRead) {
await field.hooks.afterRead.reduce(async (priorHook, currentHook) => {
await priorHook
const shouldRunHookOnAllLocales =
field.localized &&
(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({
collection,
context,
currentDepth,
data: doc,
depth,
draft,
field,
findMany,
global,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingData: siblingDoc,
value,
})
if (hookedValue !== undefined) {
siblingDoc[field.name][locale] = hookedValue
}
})(),
)
await Promise.all(hookPromises)
} else {
const hookedValue = await currentHook({
collection,
context,
currentDepth,
data: doc,
depth,
draft,
field,
findMany,
global,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingData: siblingDoc,
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}
}, Promise.resolve())
}
// Execute access control
let allowDefaultValue = true
if (triggerAccessControl && field.access && field.access.read) {
const result = overrideAccess
? true
: await field.access.read({
id: doc.id as number | string,
data: doc,
doc,
req,
siblingData: siblingDoc,
})
if (!result) {
allowDefaultValue = false
delete siblingDoc[field.name]
}
}
// Set defaultValue on the field for globals being returned without being first created
// or collection documents created prior to having a default
if (
allowDefaultValue &&
typeof siblingDoc[field.name] === 'undefined' &&
typeof field.defaultValue !== 'undefined'
) {
siblingDoc[field.name] = await getDefaultValue({
defaultValue: field.defaultValue,
locale,
user: req.user,
value: siblingDoc[field.name],
})
}
if (field.type === 'relationship' || field.type === 'upload' || field.type === 'join') {
populationPromises.push(
relationshipPopulationPromise({
currentDepth,
depth,
draft,
fallbackLocale,
field,
locale,
overrideAccess,
populate,
req,
showHiddenFields,
siblingDoc,
}),
)
}
}
switch (field.type) {
case 'array': {
const rows = siblingDoc[field.name] as JsonObject
const arraySelect = select?.[field.name]
if (selectMode === 'include' && typeof arraySelect === 'object') {
arraySelect.id = true
}
if (Array.isArray(rows)) {
rows.forEach((row, i) => {
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
select: typeof arraySelect === 'object' ? arraySelect : undefined,
selectMode,
showHiddenFields,
siblingDoc: row || {},
triggerAccessControl,
triggerHooks,
})
})
} else if (!shouldHoistLocalizedValue && typeof rows === 'object' && rows !== null) {
Object.values(rows).forEach((localeRows) => {
if (Array.isArray(localeRows)) {
localeRows.forEach((row, i) => {
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: (row as JsonObject) || {},
triggerAccessControl,
triggerHooks,
})
})
}
})
} else {
siblingDoc[field.name] = []
}
break
}
case 'blocks': {
const rows = siblingDoc[field.name]
const blocksSelect = select?.[field.name]
if (Array.isArray(rows)) {
rows.forEach((row, i) => {
const block = field.blocks.find(
(blockType) => blockType.slug === (row as JsonObject).blockType,
)
let blockSelectMode = selectMode
if (typeof blocksSelect === 'object') {
// sanitize blocks: {cta: false} to blocks: {cta: {id: true, blockType: true}}
if (selectMode === 'exclude' && blocksSelect[block.slug] === false) {
blockSelectMode = 'include'
blocksSelect[block.slug] = {
id: true,
blockType: true,
}
} else if (selectMode === 'include') {
if (!blocksSelect[block.slug]) {
blocksSelect[block.slug] = {}
}
if (typeof blocksSelect[block.slug] === 'object') {
blocksSelect[block.slug]['id'] = true
blocksSelect[block.slug]['blockType'] = true
}
}
}
const blockSelect = blocksSelect?.[block.slug]
if (block) {
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: block.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
select: typeof blockSelect === 'object' ? blockSelect : undefined,
selectMode: blockSelectMode,
showHiddenFields,
siblingDoc: (row as JsonObject) || {},
triggerAccessControl,
triggerHooks,
})
}
})
} else if (!shouldHoistLocalizedValue && typeof rows === 'object' && rows !== null) {
Object.values(rows).forEach((localeRows) => {
if (Array.isArray(localeRows)) {
localeRows.forEach((row, i) => {
const block = field.blocks.find(
(blockType) => blockType.slug === (row as JsonObject).blockType,
)
if (block) {
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: block.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: (row as JsonObject) || {},
triggerAccessControl,
triggerHooks,
})
}
})
}
})
} else {
siblingDoc[field.name] = []
}
break
}
case 'collapsible':
case 'row': {
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: fieldPath,
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
select,
selectMode,
showHiddenFields,
siblingDoc,
triggerAccessControl,
triggerHooks,
})
break
}
case 'group': {
let groupDoc = siblingDoc[field.name] as JsonObject
if (typeof siblingDoc[field.name] !== 'object') {
groupDoc = {}
}
const groupSelect = select?.[field.name]
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: fieldPath,
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
select: typeof groupSelect === 'object' ? groupSelect : undefined,
selectMode,
showHiddenFields,
siblingDoc: groupDoc,
triggerAccessControl,
triggerHooks,
})
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.afterRead?.length) {
await editor.hooks.afterRead.reduce(async (priorHook, currentHook) => {
await priorHook
const shouldRunHookOnAllLocales =
field.localized &&
(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({
collection,
context,
currentDepth,
data: doc,
depth,
draft,
fallbackLocale,
field,
fieldPromises,
findMany,
flattenLocales,
global,
locale,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingData: siblingDoc,
triggerAccessControl,
triggerHooks,
value,
})
if (hookedValue !== undefined) {
siblingDoc[field.name][locale] = hookedValue
}
})(),
)
await Promise.all(hookPromises)
} else {
const hookedValue = await currentHook({
collection,
context,
currentDepth,
data: doc,
depth,
draft,
fallbackLocale,
field,
fieldPromises,
findMany,
flattenLocales,
global,
locale,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingData: siblingDoc,
triggerAccessControl,
triggerHooks,
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}
}, Promise.resolve())
}
break
}
case 'tab': {
let tabDoc = siblingDoc
let tabSelect: SelectType | undefined
if (tabHasName(field)) {
tabDoc = siblingDoc[field.name] as JsonObject
if (typeof siblingDoc[field.name] !== 'object') {
tabDoc = {}
}
if (typeof select?.[field.name] === 'object') {
tabSelect = select?.[field.name] as SelectType
}
} else {
tabSelect = select
}
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: fieldPath,
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
select: tabSelect,
selectMode,
showHiddenFields,
siblingDoc: tabDoc,
triggerAccessControl,
triggerHooks,
})
break
}
case 'tabs': {
traverseFields({
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path: fieldPath,
populate,
populationPromises,
req,
schemaPath: fieldSchemaPath,
select,
selectMode,
showHiddenFields,
siblingDoc,
triggerAccessControl,
triggerHooks,
})
break
}
default: {
break
}
}
}

View File

@@ -1,487 +0,0 @@
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { ValidationFieldError } from '../../../errors/index.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { getExistingRowDoc } from './getExistingRowDoc.js'
import { traverseFields } from './traverseFields.js'
type Args = {
collection: null | SanitizedCollectionConfig
context: RequestContext
data: JsonObject
doc: JsonObject
docWithLocales: JsonObject
errors: ValidationFieldError[]
field: Field | TabAsField
/**
* The index of the field as it appears in the parent's fields array. This is used to construct the field path / schemaPath
* for unnamed fields like rows and collapsibles.
*/
fieldIndex: number
global: null | SanitizedGlobalConfig
id?: number | string
mergeLocaleActions: (() => Promise<void>)[]
operation: Operation
/**
* The parent's path.
*/
parentPath: (number | string)[]
/**
* The parent's schemaPath (path without indexes).
*/
parentSchemaPath: string[]
req: PayloadRequest
siblingData: JsonObject
siblingDoc: JsonObject
siblingDocWithLocales?: JsonObject
skipValidation: boolean
}
// This function is responsible for the following actions, in order:
// - Run condition
// - Execute field hooks
// - Validate data
// - Transform data for storage
// - beforeDuplicate hooks (if duplicate)
// - Unflatten locales
export const promise = async ({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
field,
fieldIndex,
global,
mergeLocaleActions,
operation,
parentPath,
parentSchemaPath,
req,
siblingData,
siblingDoc,
siblingDocWithLocales,
skipValidation,
}: Args): Promise<void> => {
const passesCondition = field.admin?.condition
? Boolean(field.admin.condition(data, siblingData, { user: req.user }))
: true
let skipValidationFromHere = skipValidation || !passesCondition
const { localization } = req.payload.config
const defaultLocale = localization ? localization?.defaultLocale : 'en'
const operationLocale = req.locale || defaultLocale
const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({
field,
index: fieldIndex,
parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data
parentPath: parentPath.join('.'),
parentSchemaPath: parentSchemaPath.join('.'),
})
const fieldPath = _fieldPath ? _fieldPath.split('.') : []
const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : []
if (fieldAffectsData(field)) {
// skip validation if the field is localized and the incoming data is null
if (field.localized && operationLocale !== defaultLocale) {
if (['array', 'blocks'].includes(field.type) && siblingData[field.name] === null) {
skipValidationFromHere = true
}
}
// Execute hooks
if (field.hooks?.beforeChange) {
await field.hooks.beforeChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name],
req,
schemaPath: parentSchemaPath,
siblingData,
siblingDocWithLocales,
value: siblingData[field.name],
})
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
// Validate
if (!skipValidationFromHere && 'validate' in field && field.validate) {
const valueToValidate = siblingData[field.name]
let jsonError: object
if (field.type === 'json' && typeof siblingData[field.name] === 'string') {
try {
JSON.parse(siblingData[field.name] as string)
} catch (e) {
jsonError = e
}
}
const validationResult = await field.validate(
valueToValidate as never,
{
...field,
id,
collectionSlug: collection?.slug,
data: deepMergeWithSourceArrays(doc, data),
jsonError,
operation,
preferences: { fields: {} },
previousValue: siblingDoc[field.name],
req,
siblingData: deepMergeWithSourceArrays(siblingDoc, siblingData),
} as any,
)
if (typeof validationResult === 'string') {
errors.push({
message: validationResult,
path: fieldPath.join('.'),
})
}
}
// Push merge locale action if applicable
if (localization && field.localized) {
mergeLocaleActions.push(async () => {
const localeData = await localization.localeCodes.reduce(
async (localizedValuesPromise: Promise<JsonObject>, locale) => {
const localizedValues = await localizedValuesPromise
const fieldValue =
locale === req.locale
? siblingData[field.name]
: siblingDocWithLocales?.[field.name]?.[locale]
// const result = await localizedValues
// update locale value if it's not undefined
if (typeof fieldValue !== 'undefined') {
return {
...localizedValues,
[locale]: fieldValue,
}
}
return localizedValuesPromise
},
Promise.resolve({}),
)
// If there are locales with data, set the data
if (Object.keys(localeData).length > 0) {
siblingData[field.name] = localeData
}
})
}
}
switch (field.type) {
case 'array': {
const rows = siblingData[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
promises.push(
traverseFields({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
fields: field.fields,
global,
mergeLocaleActions,
operation,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row as JsonObject,
siblingDoc: getExistingRowDoc(row as JsonObject, siblingDoc[field.name]),
siblingDocWithLocales: getExistingRowDoc(
row as JsonObject,
siblingDocWithLocales[field.name],
),
skipValidation: skipValidationFromHere,
}),
)
})
await Promise.all(promises)
}
break
}
case 'blocks': {
const rows = siblingData[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
const rowSiblingDoc = getExistingRowDoc(row as JsonObject, siblingDoc[field.name])
const rowSiblingDocWithLocales = getExistingRowDoc(
row as JsonObject,
siblingDocWithLocales ? siblingDocWithLocales[field.name] : {},
)
const blockTypeToMatch = (row as JsonObject).blockType || rowSiblingDoc.blockType
const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch)
if (block) {
promises.push(
traverseFields({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
fields: block.fields,
global,
mergeLocaleActions,
operation,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row as JsonObject,
siblingDoc: rowSiblingDoc,
siblingDocWithLocales: rowSiblingDocWithLocales,
skipValidation: skipValidationFromHere,
}),
)
}
})
await Promise.all(promises)
}
break
}
case 'collapsible':
case 'row': {
await traverseFields({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
fields: field.fields,
global,
mergeLocaleActions,
operation,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
siblingDocWithLocales,
skipValidation: skipValidationFromHere,
})
break
}
case 'group': {
if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
if (typeof siblingDocWithLocales[field.name] !== 'object') {
siblingDocWithLocales[field.name] = {}
}
await traverseFields({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
fields: field.fields,
global,
mergeLocaleActions,
operation,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData[field.name] as JsonObject,
siblingDoc: siblingDoc[field.name] as JsonObject,
siblingDocWithLocales: siblingDocWithLocales[field.name] as JsonObject,
skipValidation: skipValidationFromHere,
})
break
}
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 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.beforeChange?.length) {
await editor.hooks.beforeChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
docWithLocales,
errors,
field,
global,
mergeLocaleActions,
operation,
originalDoc: doc,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name],
req,
schemaPath: parentSchemaPath,
siblingData,
siblingDocWithLocales,
skipValidation,
value: siblingData[field.name],
})
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
break
}
case 'tab': {
let tabSiblingData = siblingData
let tabSiblingDoc = siblingDoc
let tabSiblingDocWithLocales = siblingDocWithLocales
if (tabHasName(field)) {
if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
if (typeof siblingDocWithLocales[field.name] !== 'object') {
siblingDocWithLocales[field.name] = {}
}
tabSiblingData = siblingData[field.name] as JsonObject
tabSiblingDoc = siblingDoc[field.name] as JsonObject
tabSiblingDocWithLocales = siblingDocWithLocales[field.name] as JsonObject
}
await traverseFields({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
fields: field.fields,
global,
mergeLocaleActions,
operation,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: tabSiblingData,
siblingDoc: tabSiblingDoc,
siblingDocWithLocales: tabSiblingDocWithLocales,
skipValidation: skipValidationFromHere,
})
break
}
case 'tabs': {
await traverseFields({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
global,
mergeLocaleActions,
operation,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
siblingDocWithLocales,
skipValidation: skipValidationFromHere,
})
break
}
default: {
break
}
}
}

View File

@@ -1,3 +1,4 @@
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { ValidationFieldError } from '../../../errors/index.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
@@ -5,7 +6,11 @@ import type { RequestContext } from '../../../index.js'
import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { getExistingRowDoc } from './getExistingRowDoc.js'
type Args = {
collection: null | SanitizedCollectionConfig
@@ -98,3 +103,475 @@ export const traverseFields = async ({
await Promise.all(promises)
}
type PromiseArgs = {
collection: null | SanitizedCollectionConfig
context: RequestContext
data: JsonObject
doc: JsonObject
docWithLocales: JsonObject
errors: ValidationFieldError[]
field: Field | TabAsField
/**
* The index of the field as it appears in the parent's fields array. This is used to construct the field path / schemaPath
* for unnamed fields like rows and collapsibles.
*/
fieldIndex: number
global: null | SanitizedGlobalConfig
id?: number | string
mergeLocaleActions: (() => Promise<void>)[]
operation: Operation
/**
* The parent's path.
*/
parentPath: (number | string)[]
/**
* The parent's schemaPath (path without indexes).
*/
parentSchemaPath: string[]
req: PayloadRequest
siblingData: JsonObject
siblingDoc: JsonObject
siblingDocWithLocales?: JsonObject
skipValidation: boolean
}
// This function is responsible for the following actions, in order:
// - Run condition
// - Execute field hooks
// - Validate data
// - Transform data for storage
// - beforeDuplicate hooks (if duplicate)
// - Unflatten locales
export const promise = async ({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
field,
fieldIndex,
global,
mergeLocaleActions,
operation,
parentPath,
parentSchemaPath,
req,
siblingData,
siblingDoc,
siblingDocWithLocales,
skipValidation,
}: PromiseArgs): Promise<void> => {
const passesCondition = field.admin?.condition
? Boolean(field.admin.condition(data, siblingData, { user: req.user }))
: true
let skipValidationFromHere = skipValidation || !passesCondition
const { localization } = req.payload.config
const defaultLocale = localization ? localization?.defaultLocale : 'en'
const operationLocale = req.locale || defaultLocale
const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({
field,
index: fieldIndex,
parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data
parentPath: parentPath.join('.'),
parentSchemaPath: parentSchemaPath.join('.'),
})
const fieldPath = _fieldPath ? _fieldPath.split('.') : []
const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : []
if (fieldAffectsData(field)) {
// skip validation if the field is localized and the incoming data is null
if (field.localized && operationLocale !== defaultLocale) {
if (['array', 'blocks'].includes(field.type) && siblingData[field.name] === null) {
skipValidationFromHere = true
}
}
// Execute hooks
if (field.hooks?.beforeChange) {
await field.hooks.beforeChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name],
req,
schemaPath: parentSchemaPath,
siblingData,
siblingDocWithLocales,
value: siblingData[field.name],
})
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
// Validate
if (!skipValidationFromHere && 'validate' in field && field.validate) {
const valueToValidate = siblingData[field.name]
let jsonError: object
if (field.type === 'json' && typeof siblingData[field.name] === 'string') {
try {
JSON.parse(siblingData[field.name] as string)
} catch (e) {
jsonError = e
}
}
const validationResult = await field.validate(
valueToValidate as never,
{
...field,
id,
collectionSlug: collection?.slug,
data: deepMergeWithSourceArrays(doc, data),
jsonError,
operation,
preferences: { fields: {} },
previousValue: siblingDoc[field.name],
req,
siblingData: deepMergeWithSourceArrays(siblingDoc, siblingData),
} as any,
)
if (typeof validationResult === 'string') {
errors.push({
message: validationResult,
path: fieldPath.join('.'),
})
}
}
// Push merge locale action if applicable
if (localization && field.localized) {
mergeLocaleActions.push(async () => {
const localeData = await localization.localeCodes.reduce(
async (localizedValuesPromise: Promise<JsonObject>, locale) => {
const localizedValues = await localizedValuesPromise
const fieldValue =
locale === req.locale
? siblingData[field.name]
: siblingDocWithLocales?.[field.name]?.[locale]
// const result = await localizedValues
// update locale value if it's not undefined
if (typeof fieldValue !== 'undefined') {
return {
...localizedValues,
[locale]: fieldValue,
}
}
return localizedValuesPromise
},
Promise.resolve({}),
)
// If there are locales with data, set the data
if (Object.keys(localeData).length > 0) {
siblingData[field.name] = localeData
}
})
}
}
switch (field.type) {
case 'array': {
const rows = siblingData[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
promises.push(
traverseFields({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
fields: field.fields,
global,
mergeLocaleActions,
operation,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row as JsonObject,
siblingDoc: getExistingRowDoc(row as JsonObject, siblingDoc[field.name]),
siblingDocWithLocales: getExistingRowDoc(
row as JsonObject,
siblingDocWithLocales[field.name],
),
skipValidation: skipValidationFromHere,
}),
)
})
await Promise.all(promises)
}
break
}
case 'blocks': {
const rows = siblingData[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
const rowSiblingDoc = getExistingRowDoc(row as JsonObject, siblingDoc[field.name])
const rowSiblingDocWithLocales = getExistingRowDoc(
row as JsonObject,
siblingDocWithLocales ? siblingDocWithLocales[field.name] : {},
)
const blockTypeToMatch = (row as JsonObject).blockType || rowSiblingDoc.blockType
const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch)
if (block) {
promises.push(
traverseFields({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
fields: block.fields,
global,
mergeLocaleActions,
operation,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row as JsonObject,
siblingDoc: rowSiblingDoc,
siblingDocWithLocales: rowSiblingDocWithLocales,
skipValidation: skipValidationFromHere,
}),
)
}
})
await Promise.all(promises)
}
break
}
case 'collapsible':
case 'row': {
await traverseFields({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
fields: field.fields,
global,
mergeLocaleActions,
operation,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
siblingDocWithLocales,
skipValidation: skipValidationFromHere,
})
break
}
case 'group': {
if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
if (typeof siblingDocWithLocales[field.name] !== 'object') {
siblingDocWithLocales[field.name] = {}
}
await traverseFields({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
fields: field.fields,
global,
mergeLocaleActions,
operation,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData[field.name] as JsonObject,
siblingDoc: siblingDoc[field.name] as JsonObject,
siblingDocWithLocales: siblingDocWithLocales[field.name] as JsonObject,
skipValidation: skipValidationFromHere,
})
break
}
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 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.beforeChange?.length) {
await editor.hooks.beforeChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
docWithLocales,
errors,
field,
global,
mergeLocaleActions,
operation,
originalDoc: doc,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name],
req,
schemaPath: parentSchemaPath,
siblingData,
siblingDocWithLocales,
skipValidation,
value: siblingData[field.name],
})
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
break
}
case 'tab': {
let tabSiblingData = siblingData
let tabSiblingDoc = siblingDoc
let tabSiblingDocWithLocales = siblingDocWithLocales
if (tabHasName(field)) {
if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
if (typeof siblingDocWithLocales[field.name] !== 'object') {
siblingDocWithLocales[field.name] = {}
}
tabSiblingData = siblingData[field.name] as JsonObject
tabSiblingDoc = siblingDoc[field.name] as JsonObject
tabSiblingDocWithLocales = siblingDocWithLocales[field.name] as JsonObject
}
await traverseFields({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
fields: field.fields,
global,
mergeLocaleActions,
operation,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: tabSiblingData,
siblingDoc: tabSiblingDoc,
siblingDocWithLocales: tabSiblingDocWithLocales,
skipValidation: skipValidationFromHere,
})
break
}
case 'tabs': {
await traverseFields({
id,
collection,
context,
data,
doc,
docWithLocales,
errors,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
global,
mergeLocaleActions,
operation,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
siblingDocWithLocales,
skipValidation: skipValidationFromHere,
})
break
}
default: {
break
}
}
}

View File

@@ -1,361 +0,0 @@
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, PayloadRequest } from '../../../types/index.js'
import type { Field, FieldHookArgs, TabAsField } from '../../config/types.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { runBeforeDuplicateHooks } from './runHook.js'
import { traverseFields } from './traverseFields.js'
type Args<T> = {
collection: null | SanitizedCollectionConfig
context: RequestContext
doc: T
field: Field | TabAsField
fieldIndex: number
id?: number | string
overrideAccess: boolean
parentPath: (number | string)[]
parentSchemaPath: string[]
req: PayloadRequest
siblingDoc: JsonObject
}
export const promise = async <T>({
id,
collection,
context,
doc,
field,
fieldIndex,
overrideAccess,
parentPath,
parentSchemaPath,
req,
siblingDoc,
}: Args<T>): Promise<void> => {
const { localization } = req.payload.config
const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({
field,
index: fieldIndex,
parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data
parentPath: parentPath.join('.'),
parentSchemaPath: parentSchemaPath.join('.'),
})
const fieldPath = _fieldPath ? _fieldPath.split('.') : []
const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : []
// Handle unnamed tabs
if (field.type === 'tab' && !tabHasName(field)) {
await traverseFields({
id,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingDoc,
})
return
}
if (fieldAffectsData(field)) {
let fieldData = siblingDoc?.[field.name]
const fieldIsLocalized = field.localized && localization
// Run field beforeDuplicate hooks
if (Array.isArray(field.hooks?.beforeDuplicate)) {
if (fieldIsLocalized) {
const localeData = await localization.localeCodes.reduce(
async (localizedValuesPromise: Promise<JsonObject>, locale) => {
const localizedValues = await localizedValuesPromise
const beforeDuplicateArgs: FieldHookArgs = {
collection,
context,
data: doc,
field,
global: undefined,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name]?.[locale],
req,
schemaPath: parentSchemaPath,
siblingData: siblingDoc,
siblingDocWithLocales: siblingDoc,
value: siblingDoc[field.name]?.[locale],
}
const hookResult = await runBeforeDuplicateHooks(beforeDuplicateArgs)
if (typeof hookResult !== 'undefined') {
return {
...localizedValues,
[locale]: hookResult,
}
}
return localizedValuesPromise
},
Promise.resolve({}),
)
siblingDoc[field.name] = localeData
} else {
const beforeDuplicateArgs: FieldHookArgs = {
collection,
context,
data: doc,
field,
global: undefined,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name],
req,
schemaPath: parentSchemaPath,
siblingData: siblingDoc,
siblingDocWithLocales: siblingDoc,
value: siblingDoc[field.name],
}
const hookResult = await runBeforeDuplicateHooks(beforeDuplicateArgs)
if (typeof hookResult !== 'undefined') {
siblingDoc[field.name] = hookResult
}
}
}
// First, for any localized fields, we will loop over locales
// and if locale data is present, traverse the sub fields.
// There are only a few different fields where this is possible.
if (fieldIsLocalized) {
if (typeof fieldData !== 'object' || fieldData === null) {
siblingDoc[field.name] = {}
fieldData = siblingDoc[field.name]
}
const promises = []
localization.localeCodes.forEach((locale) => {
if (fieldData[locale]) {
switch (field.type) {
case 'array': {
const rows = fieldData[locale]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
promises.push(
traverseFields({
id,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingDoc: row,
}),
)
})
}
break
}
case 'blocks': {
const rows = fieldData[locale]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
const blockTypeToMatch = row.blockType
const block = field.blocks.find(
(blockType) => blockType.slug === blockTypeToMatch,
)
promises.push(
traverseFields({
id,
collection,
context,
doc,
fields: block.fields,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingDoc: row,
}),
)
})
}
break
}
case 'group':
case 'tab': {
promises.push(
traverseFields({
id,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
path: fieldSchemaPath,
req,
schemaPath: fieldSchemaPath,
siblingDoc: fieldData[locale],
}),
)
break
}
}
}
})
await Promise.all(promises)
} else {
// If the field is not localized, but it affects data,
// we need to further traverse its children
// so the child fields can run beforeDuplicate hooks
switch (field.type) {
case 'array': {
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
promises.push(
traverseFields({
id,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingDoc: row,
}),
)
})
await Promise.all(promises)
}
break
}
case 'blocks': {
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
const blockTypeToMatch = row.blockType
const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch)
if (block) {
;(row as JsonObject).blockType = blockTypeToMatch
promises.push(
traverseFields({
id,
collection,
context,
doc,
fields: block.fields,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingDoc: row,
}),
)
}
})
await Promise.all(promises)
}
break
}
case 'group':
case 'tab': {
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
const groupDoc = siblingDoc[field.name] as Record<string, unknown>
await traverseFields({
id,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingDoc: groupDoc as JsonObject,
})
break
}
}
}
} else {
// Finally, we traverse fields which do not affect data here
switch (field.type) {
case 'collapsible':
case 'row': {
await traverseFields({
id,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingDoc,
})
break
}
case 'tabs': {
await traverseFields({
id,
collection,
context,
doc,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingDoc,
})
break
}
default: {
break
}
}
}
}

View File

@@ -1,9 +1,11 @@
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, PayloadRequest } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import type { Field, FieldHookArgs, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { runBeforeDuplicateHooks } from './runHook.js'
type Args<T> = {
collection: null | SanitizedCollectionConfig
@@ -50,3 +52,355 @@ export const traverseFields = async <T>({
})
await Promise.all(promises)
}
type PromiseArgs<T> = {
collection: null | SanitizedCollectionConfig
context: RequestContext
doc: T
field: Field | TabAsField
fieldIndex: number
id?: number | string
overrideAccess: boolean
parentPath: (number | string)[]
parentSchemaPath: string[]
req: PayloadRequest
siblingDoc: JsonObject
}
export const promise = async <T>({
id,
collection,
context,
doc,
field,
fieldIndex,
overrideAccess,
parentPath,
parentSchemaPath,
req,
siblingDoc,
}: PromiseArgs<T>): Promise<void> => {
const { localization } = req.payload.config
const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({
field,
index: fieldIndex,
parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data
parentPath: parentPath.join('.'),
parentSchemaPath: parentSchemaPath.join('.'),
})
const fieldPath = _fieldPath ? _fieldPath.split('.') : []
const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : []
// Handle unnamed tabs
if (field.type === 'tab' && !tabHasName(field)) {
await traverseFields({
id,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingDoc,
})
return
}
if (fieldAffectsData(field)) {
let fieldData = siblingDoc?.[field.name]
const fieldIsLocalized = field.localized && localization
// Run field beforeDuplicate hooks
if (Array.isArray(field.hooks?.beforeDuplicate)) {
if (fieldIsLocalized) {
const localeData = await localization.localeCodes.reduce(
async (localizedValuesPromise: Promise<JsonObject>, locale) => {
const localizedValues = await localizedValuesPromise
const beforeDuplicateArgs: FieldHookArgs = {
collection,
context,
data: doc,
field,
global: undefined,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name]?.[locale],
req,
schemaPath: parentSchemaPath,
siblingData: siblingDoc,
siblingDocWithLocales: siblingDoc,
value: siblingDoc[field.name]?.[locale],
}
const hookResult = await runBeforeDuplicateHooks(beforeDuplicateArgs)
if (typeof hookResult !== 'undefined') {
return {
...localizedValues,
[locale]: hookResult,
}
}
return localizedValuesPromise
},
Promise.resolve({}),
)
siblingDoc[field.name] = localeData
} else {
const beforeDuplicateArgs: FieldHookArgs = {
collection,
context,
data: doc,
field,
global: undefined,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name],
req,
schemaPath: parentSchemaPath,
siblingData: siblingDoc,
siblingDocWithLocales: siblingDoc,
value: siblingDoc[field.name],
}
const hookResult = await runBeforeDuplicateHooks(beforeDuplicateArgs)
if (typeof hookResult !== 'undefined') {
siblingDoc[field.name] = hookResult
}
}
}
// First, for any localized fields, we will loop over locales
// and if locale data is present, traverse the sub fields.
// There are only a few different fields where this is possible.
if (fieldIsLocalized) {
if (typeof fieldData !== 'object' || fieldData === null) {
siblingDoc[field.name] = {}
fieldData = siblingDoc[field.name]
}
const promises = []
localization.localeCodes.forEach((locale) => {
if (fieldData[locale]) {
switch (field.type) {
case 'array': {
const rows = fieldData[locale]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
promises.push(
traverseFields({
id,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingDoc: row,
}),
)
})
}
break
}
case 'blocks': {
const rows = fieldData[locale]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
const blockTypeToMatch = row.blockType
const block = field.blocks.find(
(blockType) => blockType.slug === blockTypeToMatch,
)
promises.push(
traverseFields({
id,
collection,
context,
doc,
fields: block.fields,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingDoc: row,
}),
)
})
}
break
}
case 'group':
case 'tab': {
promises.push(
traverseFields({
id,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
path: fieldSchemaPath,
req,
schemaPath: fieldSchemaPath,
siblingDoc: fieldData[locale],
}),
)
break
}
}
}
})
await Promise.all(promises)
} else {
// If the field is not localized, but it affects data,
// we need to further traverse its children
// so the child fields can run beforeDuplicate hooks
switch (field.type) {
case 'array': {
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
promises.push(
traverseFields({
id,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingDoc: row,
}),
)
})
await Promise.all(promises)
}
break
}
case 'blocks': {
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
const blockTypeToMatch = row.blockType
const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch)
if (block) {
;(row as JsonObject).blockType = blockTypeToMatch
promises.push(
traverseFields({
id,
collection,
context,
doc,
fields: block.fields,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingDoc: row,
}),
)
}
})
await Promise.all(promises)
}
break
}
case 'group':
case 'tab': {
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
const groupDoc = siblingDoc[field.name] as Record<string, unknown>
await traverseFields({
id,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingDoc: groupDoc as JsonObject,
})
break
}
}
}
} else {
// Finally, we traverse fields which do not affect data here
switch (field.type) {
case 'collapsible':
case 'row': {
await traverseFields({
id,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingDoc,
})
break
}
case 'tabs': {
await traverseFields({
id,
collection,
context,
doc,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingDoc,
})
break
}
default: {
break
}
}
}
}

View File

@@ -1,552 +0,0 @@
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig, TypeWithID } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, JsonValue, PayloadRequest } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName, valueIsValueWithRelation } from '../../config/types.js'
import { getDefaultValue } from '../../getDefaultValue.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { cloneDataFromOriginalDoc } from '../beforeChange/cloneDataFromOriginalDoc.js'
import { getExistingRowDoc } from '../beforeChange/getExistingRowDoc.js'
import { traverseFields } from './traverseFields.js'
type Args<T> = {
collection: null | SanitizedCollectionConfig
context: RequestContext
data: T
/**
* The original data (not modified by any hooks)
*/
doc: T
field: Field | TabAsField
fieldIndex: number
global: null | SanitizedGlobalConfig
id?: number | string
operation: 'create' | 'update'
overrideAccess: boolean
parentPath: (number | string)[]
parentSchemaPath: string[]
req: PayloadRequest
siblingData: JsonObject
/**
* The original siblingData (not modified by any hooks)
*/
siblingDoc: JsonObject
}
// This function is responsible for the following actions, in order:
// - Sanitize incoming data
// - Execute field hooks
// - Execute field access control
// - Merge original document data into incoming data
// - Compute default values for undefined fields
export const promise = async <T>({
id,
collection,
context,
data,
doc,
field,
fieldIndex,
global,
operation,
overrideAccess,
parentPath,
parentSchemaPath,
req,
siblingData,
siblingDoc,
}: Args<T>): Promise<void> => {
const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({
field,
index: fieldIndex,
parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data
parentPath: parentPath.join('.'),
parentSchemaPath: parentSchemaPath.join('.'),
})
const fieldPath = _fieldPath ? _fieldPath.split('.') : []
const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : []
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 'array':
case 'blocks': {
// Handle cases of arrays being intentionally set to 0
if (siblingData[field.name] === '0' || siblingData[field.name] === 0) {
siblingData[field.name] = []
}
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 '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 'point': {
if (Array.isArray(siblingData[field.name])) {
siblingData[field.name] = (siblingData[field.name] as string[]).map((coordinate, i) => {
if (typeof coordinate === 'string') {
const value = siblingData[field.name][i] as string
const trimmed = value.trim()
return trimmed.length === 0 ? null : parseFloat(trimmed)
}
return coordinate
})
}
break
}
case 'relationship':
case 'upload': {
if (
siblingData[field.name] === '' ||
siblingData[field.name] === 'none' ||
siblingData[field.name] === 'null' ||
siblingData[field.name] === null
) {
if (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: { relationTo: string; value: JsonValue }, i) => {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === relatedDoc.relationTo,
)
if (
typeof relatedDoc.value === 'object' &&
relatedDoc.value &&
'id' in relatedDoc.value
) {
relatedDoc.value = relatedDoc.value.id
}
if (relatedCollection?.fields) {
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.hasMany !== true && valueIsValueWithRelation(value)) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === value.relationTo,
)
if (typeof value.value === 'object' && value.value && 'id' in value.value) {
value.value = (value.value as TypeWithID).id
}
if (relatedCollection?.fields) {
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,
)
if (typeof relatedDoc === 'object' && relatedDoc && 'id' in relatedDoc) {
value[i] = relatedDoc.id
}
if (relatedCollection?.fields) {
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.hasMany !== true && value) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === field.relationTo,
)
if (typeof value === 'object' && value && 'id' in value) {
siblingData[field.name] = value.id
}
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name] = parseFloat(value as string)
}
}
}
}
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
}
default: {
break
}
}
// Execute hooks
if (field.hooks?.beforeValidate) {
await field.hooks.beforeValidate.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
overrideAccess,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingData[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingData[field.name],
})
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]({ id, data, doc, req, siblingData })
if (!result) {
delete siblingData[field.name]
}
}
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') {
siblingData[field.name] = cloneDataFromOriginalDoc(siblingDoc[field.name])
// Otherwise compute default value
} else if (typeof field.defaultValue !== 'undefined') {
siblingData[field.name] = await getDefaultValue({
defaultValue: field.defaultValue,
locale: req.locale,
user: req.user,
value: siblingData[field.name],
})
}
}
}
// Traverse subfields
switch (field.type) {
case 'array': {
const rows = siblingData[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
promises.push(
traverseFields({
id,
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row as JsonObject,
siblingDoc: getExistingRowDoc(row as JsonObject, siblingDoc[field.name]),
}),
)
})
await Promise.all(promises)
}
break
}
case 'blocks': {
const rows = siblingData[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
const rowSiblingDoc = getExistingRowDoc(row as JsonObject, siblingDoc[field.name])
const blockTypeToMatch = (row as JsonObject).blockType || rowSiblingDoc.blockType
const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch)
if (block) {
;(row as JsonObject).blockType = blockTypeToMatch
promises.push(
traverseFields({
id,
collection,
context,
data,
doc,
fields: block.fields,
global,
operation,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row as JsonObject,
siblingDoc: rowSiblingDoc,
}),
)
}
})
await Promise.all(promises)
}
break
}
case 'collapsible':
case 'row': {
await traverseFields({
id,
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
})
break
}
case 'group': {
if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
const groupData = siblingData[field.name] as Record<string, unknown>
const groupDoc = siblingDoc[field.name] as Record<string, unknown>
await traverseFields({
id,
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: groupData as JsonObject,
siblingDoc: groupDoc as JsonObject,
})
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.beforeValidate?.length) {
await editor.hooks.beforeValidate.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
overrideAccess,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingData[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingData[field.name],
})
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
break
}
case 'tab': {
let tabSiblingData
let tabSiblingDoc
if (tabHasName(field)) {
if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
tabSiblingData = siblingData[field.name] as Record<string, unknown>
tabSiblingDoc = siblingDoc[field.name] as Record<string, unknown>
} else {
tabSiblingData = siblingData
tabSiblingDoc = siblingDoc
}
await traverseFields({
id,
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: tabSiblingData,
siblingDoc: tabSiblingDoc,
})
break
}
case 'tabs': {
await traverseFields({
id,
collection,
context,
data,
doc,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
})
break
}
default: {
break
}
}
}

View File

@@ -1,10 +1,16 @@
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig, TypeWithID } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, PayloadRequest } from '../../../types/index.js'
import type { JsonObject, JsonValue, PayloadRequest } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName, valueIsValueWithRelation } from '../../config/types.js'
import { getDefaultValue } from '../../getDefaultValue.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { cloneDataFromOriginalDoc } from '../beforeChange/cloneDataFromOriginalDoc.js'
import { getExistingRowDoc } from '../beforeChange/getExistingRowDoc.js'
type Args<T> = {
collection: null | SanitizedCollectionConfig
@@ -69,3 +75,541 @@ export const traverseFields = async <T>({
})
await Promise.all(promises)
}
type PromiseArgs<T> = {
collection: null | SanitizedCollectionConfig
context: RequestContext
data: T
/**
* The original data (not modified by any hooks)
*/
doc: T
field: Field | TabAsField
fieldIndex: number
global: null | SanitizedGlobalConfig
id?: number | string
operation: 'create' | 'update'
overrideAccess: boolean
parentPath: (number | string)[]
parentSchemaPath: string[]
req: PayloadRequest
siblingData: JsonObject
/**
* The original siblingData (not modified by any hooks)
*/
siblingDoc: JsonObject
}
// This function is responsible for the following actions, in order:
// - Sanitize incoming data
// - Execute field hooks
// - Execute field access control
// - Merge original document data into incoming data
// - Compute default values for undefined fields
export const promise = async <T>({
id,
collection,
context,
data,
doc,
field,
fieldIndex,
global,
operation,
overrideAccess,
parentPath,
parentSchemaPath,
req,
siblingData,
siblingDoc,
}: PromiseArgs<T>): Promise<void> => {
const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({
field,
index: fieldIndex,
parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data
parentPath: parentPath.join('.'),
parentSchemaPath: parentSchemaPath.join('.'),
})
const fieldPath = _fieldPath ? _fieldPath.split('.') : []
const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : []
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 'array':
case 'blocks': {
// Handle cases of arrays being intentionally set to 0
if (siblingData[field.name] === '0' || siblingData[field.name] === 0) {
siblingData[field.name] = []
}
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 '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 'point': {
if (Array.isArray(siblingData[field.name])) {
siblingData[field.name] = (siblingData[field.name] as string[]).map((coordinate, i) => {
if (typeof coordinate === 'string') {
const value = siblingData[field.name][i] as string
const trimmed = value.trim()
return trimmed.length === 0 ? null : parseFloat(trimmed)
}
return coordinate
})
}
break
}
case 'relationship':
case 'upload': {
if (
siblingData[field.name] === '' ||
siblingData[field.name] === 'none' ||
siblingData[field.name] === 'null' ||
siblingData[field.name] === null
) {
if (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: { relationTo: string; value: JsonValue }, i) => {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === relatedDoc.relationTo,
)
if (
typeof relatedDoc.value === 'object' &&
relatedDoc.value &&
'id' in relatedDoc.value
) {
relatedDoc.value = relatedDoc.value.id
}
if (relatedCollection?.fields) {
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.hasMany !== true && valueIsValueWithRelation(value)) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === value.relationTo,
)
if (typeof value.value === 'object' && value.value && 'id' in value.value) {
value.value = (value.value as TypeWithID).id
}
if (relatedCollection?.fields) {
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,
)
if (typeof relatedDoc === 'object' && relatedDoc && 'id' in relatedDoc) {
value[i] = relatedDoc.id
}
if (relatedCollection?.fields) {
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.hasMany !== true && value) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === field.relationTo,
)
if (typeof value === 'object' && value && 'id' in value) {
siblingData[field.name] = value.id
}
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name] = parseFloat(value as string)
}
}
}
}
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
}
default: {
break
}
}
// Execute hooks
if (field.hooks?.beforeValidate) {
await field.hooks.beforeValidate.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
overrideAccess,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingData[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingData[field.name],
})
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]({ id, data, doc, req, siblingData })
if (!result) {
delete siblingData[field.name]
}
}
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') {
siblingData[field.name] = cloneDataFromOriginalDoc(siblingDoc[field.name])
// Otherwise compute default value
} else if (typeof field.defaultValue !== 'undefined') {
siblingData[field.name] = await getDefaultValue({
defaultValue: field.defaultValue,
locale: req.locale,
user: req.user,
value: siblingData[field.name],
})
}
}
}
// Traverse subfields
switch (field.type) {
case 'array': {
const rows = siblingData[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
promises.push(
traverseFields({
id,
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row as JsonObject,
siblingDoc: getExistingRowDoc(row as JsonObject, siblingDoc[field.name]),
}),
)
})
await Promise.all(promises)
}
break
}
case 'blocks': {
const rows = siblingData[field.name]
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
const rowSiblingDoc = getExistingRowDoc(row as JsonObject, siblingDoc[field.name])
const blockTypeToMatch = (row as JsonObject).blockType || rowSiblingDoc.blockType
const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch)
if (block) {
;(row as JsonObject).blockType = blockTypeToMatch
promises.push(
traverseFields({
id,
collection,
context,
data,
doc,
fields: block.fields,
global,
operation,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row as JsonObject,
siblingDoc: rowSiblingDoc,
}),
)
}
})
await Promise.all(promises)
}
break
}
case 'collapsible':
case 'row': {
await traverseFields({
id,
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
})
break
}
case 'group': {
if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
const groupData = siblingData[field.name] as Record<string, unknown>
const groupDoc = siblingDoc[field.name] as Record<string, unknown>
await traverseFields({
id,
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: groupData as JsonObject,
siblingDoc: groupDoc as JsonObject,
})
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.beforeValidate?.length) {
await editor.hooks.beforeValidate.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
overrideAccess,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingData[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingData[field.name],
})
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
break
}
case 'tab': {
let tabSiblingData
let tabSiblingDoc
if (tabHasName(field)) {
if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
tabSiblingData = siblingData[field.name] as Record<string, unknown>
tabSiblingDoc = siblingDoc[field.name] as Record<string, unknown>
} else {
tabSiblingData = siblingData
tabSiblingDoc = siblingDoc
}
await traverseFields({
id,
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: tabSiblingData,
siblingDoc: tabSiblingDoc,
})
break
}
case 'tabs': {
await traverseFields({
id,
collection,
context,
data,
doc,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
})
break
}
default: {
break
}
}
}

View File

@@ -3,13 +3,10 @@ import type { PayloadRequest, Where } from '../../types/index.js'
import executeAccess from '../../auth/executeAccess.js'
import { combineQueries } from '../../database/combineQueries.js'
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
import {
buildVersionGlobalFields,
type GlobalSlug,
type SanitizedGlobalConfig,
} from '../../index.js'
import { validateQueryPaths } from '../../database/queryValidation/validateSearchParams.js'
import { type GlobalSlug, type SanitizedGlobalConfig } from '../../index.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { buildVersionGlobalFields } from '../../versions/buildGlobalFields.js'
export type Arguments = {
disableErrors?: boolean

View File

@@ -5,7 +5,7 @@ import type { SanitizedGlobalConfig } from '../config/types.js'
import executeAccess from '../../auth/executeAccess.js'
import { combineQueries } from '../../database/combineQueries.js'
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths.js'
import { validateQueryPaths } from '../../database/queryValidation/validateSearchParams.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields.js'

View File

@@ -1,7 +1,7 @@
import type { GlobalSlug, Payload, RequestContext, TypedLocale } from '../../../index.js'
import type { Document, PayloadRequest, Where } from '../../../types/index.js'
import { APIError } from '../../../errors/index.js'
import { APIError } from '../../../errors/APIError.js'
import { createLocalReq } from '../../../utilities/createLocalReq.js'
import { countGlobalVersionsOperation } from '../countGlobalVersions.js'

View File

@@ -20,9 +20,9 @@ import { afterChange } from '../../fields/hooks/afterChange/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { beforeChange } from '../../fields/hooks/beforeChange/index.js'
import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js'
import { deepCopyObjectSimple } from '../../index.js'
import { checkDocumentLockStatus } from '../../utilities/checkDocumentLockStatus.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js'
import { getSelectMode } from '../../utilities/getSelectMode.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'

View File

@@ -1006,8 +1006,10 @@ export { readMigrationFiles } from './database/migrations/readMigrationFiles.js'
export { writeMigrationIndex } from './database/migrations/writeMigrationIndex.js'
export type * from './database/queryValidation/types.js'
export type { EntityPolicies, PathToQuery } from './database/queryValidation/types.js'
export { validateQueryPaths } from './database/queryValidation/validateQueryPaths.js'
export { validateSearchParam } from './database/queryValidation/validateSearchParams.js'
export {
validateQueryPaths,
validateSearchParam,
} from './database/queryValidation/validateSearchParams.js'
export type {
BaseDatabaseAdapter,
BeginTransaction,
@@ -1200,8 +1202,10 @@ export type {
export { getDefaultValue } from './fields/getDefaultValue.js'
export { traverseFields as afterChangeTraverseFields } from './fields/hooks/afterChange/traverseFields.js'
export { promise as afterReadPromise } from './fields/hooks/afterRead/promise.js'
export { traverseFields as afterReadTraverseFields } from './fields/hooks/afterRead/traverseFields.js'
export {
promise as afterReadPromise,
traverseFields as afterReadTraverseFields,
} from './fields/hooks/afterRead/traverseFields.js'
export { traverseFields as beforeChangeTraverseFields } from './fields/hooks/beforeChange/traverseFields.js'
export { traverseFields as beforeValidateTraverseFields } from './fields/hooks/beforeValidate/traverseFields.js'
export { default as sortableFieldTypes } from './fields/sortableFieldTypes.js'
@@ -1298,12 +1302,7 @@ export * from './types/index.js'
export { getFileByPath } from './uploads/getFileByPath.js'
export type * from './uploads/types.js'
export { commitTransaction } from './utilities/commitTransaction.js'
export {
configToJSONSchema,
entityToJSONSchema,
fieldsToJSONSchema,
withNullableJSONSchemaType,
} from './utilities/configToJSONSchema.js'
export { configToJSONSchema, entityToJSONSchema } from './utilities/configToJSONSchema.js'
export { createArrayFromCommaDelineated } from './utilities/createArrayFromCommaDelineated.js'
export { createLocalReq } from './utilities/createLocalReq.js'
export {
@@ -1322,13 +1321,14 @@ export {
type CustomVersionParser,
} from './utilities/dependencies/dependencyChecker.js'
export { getDependencies } from './utilities/dependencies/getDependencies.js'
export { fieldsToJSONSchema, withNullableJSONSchemaType } from './utilities/fieldsToJSONSchema.js'
export {
findUp,
findUpSync,
pathExistsAndIsAccessible,
pathExistsAndIsAccessibleSync,
} from './utilities/findUp.js'
export { default as flattenTopLevelFields } from './utilities/flattenTopLevelFields.js'
export { flattenTopLevelFields } from './utilities/flattenTopLevelFields.js'
export { formatErrors } from './utilities/formatErrors.js'
export { formatLabels, formatNames, toWords } from './utilities/formatLabels.js'
export { getCollectionIDFieldTypes } from './utilities/getCollectionIDFieldTypes.js'

View File

@@ -3,7 +3,7 @@ import type { JSONSchema4 } from 'json-schema'
import type { SanitizedConfig } from '../../config/types.js'
import type { JobsConfig } from './types/index.js'
import { fieldsToJSONSchema } from '../../utilities/configToJSONSchema.js'
import { fieldsToJSONSchema } from '../../utilities/fieldsToJSONSchema.js'
export function generateJobsJSONSchemas(
config: SanitizedConfig,

View File

@@ -1,12 +1,7 @@
import type { Payload, PayloadRequest, RunningJob, TypedJobs } from '../index.js'
import type { RunningJobFromTask } from './config/types/workflowTypes.js'
import {
createLocalReq,
type Payload,
type PayloadRequest,
type RunningJob,
type TypedJobs,
} from '../index.js'
import { createLocalReq } from '../utilities/createLocalReq.js'
import { runJobs } from './operations/runJobs/index.js'
export const getJobsLocalAPI = (payload: Payload) => ({

View File

@@ -1,4 +1,4 @@
import type { JSONSchema4, JSONSchema4TypeName } from 'json-schema'
import type { JSONSchema4 } from 'json-schema'
import pluralize from 'pluralize'
const { singular } = pluralize
@@ -6,55 +6,16 @@ const { singular } = pluralize
import type { Auth } from '../auth/types.js'
import type { SanitizedCollectionConfig } from '../collections/config/types.js'
import type { SanitizedConfig } from '../config/types.js'
import type { Field, FieldAffectingData, Option } from '../fields/config/types.js'
import type { Field, FieldAffectingData } from '../fields/config/types.js'
import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import { MissingEditorProp } from '../errors/MissingEditorProp.js'
import { fieldAffectsData, tabHasName } from '../fields/config/types.js'
import { generateJobsJSONSchemas } from '../queues/config/generateJobsJSONSchemas.js'
import { deepCopyObject } from './deepCopyObject.js'
import { fieldsToJSONSchema } from './fieldsToJSONSchema.js'
import { toWords } from './formatLabels.js'
import { getCollectionIDFieldTypes } from './getCollectionIDFieldTypes.js'
const fieldIsRequired = (field: Field) => {
const isConditional = Boolean(field?.admin && field?.admin?.condition)
if (isConditional) {
return false
}
const isMarkedRequired = 'required' in field && field.required === true
if (fieldAffectsData(field) && isMarkedRequired) {
return true
}
// if any subfields are required, this field is required
if ('fields' in field && field.type !== 'array') {
return field.fields.some((subField) => fieldIsRequired(subField))
}
// if any tab subfields have required fields, this field is required
if (field.type === 'tabs') {
return field.tabs.some((tab) => {
if ('name' in tab) {
return tab.fields.some((subField) => fieldIsRequired(subField))
}
return false
})
}
return false
}
function buildOptionEnums(options: Option[]): string[] {
return options.map((option) => {
if (typeof option === 'object' && 'value' in option) {
return option.value
}
return option
})
}
function generateEntitySchemas(
entities: (SanitizedCollectionConfig | SanitizedGlobalConfig)[],
): JSONSchema4 {
@@ -192,450 +153,6 @@ function generateDbEntitySchema(config: SanitizedConfig): JSONSchema4 {
}
}
/**
* Returns a JSON Schema Type with 'null' added if the field is not required.
*/
export function withNullableJSONSchemaType(
fieldType: JSONSchema4TypeName,
isRequired: boolean,
): JSONSchema4TypeName | JSONSchema4TypeName[] {
const fieldTypes = [fieldType]
if (isRequired) {
return fieldType
}
fieldTypes.push('null')
return fieldTypes
}
export function fieldsToJSONSchema(
/**
* Used for relationship fields, to determine whether to use a string or number type for the ID.
* While there is a default ID field type set by the db adapter, they can differ on a collection-level
* if they have custom ID fields.
*/
collectionIDFieldTypes: { [key: string]: 'number' | 'string' },
fields: Field[],
/**
* Allows you to define new top-level interfaces that can be re-used in the output schema.
*/
interfaceNameDefinitions: Map<string, JSONSchema4>,
config?: SanitizedConfig,
): {
properties: {
[k: string]: JSONSchema4
}
required: string[]
} {
const requiredFieldNames = new Set<string>()
return {
properties: Object.fromEntries(
fields.reduce((fieldSchemas, field) => {
const isRequired = fieldAffectsData(field) && fieldIsRequired(field)
if (isRequired) {
requiredFieldNames.add(field.name)
}
let fieldSchema: JSONSchema4
switch (field.type) {
case 'array': {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: {
type: 'object',
additionalProperties: false,
...fieldsToJSONSchema(
collectionIDFieldTypes,
field.fields,
interfaceNameDefinitions,
config,
),
},
}
if (field.interfaceName) {
interfaceNameDefinitions.set(field.interfaceName, fieldSchema)
fieldSchema = {
$ref: `#/definitions/${field.interfaceName}`,
}
}
break
}
case 'blocks': {
// Check for a case where no blocks are provided.
// We need to generate an empty array for this case, note that JSON schema 4 doesn't support empty arrays
// so the best we can get is `unknown[]`
const hasBlocks = Boolean(field.blocks.length)
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: hasBlocks
? {
oneOf: field.blocks.map((block) => {
const blockFieldSchemas = fieldsToJSONSchema(
collectionIDFieldTypes,
block.fields,
interfaceNameDefinitions,
config,
)
const blockSchema: JSONSchema4 = {
type: 'object',
additionalProperties: false,
properties: {
...blockFieldSchemas.properties,
blockType: {
const: block.slug,
},
},
required: ['blockType', ...blockFieldSchemas.required],
}
if (block.interfaceName) {
interfaceNameDefinitions.set(block.interfaceName, blockSchema)
return {
$ref: `#/definitions/${block.interfaceName}`,
}
}
return blockSchema
}),
}
: {},
}
break
}
case 'checkbox': {
fieldSchema = { type: withNullableJSONSchemaType('boolean', isRequired) }
break
}
case 'code':
case 'date':
case 'email':
case 'textarea': {
fieldSchema = { type: withNullableJSONSchemaType('string', isRequired) }
break
}
case 'collapsible':
case 'row': {
const childSchema = fieldsToJSONSchema(
collectionIDFieldTypes,
field.fields,
interfaceNameDefinitions,
config,
)
Object.entries(childSchema.properties).forEach(([propName, propSchema]) => {
fieldSchemas.set(propName, propSchema)
})
childSchema.required.forEach((propName) => {
requiredFieldNames.add(propName)
})
break
}
case 'group': {
fieldSchema = {
type: 'object',
additionalProperties: false,
...fieldsToJSONSchema(
collectionIDFieldTypes,
field.fields,
interfaceNameDefinitions,
config,
),
}
if (field.interfaceName) {
interfaceNameDefinitions.set(field.interfaceName, fieldSchema)
fieldSchema = {
$ref: `#/definitions/${field.interfaceName}`,
}
}
break
}
case 'join': {
fieldSchema = {
type: withNullableJSONSchemaType('object', false),
additionalProperties: false,
properties: {
docs: {
type: withNullableJSONSchemaType('array', false),
items: {
oneOf: [
{
type: collectionIDFieldTypes[field.collection],
},
{
$ref: `#/definitions/${field.collection}`,
},
],
},
},
hasNextPage: { type: withNullableJSONSchemaType('boolean', false) },
},
}
break
}
case 'json': {
fieldSchema = field.jsonSchema?.schema || {
type: ['object', 'array', 'string', 'number', 'boolean', 'null'],
}
break
}
case 'number': {
if (field.hasMany === true) {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: { type: 'number' },
}
} else {
fieldSchema = { type: withNullableJSONSchemaType('number', isRequired) }
}
break
}
case 'point': {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: [
{
type: 'number',
},
{
type: 'number',
},
],
maxItems: 2,
minItems: 2,
}
break
}
case 'radio': {
fieldSchema = {
type: withNullableJSONSchemaType('string', isRequired),
enum: buildOptionEnums(field.options),
}
break
}
case 'relationship':
case 'upload': {
if (Array.isArray(field.relationTo)) {
if (field.hasMany) {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: {
oneOf: field.relationTo.map((relation) => {
return {
type: 'object',
additionalProperties: false,
properties: {
relationTo: {
const: relation,
},
value: {
oneOf: [
{
type: collectionIDFieldTypes[relation],
},
{
$ref: `#/definitions/${relation}`,
},
],
},
},
required: ['value', 'relationTo'],
}
}),
},
}
} else {
fieldSchema = {
oneOf: field.relationTo.map((relation) => {
return {
type: withNullableJSONSchemaType('object', isRequired),
additionalProperties: false,
properties: {
relationTo: {
const: relation,
},
value: {
oneOf: [
{
type: collectionIDFieldTypes[relation],
},
{
$ref: `#/definitions/${relation}`,
},
],
},
},
required: ['value', 'relationTo'],
}
}),
}
}
} else if (field.hasMany) {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: {
oneOf: [
{
type: collectionIDFieldTypes[field.relationTo],
},
{
$ref: `#/definitions/${field.relationTo}`,
},
],
},
}
} else {
fieldSchema = {
oneOf: [
{
type: withNullableJSONSchemaType(
collectionIDFieldTypes[field.relationTo],
isRequired,
),
},
{
$ref: `#/definitions/${field.relationTo}`,
},
],
}
}
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
if (field.editor.outputSchema) {
fieldSchema = field.editor.outputSchema({
collectionIDFieldTypes,
config,
field,
interfaceNameDefinitions,
isRequired,
})
} else {
// Maintain backwards compatibility with existing rich text editors
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: {
type: 'object',
},
}
}
break
}
case 'select': {
const optionEnums = buildOptionEnums(field.options)
if (field.hasMany) {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: {
type: 'string',
},
}
if (optionEnums?.length) {
;(fieldSchema.items as JSONSchema4).enum = optionEnums
}
} else {
fieldSchema = {
type: withNullableJSONSchemaType('string', isRequired),
}
if (optionEnums?.length) {
fieldSchema.enum = optionEnums
}
}
break
}
case 'tabs': {
field.tabs.forEach((tab) => {
const childSchema = fieldsToJSONSchema(
collectionIDFieldTypes,
tab.fields,
interfaceNameDefinitions,
config,
)
if (tabHasName(tab)) {
// could have interface
fieldSchemas.set(tab.name, {
type: 'object',
additionalProperties: false,
...childSchema,
})
// If the named tab has any required fields then we mark this as required otherwise it should be optional
const hasRequiredFields = tab.fields.some((subField) => fieldIsRequired(subField))
if (hasRequiredFields) {
requiredFieldNames.add(tab.name)
}
} else {
Object.entries(childSchema.properties).forEach(([propName, propSchema]) => {
fieldSchemas.set(propName, propSchema)
})
childSchema.required.forEach((propName) => {
requiredFieldNames.add(propName)
})
}
})
break
}
case 'text':
if (field.hasMany === true) {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: { type: 'string' },
}
} else {
fieldSchema = { type: withNullableJSONSchemaType('string', isRequired) }
}
break
default: {
break
}
}
if ('typescriptSchema' in field && field?.typescriptSchema?.length) {
for (const schema of field.typescriptSchema) {
fieldSchema = schema({ jsonSchema: fieldSchema })
}
}
if (fieldSchema && fieldAffectsData(field)) {
fieldSchemas.set(field.name, fieldSchema)
}
return fieldSchemas
}, new Map<string, JSONSchema4>()),
),
required: Array.from(requiredFieldNames),
}
}
// This function is part of the public API and is exported through payload/utilities
export function entityToJSONSchema(
config: SanitizedConfig,

View File

@@ -1,7 +1,7 @@
import path from 'path'
import { fileURLToPath } from 'url'
import { getDependencies } from '../../index.js'
import { getDependencies } from './getDependencies.js'
import { compareVersions } from './versionUtils.js'
const filename = fileURLToPath(import.meta.url)

View File

@@ -0,0 +1,489 @@
import type { JSONSchema4, JSONSchema4TypeName } from 'json-schema'
import type { SanitizedConfig } from '../config/types.js'
import { MissingEditorProp } from '../errors/MissingEditorProp.js'
import { type Field, fieldAffectsData, type Option, tabHasName } from '../fields/config/types.js'
/**
* Returns a JSON Schema Type with 'null' added if the field is not required.
*/
export function withNullableJSONSchemaType(
fieldType: JSONSchema4TypeName,
isRequired: boolean,
): JSONSchema4TypeName | JSONSchema4TypeName[] {
const fieldTypes = [fieldType]
if (isRequired) {
return fieldType
}
fieldTypes.push('null')
return fieldTypes
}
const fieldIsRequired = (field: Field) => {
const isConditional = Boolean(field?.admin && field?.admin?.condition)
if (isConditional) {
return false
}
const isMarkedRequired = 'required' in field && field.required === true
if (fieldAffectsData(field) && isMarkedRequired) {
return true
}
// if any subfields are required, this field is required
if ('fields' in field && field.type !== 'array') {
return field.fields.some((subField) => fieldIsRequired(subField))
}
// if any tab subfields have required fields, this field is required
if (field.type === 'tabs') {
return field.tabs.some((tab) => {
if ('name' in tab) {
return tab.fields.some((subField) => fieldIsRequired(subField))
}
return false
})
}
return false
}
function buildOptionEnums(options: Option[]): string[] {
return options.map((option) => {
if (typeof option === 'object' && 'value' in option) {
return option.value
}
return option
})
}
export function fieldsToJSONSchema(
/**
* Used for relationship fields, to determine whether to use a string or number type for the ID.
* While there is a default ID field type set by the db adapter, they can differ on a collection-level
* if they have custom ID fields.
*/
collectionIDFieldTypes: { [key: string]: 'number' | 'string' },
fields: Field[],
/**
* Allows you to define new top-level interfaces that can be re-used in the output schema.
*/
interfaceNameDefinitions: Map<string, JSONSchema4>,
config?: SanitizedConfig,
): {
properties: {
[k: string]: JSONSchema4
}
required: string[]
} {
const requiredFieldNames = new Set<string>()
return {
properties: Object.fromEntries(
fields.reduce((fieldSchemas, field) => {
const isRequired = fieldAffectsData(field) && fieldIsRequired(field)
if (isRequired) {
requiredFieldNames.add(field.name)
}
let fieldSchema: JSONSchema4
switch (field.type) {
case 'array': {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: {
type: 'object',
additionalProperties: false,
...fieldsToJSONSchema(
collectionIDFieldTypes,
field.fields,
interfaceNameDefinitions,
config,
),
},
}
if (field.interfaceName) {
interfaceNameDefinitions.set(field.interfaceName, fieldSchema)
fieldSchema = {
$ref: `#/definitions/${field.interfaceName}`,
}
}
break
}
case 'blocks': {
// Check for a case where no blocks are provided.
// We need to generate an empty array for this case, note that JSON schema 4 doesn't support empty arrays
// so the best we can get is `unknown[]`
const hasBlocks = Boolean(field.blocks.length)
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: hasBlocks
? {
oneOf: field.blocks.map((block) => {
const blockFieldSchemas = fieldsToJSONSchema(
collectionIDFieldTypes,
block.fields,
interfaceNameDefinitions,
config,
)
const blockSchema: JSONSchema4 = {
type: 'object',
additionalProperties: false,
properties: {
...blockFieldSchemas.properties,
blockType: {
const: block.slug,
},
},
required: ['blockType', ...blockFieldSchemas.required],
}
if (block.interfaceName) {
interfaceNameDefinitions.set(block.interfaceName, blockSchema)
return {
$ref: `#/definitions/${block.interfaceName}`,
}
}
return blockSchema
}),
}
: {},
}
break
}
case 'checkbox': {
fieldSchema = { type: withNullableJSONSchemaType('boolean', isRequired) }
break
}
case 'code':
case 'date':
case 'email':
case 'textarea': {
fieldSchema = { type: withNullableJSONSchemaType('string', isRequired) }
break
}
case 'collapsible':
case 'row': {
const childSchema = fieldsToJSONSchema(
collectionIDFieldTypes,
field.fields,
interfaceNameDefinitions,
config,
)
Object.entries(childSchema.properties).forEach(([propName, propSchema]) => {
fieldSchemas.set(propName, propSchema)
})
childSchema.required.forEach((propName) => {
requiredFieldNames.add(propName)
})
break
}
case 'group': {
fieldSchema = {
type: 'object',
additionalProperties: false,
...fieldsToJSONSchema(
collectionIDFieldTypes,
field.fields,
interfaceNameDefinitions,
config,
),
}
if (field.interfaceName) {
interfaceNameDefinitions.set(field.interfaceName, fieldSchema)
fieldSchema = {
$ref: `#/definitions/${field.interfaceName}`,
}
}
break
}
case 'join': {
fieldSchema = {
type: withNullableJSONSchemaType('object', false),
additionalProperties: false,
properties: {
docs: {
type: withNullableJSONSchemaType('array', false),
items: {
oneOf: [
{
type: collectionIDFieldTypes[field.collection],
},
{
$ref: `#/definitions/${field.collection}`,
},
],
},
},
hasNextPage: { type: withNullableJSONSchemaType('boolean', false) },
},
}
break
}
case 'json': {
fieldSchema = field.jsonSchema?.schema || {
type: ['object', 'array', 'string', 'number', 'boolean', 'null'],
}
break
}
case 'number': {
if (field.hasMany === true) {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: { type: 'number' },
}
} else {
fieldSchema = { type: withNullableJSONSchemaType('number', isRequired) }
}
break
}
case 'point': {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: [
{
type: 'number',
},
{
type: 'number',
},
],
maxItems: 2,
minItems: 2,
}
break
}
case 'radio': {
fieldSchema = {
type: withNullableJSONSchemaType('string', isRequired),
enum: buildOptionEnums(field.options),
}
break
}
case 'relationship':
case 'upload': {
if (Array.isArray(field.relationTo)) {
if (field.hasMany) {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: {
oneOf: field.relationTo.map((relation) => {
return {
type: 'object',
additionalProperties: false,
properties: {
relationTo: {
const: relation,
},
value: {
oneOf: [
{
type: collectionIDFieldTypes[relation],
},
{
$ref: `#/definitions/${relation}`,
},
],
},
},
required: ['value', 'relationTo'],
}
}),
},
}
} else {
fieldSchema = {
oneOf: field.relationTo.map((relation) => {
return {
type: withNullableJSONSchemaType('object', isRequired),
additionalProperties: false,
properties: {
relationTo: {
const: relation,
},
value: {
oneOf: [
{
type: collectionIDFieldTypes[relation],
},
{
$ref: `#/definitions/${relation}`,
},
],
},
},
required: ['value', 'relationTo'],
}
}),
}
}
} else if (field.hasMany) {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: {
oneOf: [
{
type: collectionIDFieldTypes[field.relationTo],
},
{
$ref: `#/definitions/${field.relationTo}`,
},
],
},
}
} else {
fieldSchema = {
oneOf: [
{
type: withNullableJSONSchemaType(
collectionIDFieldTypes[field.relationTo],
isRequired,
),
},
{
$ref: `#/definitions/${field.relationTo}`,
},
],
}
}
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
if (field.editor.outputSchema) {
fieldSchema = field.editor.outputSchema({
collectionIDFieldTypes,
config,
field,
interfaceNameDefinitions,
isRequired,
})
} else {
// Maintain backwards compatibility with existing rich text editors
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: {
type: 'object',
},
}
}
break
}
case 'select': {
const optionEnums = buildOptionEnums(field.options)
if (field.hasMany) {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: {
type: 'string',
},
}
if (optionEnums?.length) {
;(fieldSchema.items as JSONSchema4).enum = optionEnums
}
} else {
fieldSchema = {
type: withNullableJSONSchemaType('string', isRequired),
}
if (optionEnums?.length) {
fieldSchema.enum = optionEnums
}
}
break
}
case 'tabs': {
field.tabs.forEach((tab) => {
const childSchema = fieldsToJSONSchema(
collectionIDFieldTypes,
tab.fields,
interfaceNameDefinitions,
config,
)
if (tabHasName(tab)) {
// could have interface
fieldSchemas.set(tab.name, {
type: 'object',
additionalProperties: false,
...childSchema,
})
// If the named tab has any required fields then we mark this as required otherwise it should be optional
const hasRequiredFields = tab.fields.some((subField) => fieldIsRequired(subField))
if (hasRequiredFields) {
requiredFieldNames.add(tab.name)
}
} else {
Object.entries(childSchema.properties).forEach(([propName, propSchema]) => {
fieldSchemas.set(propName, propSchema)
})
childSchema.required.forEach((propName) => {
requiredFieldNames.add(propName)
})
}
})
break
}
case 'text':
if (field.hasMany === true) {
fieldSchema = {
type: withNullableJSONSchemaType('array', isRequired),
items: { type: 'string' },
}
} else {
fieldSchema = { type: withNullableJSONSchemaType('string', isRequired) }
}
break
default: {
break
}
}
if ('typescriptSchema' in field && field?.typescriptSchema?.length) {
for (const schema of field.typescriptSchema) {
fieldSchema = schema({ jsonSchema: fieldSchema })
}
}
if (fieldSchema && fieldAffectsData(field)) {
fieldSchemas.set(field.name, fieldSchema)
}
return fieldSchemas
}, new Map<string, JSONSchema4>()),
),
required: Array.from(requiredFieldNames),
}
}

View File

@@ -29,7 +29,7 @@ type TabType<TField> = TField extends ClientField ? ClientTab : Tab
* @param fields
* @param keepPresentationalFields if true, will skip flattening fields that are presentational only
*/
function flattenFields<TField extends ClientField | Field>(
export function flattenTopLevelFields<TField extends ClientField | Field>(
fields: TField[],
keepPresentationalFields?: boolean,
): FlattenedField<TField>[] {
@@ -39,7 +39,7 @@ function flattenFields<TField extends ClientField | Field>(
}
if (fieldHasSubFields(field)) {
return [...fieldsToUse, ...flattenFields(field.fields as TField[], keepPresentationalFields)]
return [...fieldsToUse, ...flattenTopLevelFields(field.fields as TField[], keepPresentationalFields)]
}
if (field.type === 'tabs' && 'tabs' in field) {
@@ -51,7 +51,7 @@ function flattenFields<TField extends ClientField | Field>(
} else {
return [
...tabFields,
...flattenFields(tab.fields as TField[], keepPresentationalFields),
...flattenTopLevelFields(tab.fields as TField[], keepPresentationalFields),
]
}
}, []),
@@ -61,5 +61,3 @@ function flattenFields<TField extends ClientField | Field>(
return fieldsToUse
}, [])
}
export default flattenFields

View File

@@ -3,7 +3,7 @@ import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import type { Payload } from '../index.js'
import type { PayloadRequest, SelectType } from '../types/index.js'
import { deepCopyObjectSimple } from '../index.js'
import { deepCopyObjectSimple } from '../utilities/deepCopyObject.js'
import sanitizeInternalFields from '../utilities/sanitizeInternalFields.js'
import { getQueryDraftsSelect } from './drafts/getQueryDraftsSelect.js'
import { enforceMaxVersions } from './enforceMaxVersions.js'

View File

@@ -1,10 +1,10 @@
'use client'
import React from 'react'
import type { UnknownConvertedNodeData } from './index.js'
import './index.scss'
import type { UnknownConvertedNodeData } from './types.js'
type Props = {
data: UnknownConvertedNodeData
}

View File

@@ -5,10 +5,7 @@ import { addClassNamesToElement } from '@lexical/utils'
import { DecoratorNode } from 'lexical'
import * as React from 'react'
export type UnknownConvertedNodeData = {
nodeData: unknown
nodeType: string
}
import type { UnknownConvertedNodeData } from './types.js'
export type SerializedUnknownConvertedNode = Spread<
{

View File

@@ -0,0 +1,4 @@
export type UnknownConvertedNodeData = {
nodeData: unknown
nodeType: string
}

View File

@@ -1,9 +1,8 @@
'use client'
import React from 'react'
import type { UnknownConvertedNodeData } from './index.js'
import './index.scss'
import type { UnknownConvertedNodeData } from './types.js'
type Props = {
data: UnknownConvertedNodeData

View File

@@ -5,10 +5,7 @@ import { addClassNamesToElement } from '@lexical/utils'
import { DecoratorNode } from 'lexical'
import * as React from 'react'
export type UnknownConvertedNodeData = {
nodeData: unknown
nodeType: string
}
import type { UnknownConvertedNodeData } from './types.js'
export type SerializedUnknownConvertedNode = Spread<
{

View File

@@ -0,0 +1,4 @@
export type UnknownConvertedNodeData = {
nodeData: unknown
nodeType: string
}

View File

@@ -1,9 +1,10 @@
import type { Field, PayloadRequest, PopulateType } from 'payload'
import type { Field, PayloadRequest, PopulateType, RichTextAdapter, RichTextField } from 'payload'
import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType, tabHasName } from 'payload/shared'
import type { AdapterArguments } from '../types.js'
import { populate } from './populate.js'
import { recurseRichText } from './richTextRelationshipPromise.js'
type NestedRichTextFieldsArgs = {
currentDepth?: number
@@ -227,3 +228,168 @@ export const recurseNestedFields = ({
}
})
}
export type Args = Parameters<
RichTextAdapter<any[], AdapterArguments>['graphQLPopulationPromises']
>[0]
type RecurseRichTextArgs = {
children: unknown[]
currentDepth: number
depth: number
draft: boolean
field: RichTextField<any[], any, any>
overrideAccess: boolean
populateArg?: PopulateType
populationPromises: Promise<void>[]
req: PayloadRequest
showHiddenFields: boolean
}
export const recurseRichText = ({
children,
currentDepth = 0,
depth,
draft,
field,
overrideAccess = false,
populateArg,
populationPromises,
req,
showHiddenFields,
}: RecurseRichTextArgs): void => {
if (depth <= 0 || currentDepth > depth) {
return
}
if (Array.isArray(children)) {
;(children as any[]).forEach((element) => {
if ((element.type === 'relationship' || element.type === 'upload') && element?.value?.id) {
const collection = req.payload.collections[element?.relationTo]
if (collection) {
populationPromises.push(
populate({
id: element.value.id,
collection,
currentDepth,
data: element,
depth,
draft,
field,
key: 'value',
overrideAccess,
req,
select:
req.payloadAPI !== 'GraphQL'
? (populateArg?.[collection.config.slug] ?? collection.config.defaultPopulate)
: undefined,
showHiddenFields,
}),
)
}
if (
element.type === 'upload' &&
Array.isArray(field.admin?.upload?.collections?.[element?.relationTo]?.fields)
) {
recurseNestedFields({
currentDepth,
data: element.fields || {},
depth,
draft,
fields: field.admin.upload.collections[element.relationTo].fields,
overrideAccess,
populateArg,
populationPromises,
req,
showHiddenFields,
})
}
}
if (element.type === 'link') {
if (element?.doc?.value && element?.doc?.relationTo) {
const collection = req.payload.collections[element?.doc?.relationTo]
if (collection) {
populationPromises.push(
populate({
id: element.doc.value,
collection,
currentDepth,
data: element.doc,
depth,
draft,
field,
key: 'value',
overrideAccess,
req,
select:
req.payloadAPI !== 'GraphQL'
? (populateArg?.[collection.config.slug] ?? collection.config.defaultPopulate)
: undefined,
showHiddenFields,
}),
)
}
}
if (Array.isArray(field.admin?.link?.fields)) {
recurseNestedFields({
currentDepth,
data: element.fields || {},
depth,
draft,
fields: field.admin?.link?.fields,
overrideAccess,
populateArg,
populationPromises,
req,
showHiddenFields,
})
}
}
if (element?.children) {
recurseRichText({
children: element.children,
currentDepth,
depth,
draft,
field,
overrideAccess,
populateArg,
populationPromises,
req,
showHiddenFields,
})
}
})
}
}
export const richTextRelationshipPromise = ({
currentDepth,
depth,
draft,
field,
overrideAccess,
populateArg,
populationPromises,
req,
showHiddenFields,
siblingDoc,
}: Args) => {
recurseRichText({
children: siblingDoc[field.name] as unknown[],
currentDepth,
depth,
draft,
field,
overrideAccess,
populateArg,
populationPromises,
req,
showHiddenFields,
})
}

View File

@@ -1,177 +0,0 @@
import type {
CollectionConfig,
PayloadRequest,
PopulateType,
RichTextAdapter,
RichTextField,
} from 'payload'
import type { AdapterArguments } from '../types.js'
import { populate } from './populate.js'
import { recurseNestedFields } from './recurseNestedFields.js'
export type Args = Parameters<
RichTextAdapter<any[], AdapterArguments>['graphQLPopulationPromises']
>[0]
type RecurseRichTextArgs = {
children: unknown[]
currentDepth: number
depth: number
draft: boolean
field: RichTextField<any[], any, any>
overrideAccess: boolean
populateArg?: PopulateType
populationPromises: Promise<void>[]
req: PayloadRequest
showHiddenFields: boolean
}
export const recurseRichText = ({
children,
currentDepth = 0,
depth,
draft,
field,
overrideAccess = false,
populateArg,
populationPromises,
req,
showHiddenFields,
}: RecurseRichTextArgs): void => {
if (depth <= 0 || currentDepth > depth) {
return
}
if (Array.isArray(children)) {
;(children as any[]).forEach((element) => {
if ((element.type === 'relationship' || element.type === 'upload') && element?.value?.id) {
const collection = req.payload.collections[element?.relationTo]
if (collection) {
populationPromises.push(
populate({
id: element.value.id,
collection,
currentDepth,
data: element,
depth,
draft,
field,
key: 'value',
overrideAccess,
req,
select:
req.payloadAPI !== 'GraphQL'
? (populateArg?.[collection.config.slug] ?? collection.config.defaultPopulate)
: undefined,
showHiddenFields,
}),
)
}
if (
element.type === 'upload' &&
Array.isArray(field.admin?.upload?.collections?.[element?.relationTo]?.fields)
) {
recurseNestedFields({
currentDepth,
data: element.fields || {},
depth,
draft,
fields: field.admin.upload.collections[element.relationTo].fields,
overrideAccess,
populateArg,
populationPromises,
req,
showHiddenFields,
})
}
}
if (element.type === 'link') {
if (element?.doc?.value && element?.doc?.relationTo) {
const collection = req.payload.collections[element?.doc?.relationTo]
if (collection) {
populationPromises.push(
populate({
id: element.doc.value,
collection,
currentDepth,
data: element.doc,
depth,
draft,
field,
key: 'value',
overrideAccess,
req,
select:
req.payloadAPI !== 'GraphQL'
? (populateArg?.[collection.config.slug] ?? collection.config.defaultPopulate)
: undefined,
showHiddenFields,
}),
)
}
}
if (Array.isArray(field.admin?.link?.fields)) {
recurseNestedFields({
currentDepth,
data: element.fields || {},
depth,
draft,
fields: field.admin?.link?.fields,
overrideAccess,
populateArg,
populationPromises,
req,
showHiddenFields,
})
}
}
if (element?.children) {
recurseRichText({
children: element.children,
currentDepth,
depth,
draft,
field,
overrideAccess,
populateArg,
populationPromises,
req,
showHiddenFields,
})
}
})
}
}
export const richTextRelationshipPromise = ({
currentDepth,
depth,
draft,
field,
overrideAccess,
populateArg,
populationPromises,
req,
showHiddenFields,
siblingDoc,
}: Args) => {
recurseRichText({
children: siblingDoc[field.name] as unknown[],
currentDepth,
depth,
draft,
field,
overrideAccess,
populateArg,
populationPromises,
req,
showHiddenFields,
})
}

View File

@@ -4,7 +4,7 @@ import { sanitizeFields, withNullableJSONSchemaType } from 'payload'
import type { AdapterArguments } from './types.js'
import { richTextRelationshipPromise } from './data/richTextRelationshipPromise.js'
import { richTextRelationshipPromise } from './data/recurseNestedFields.js'
import { richTextValidate } from './data/validation.js'
import { elements as elementTypes } from './field/elements/index.js'
import { transformExtraFields } from './field/elements/link/utilities.js'

View File

@@ -1,6 +1,5 @@
import type { Locale } from 'date-fns'
import type { clientTranslationKeys } from './clientKeys.js'
import type { enTranslations } from './languages/en.js'
import type { acceptedLanguages } from './utilities/languages.js'
@@ -110,8 +109,7 @@ export type DefaultTranslationKeysUnSanitized = NestedKeysUnSanitized<DefaultTra
*/
export type DefaultTranslationKeys = NestedKeysStripped<DefaultTranslationsObject>
export type ClientTranslationKeys<TExtraProps = (typeof clientTranslationKeys)[number]> =
TExtraProps
export type ClientTranslationKeys<TExtraProps = DefaultTranslationKeys> = TExtraProps
// Use GenericTranslationsObject instead of reconstructing the object from the client keys. This is because reconstructing the object is
// A) Expensive on performance.

View File

@@ -22,7 +22,6 @@ import {
import type { RenderFieldMethod } from './types.js'
import { getFilterOptionsQuery } from './getFilterOptionsQuery.js'
import { iterateFields } from './iterateFields.js'
const ObjectId = (ObjectIdImport.default ||
ObjectIdImport) as unknown as typeof ObjectIdImport.default
@@ -736,3 +735,137 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
})
}
}
type IterateFieldsArgs = {
addErrorPathToParent: (fieldPath: string) => void
/**
* if any parents is localized, then the field is localized. @default false
*/
anyParentLocalized?: boolean
collectionSlug?: string
data: Data
fields: Field[]
fieldSchemaMap: FieldSchemaMap
filter?: (args: AddFieldStatePromiseArgs) => boolean
/**
* Force the value of fields like arrays or blocks to be the full value instead of the length @default false
*/
forceFullValue?: boolean
fullData: Data
id?: number | string
/**
* Whether the field schema should be included in the state. @default false
*/
includeSchema?: boolean
/**
* Whether to omit parent fields in the state. @default false
*/
omitParents?: boolean
/**
* operation is only needed for validation
*/
operation: 'create' | 'update'
parentIndexPath: string
parentPassesCondition?: boolean
parentPath: string
parentSchemaPath: string
permissions:
| {
[fieldName: string]: SanitizedFieldPermissions
}
| null
| SanitizedFieldPermissions
preferences?: DocumentPreferences
previousFormState: FormState
renderAllFields: boolean
renderFieldFn: RenderFieldMethod
req: PayloadRequest
/**
* Whether to skip checking the field's condition. @default false
*/
skipConditionChecks?: boolean
/**
* Whether to skip validating the field. @default false
*/
skipValidation?: boolean
state?: FormStateWithoutComponents
}
/**
* Flattens the fields schema and fields data
*/
export const iterateFields = async ({
id,
addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized = false,
collectionSlug,
data,
fields,
fieldSchemaMap,
filter,
forceFullValue = false,
fullData,
includeSchema = false,
omitParents = false,
operation,
parentIndexPath,
parentPassesCondition = true,
parentPath,
parentSchemaPath,
permissions,
preferences,
previousFormState,
renderAllFields,
renderFieldFn: renderFieldFn,
req,
skipConditionChecks = false,
skipValidation = false,
state = {},
}: IterateFieldsArgs): Promise<void> => {
const promises = []
fields.forEach((field, fieldIndex) => {
let passesCondition = true
if (!skipConditionChecks) {
passesCondition = Boolean(
(field?.admin?.condition
? Boolean(field.admin.condition(fullData || {}, data || {}, { user: req.user }))
: true) && parentPassesCondition,
)
}
promises.push(
addFieldStatePromise({
id,
addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized,
collectionSlug,
data,
field,
fieldIndex,
fieldSchemaMap,
filter,
forceFullValue,
fullData,
includeSchema,
omitParents,
operation,
parentIndexPath,
parentPath,
parentSchemaPath,
passesCondition,
permissions,
preferences,
previousFormState,
renderAllFields,
renderFieldFn,
req,
skipConditionChecks,
skipValidation,
state,
}),
)
})
await Promise.all(promises)
}

View File

@@ -1,6 +1,6 @@
import type { Data, Field as FieldSchema, User } from 'payload'
import { iterateFields } from './iterateFields.js'
import { iterateFields } from './promise.js'
type Args = {
data: Data

View File

@@ -1,38 +0,0 @@
import type { Data, Field, TabAsField, User } from 'payload'
import { defaultValuePromise } from './promise.js'
type Args<T> = {
data: T
fields: (Field | TabAsField)[]
id?: number | string
locale: string | undefined
siblingData: Data
user: User
}
export const iterateFields = async <T>({
id,
data,
fields,
locale,
siblingData,
user,
}: Args<T>): Promise<void> => {
const promises = []
fields.forEach((field) => {
promises.push(
defaultValuePromise({
id,
data,
field,
locale,
siblingData,
user,
}),
)
})
await Promise.all(promises)
}

View File

@@ -3,8 +3,6 @@ import type { Data, Field, TabAsField, User } from 'payload'
import { getDefaultValue } from 'payload'
import { fieldAffectsData, tabHasName } from 'payload/shared'
import { iterateFields } from './iterateFields.js'
type Args<T> = {
data: T
field: Field | TabAsField
@@ -168,3 +166,38 @@ export const defaultValuePromise = async <T>({
}
}
}
type IterateFieldsArgs<T> = {
data: T
fields: (Field | TabAsField)[]
id?: number | string
locale: string | undefined
siblingData: Data
user: User
}
export const iterateFields = async <T>({
id,
data,
fields,
locale,
siblingData,
user,
}: IterateFieldsArgs<T>): Promise<void> => {
const promises = []
fields.forEach((field) => {
promises.push(
defaultValuePromise({
id,
data,
field,
locale,
siblingData,
user,
}),
)
})
await Promise.all(promises)
}

View File

@@ -11,8 +11,8 @@ import type {
import type { RenderFieldMethod } from './types.js'
import { iterateFields } from './addFieldStatePromise.js'
import { calculateDefaultValues } from './calculateDefaultValues/index.js'
import { iterateFields } from './iterateFields.js'
type Args = {
collectionSlug?: string

View File

@@ -1,149 +0,0 @@
import type {
Data,
DocumentPreferences,
Field as FieldSchema,
FieldSchemaMap,
FormState,
FormStateWithoutComponents,
PayloadRequest,
SanitizedFieldPermissions,
} from 'payload'
import type { AddFieldStatePromiseArgs } from './addFieldStatePromise.js'
import type { RenderFieldMethod } from './types.js'
import { addFieldStatePromise } from './addFieldStatePromise.js'
type Args = {
addErrorPathToParent: (fieldPath: string) => void
/**
* if any parents is localized, then the field is localized. @default false
*/
anyParentLocalized?: boolean
collectionSlug?: string
data: Data
fields: FieldSchema[]
fieldSchemaMap: FieldSchemaMap
filter?: (args: AddFieldStatePromiseArgs) => boolean
/**
* Force the value of fields like arrays or blocks to be the full value instead of the length @default false
*/
forceFullValue?: boolean
fullData: Data
id?: number | string
/**
* Whether the field schema should be included in the state. @default false
*/
includeSchema?: boolean
/**
* Whether to omit parent fields in the state. @default false
*/
omitParents?: boolean
/**
* operation is only needed for validation
*/
operation: 'create' | 'update'
parentIndexPath: string
parentPassesCondition?: boolean
parentPath: string
parentSchemaPath: string
permissions:
| {
[fieldName: string]: SanitizedFieldPermissions
}
| null
| SanitizedFieldPermissions
preferences?: DocumentPreferences
previousFormState: FormState
renderAllFields: boolean
renderFieldFn: RenderFieldMethod
req: PayloadRequest
/**
* Whether to skip checking the field's condition. @default false
*/
skipConditionChecks?: boolean
/**
* Whether to skip validating the field. @default false
*/
skipValidation?: boolean
state?: FormStateWithoutComponents
}
/**
* Flattens the fields schema and fields data
*/
export const iterateFields = async ({
id,
addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized = false,
collectionSlug,
data,
fields,
fieldSchemaMap,
filter,
forceFullValue = false,
fullData,
includeSchema = false,
omitParents = false,
operation,
parentIndexPath,
parentPassesCondition = true,
parentPath,
parentSchemaPath,
permissions,
preferences,
previousFormState,
renderAllFields,
renderFieldFn: renderFieldFn,
req,
skipConditionChecks = false,
skipValidation = false,
state = {},
}: Args): Promise<void> => {
const promises = []
fields.forEach((field, fieldIndex) => {
let passesCondition = true
if (!skipConditionChecks) {
passesCondition = Boolean(
(field?.admin?.condition
? Boolean(field.admin.condition(fullData || {}, data || {}, { user: req.user }))
: true) && parentPassesCondition,
)
}
promises.push(
addFieldStatePromise({
id,
addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized,
collectionSlug,
data,
field,
fieldIndex,
fieldSchemaMap,
filter,
forceFullValue,
fullData,
includeSchema,
omitParents,
operation,
parentIndexPath,
parentPath,
parentSchemaPath,
passesCondition,
permissions,
preferences,
previousFormState,
renderAllFields,
renderFieldFn,
req,
skipConditionChecks,
skipValidation,
state,
}),
)
})
await Promise.all(promises)
}

View File

@@ -4,7 +4,6 @@ import type { PgTable } from 'drizzle-orm/pg-core'
import type { SQLiteTable } from 'drizzle-orm/sqlite-core'
import type { Payload } from 'payload'
import { GenericTable } from '@payloadcms/drizzle/types'
import { sql } from 'drizzle-orm'
import { isMongoose } from './isMongoose.js'

View File

@@ -37,7 +37,7 @@
],
"paths": {
"@payload-config": [
"./test/_community/config.ts"
"./test/fields/config.ts"
],
"@payloadcms/live-preview": [
"./packages/live-preview/src"
@@ -54,6 +54,42 @@
"@payloadcms/ui/shared": [
"./packages/ui/src/exports/shared/index.ts"
],
"@payloadcms/ui/forms/fieldSchemasToFormState": [
"./packages/ui/src/forms/fieldSchemasToFormState/index.tsx"
],
"@payloadcms/ui/utilities/buildFieldSchemaMap/traverseFields": [
"./packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts"
],
"@payloadcms/translations/utilities": [
"./packages/translations/src/exports/utilities.ts"
],
"@payloadcms/translations/languages/en": [
"./packages/translations/src/languages/en.ts"
],
"@payloadcms/translations/languages/de": [
"./packages/translations/src/languages/de.ts"
],
"@payloadcms/translations/languages/es": [
"./packages/translations/src/languages/es.ts"
],
"payload": [
"./packages/payload/src/index.ts"
],
"payload/shared": [
"./packages/payload/src/exports/shared.ts"
],
"payload/i18n/en": [
"./packages/payload/src/exports/i18n/en.ts"
],
"payload/i18n/de": [
"./packages/payload/src/exports/i18n/de.ts"
],
"payload/i18n/es": [
"./packages/payload/src/exports/i18n/es.ts"
],
"payload/node": [
"./packages/payload/src/exports/node.ts"
],
"@payloadcms/ui/scss": [
"./packages/ui/src/scss.scss"
],
@@ -69,6 +105,15 @@
"@payloadcms/richtext-lexical/rsc": [
"./packages/richtext-lexical/src/exports/server/rsc.ts"
],
"@payloadcms/richtext-lexical/migrate": [
"./packages/richtext-lexical/src/exports/server/migrate.ts"
],
"@payloadcms/richtext-lexical/lexical/headless": [
"./packages/richtext-lexical/src/lexical-proxy/@lexical-headless.ts"
],
"@payloadcms/richtext-lexical/lexical/markdown": [
"./packages/richtext-lexical/src/lexical-proxy/@lexical-markdown.ts"
],
"@payloadcms/richtext-slate/rsc": [
"./packages/richtext-slate/src/exports/server/rsc.ts"
],