Files
payloadcms/packages/drizzle/src/transform/write/traverseFields.ts
Alessio Gravili e6fea1d132 fix: localized fields within block references were not handled properly if any parent is localized (#11207)
The `localized` properly was not stripped out of referenced block fields, if any parent was localized. For normal fields, this is done in sanitizeConfig. As the same referenced block config can be used in both a localized and non-localized config, we are not able to strip it out inside sanitizeConfig by modifying the block config.

Instead, this PR had to bring back tedious logic to handle it everywhere the `field.localized` property is accessed. For backwards-compatibility, we need to keep the existing sanitizeConfig logic. In 4.0, we should remove it to benefit from better test coverage of runtime field.localized handling - for now, this is done for our test suite using the `PAYLOAD_DO_NOT_SANITIZE_LOCALIZED_PROPERTY` flag.
2025-02-17 19:50:32 +00:00

523 lines
14 KiB
TypeScript

import type { FlattenedField } from 'payload'
import { sql } from 'drizzle-orm'
import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared'
import toSnakeCase from 'to-snake-case'
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'
import { transformTexts } from './texts.js'
type Args = {
adapter: DrizzleAdapter
arrays: {
[tableName: string]: ArrayRowToInsert[]
}
/**
* This is the name of the base table
*/
baseTableName: string
blocks: {
[blockType: string]: BlockRowToInsert[]
}
blocksToDelete: Set<string>
/**
* A snake-case field prefix, representing prior fields
* Ex: my_group_my_named_tab_
*/
columnPrefix: string
data: Record<string, unknown>
existingLocales?: Record<string, unknown>[]
/**
* A prefix that will retain camel-case formatting, representing prior fields
* Ex: myGroup_myNamedTab_
*/
fieldPrefix: string
fields: FlattenedField[]
forcedLocale?: string
/**
* Tracks whether the current traversion context is from array or block.
*/
insideArrayOrBlock?: boolean
locales: {
[locale: string]: Record<string, unknown>
}
numbers: Record<string, unknown>[]
parentIsLocalized: boolean
/**
* This is the name of the parent table
*/
parentTableName: string
path: string
relationships: Record<string, unknown>[]
relationshipsToDelete: RelationshipToDelete[]
row: Record<string, unknown>
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 traverseFields = ({
adapter,
arrays,
baseTableName,
blocks,
blocksToDelete,
columnPrefix,
data,
existingLocales,
fieldPrefix,
fields,
forcedLocale,
insideArrayOrBlock = false,
locales,
numbers,
parentIsLocalized,
parentTableName,
path,
relationships,
relationshipsToDelete,
row,
selects,
texts,
withinArrayOrBlockLocale,
}: Args) => {
if (row._uuid) {
data._uuid = row._uuid
}
fields.forEach((field) => {
let columnName = ''
let fieldName = ''
let fieldData: unknown
if (fieldIsVirtual(field)) {
return
}
columnName = `${columnPrefix || ''}${toSnakeCase(field.name)}`
fieldName = `${fieldPrefix || ''}${field.name}`
fieldData = data[field.name]
const isLocalized = fieldShouldBeLocalized({ field, parentIsLocalized })
if (field.type === 'array') {
const arrayTableName = adapter.tableNameMap.get(`${parentTableName}_${columnName}`)
if (!arrays[arrayTableName]) {
arrays[arrayTableName] = []
}
if (isLocalized) {
if (typeof data[field.name] === 'object' && data[field.name] !== null) {
Object.entries(data[field.name]).forEach(([localeKey, localeData]) => {
if (Array.isArray(localeData)) {
const newRows = transformArray({
adapter,
arrayTableName,
baseTableName,
blocks,
blocksToDelete,
data: localeData,
field,
locale: localeKey,
numbers,
parentIsLocalized: parentIsLocalized || field.localized,
path,
relationships,
relationshipsToDelete,
selects,
texts,
withinArrayOrBlockLocale: localeKey,
})
arrays[arrayTableName] = arrays[arrayTableName].concat(newRows)
}
})
}
} else {
const newRows = transformArray({
adapter,
arrayTableName,
baseTableName,
blocks,
blocksToDelete,
data: data[field.name],
field,
numbers,
parentIsLocalized: parentIsLocalized || field.localized,
path,
relationships,
relationshipsToDelete,
selects,
texts,
withinArrayOrBlockLocale,
})
arrays[arrayTableName] = arrays[arrayTableName].concat(newRows)
}
return
}
if (field.type === 'blocks') {
;(field.blockReferences ?? field.blocks).forEach((block) => {
blocksToDelete.add(toSnakeCase(typeof block === 'string' ? block : block.slug))
})
if (isLocalized) {
if (typeof data[field.name] === 'object' && data[field.name] !== null) {
Object.entries(data[field.name]).forEach(([localeKey, localeData]) => {
if (Array.isArray(localeData)) {
transformBlocks({
adapter,
baseTableName,
blocks,
blocksToDelete,
data: localeData,
field,
locale: localeKey,
numbers,
parentIsLocalized: parentIsLocalized || field.localized,
path,
relationships,
relationshipsToDelete,
selects,
texts,
withinArrayOrBlockLocale: localeKey,
})
}
})
}
} else if (isArrayOfRows(fieldData)) {
transformBlocks({
adapter,
baseTableName,
blocks,
blocksToDelete,
data: fieldData,
field,
numbers,
parentIsLocalized: parentIsLocalized || field.localized,
path,
relationships,
relationshipsToDelete,
selects,
texts,
withinArrayOrBlockLocale,
})
}
return
}
if (field.type === 'group' || field.type === 'tab') {
if (typeof data[field.name] === 'object' && data[field.name] !== null) {
if (isLocalized) {
Object.entries(data[field.name]).forEach(([localeKey, localeData]) => {
// preserve array ID if there is
localeData._uuid = data.id || data._uuid
traverseFields({
adapter,
arrays,
baseTableName,
blocks,
blocksToDelete,
columnPrefix: `${columnName}_`,
data: localeData as Record<string, unknown>,
existingLocales,
fieldPrefix: `${fieldName}_`,
fields: field.flattenedFields,
forcedLocale: localeKey,
insideArrayOrBlock,
locales,
numbers,
parentIsLocalized: parentIsLocalized || field.localized,
parentTableName,
path: `${path || ''}${field.name}.`,
relationships,
relationshipsToDelete,
row,
selects,
texts,
withinArrayOrBlockLocale: localeKey,
})
})
} else {
// preserve array ID if there is
const groupData = data[field.name] as Record<string, unknown>
groupData._uuid = data.id || data._uuid
traverseFields({
adapter,
arrays,
baseTableName,
blocks,
blocksToDelete,
columnPrefix: `${columnName}_`,
data: groupData,
existingLocales,
fieldPrefix: `${fieldName}_`,
fields: field.flattenedFields,
insideArrayOrBlock,
locales,
numbers,
parentIsLocalized: parentIsLocalized || field.localized,
parentTableName,
path: `${path || ''}${field.name}.`,
relationships,
relationshipsToDelete,
row,
selects,
texts,
withinArrayOrBlockLocale,
})
}
}
return
}
if (field.type === 'relationship' || field.type === 'upload') {
const relationshipPath = `${path || ''}${field.name}`
if (
isLocalized &&
(Array.isArray(field.relationTo) || ('hasMany' in field && field.hasMany))
) {
if (typeof fieldData === 'object') {
Object.entries(fieldData).forEach(([localeKey, localeData]) => {
if (localeData === null) {
relationshipsToDelete.push({
locale: localeKey,
path: relationshipPath,
})
return
}
transformRelationship({
baseRow: {
locale: localeKey,
path: relationshipPath,
},
data: localeData,
field,
relationships,
})
})
}
return
} else if (Array.isArray(field.relationTo) || ('hasMany' in field && field.hasMany)) {
if (fieldData === null || (Array.isArray(fieldData) && fieldData.length === 0)) {
relationshipsToDelete.push({ path: relationshipPath })
return
}
transformRelationship({
baseRow: {
locale: withinArrayOrBlockLocale,
path: relationshipPath,
},
data: fieldData,
field,
relationships,
})
return
} else {
if (
!isLocalized &&
fieldData &&
typeof fieldData === 'object' &&
'id' in fieldData &&
fieldData?.id
) {
fieldData = fieldData.id
} else if (isLocalized) {
if (typeof fieldData === 'object') {
Object.entries(fieldData).forEach(([localeKey, localeData]) => {
if (typeof localeData === 'object') {
if (localeData && 'id' in localeData && localeData?.id) {
fieldData[localeKey] = localeData.id
}
} else {
fieldData[localeKey] = localeData
}
})
}
}
}
}
if (field.type === 'text' && field.hasMany) {
const textPath = `${path || ''}${field.name}`
if (isLocalized) {
if (typeof fieldData === 'object') {
Object.entries(fieldData).forEach(([localeKey, localeData]) => {
if (Array.isArray(localeData)) {
transformTexts({
baseRow: {
locale: localeKey,
path: textPath,
},
data: localeData,
texts,
})
}
})
}
} else if (Array.isArray(fieldData)) {
transformTexts({
baseRow: {
locale: withinArrayOrBlockLocale,
path: textPath,
},
data: fieldData,
texts,
})
}
return
}
if (field.type === 'number' && field.hasMany) {
const numberPath = `${path || ''}${field.name}`
if (isLocalized) {
if (typeof fieldData === 'object') {
Object.entries(fieldData).forEach(([localeKey, localeData]) => {
if (Array.isArray(localeData)) {
transformNumbers({
baseRow: {
locale: localeKey,
path: numberPath,
},
data: localeData,
numbers,
})
}
})
}
} else if (Array.isArray(fieldData)) {
transformNumbers({
baseRow: {
locale: withinArrayOrBlockLocale,
path: numberPath,
},
data: fieldData,
numbers,
})
}
return
}
if (field.type === 'select' && field.hasMany) {
const selectTableName = adapter.tableNameMap.get(`${parentTableName}_${columnName}`)
if (!selects[selectTableName]) {
selects[selectTableName] = []
}
if (isLocalized) {
if (typeof data[field.name] === 'object' && data[field.name] !== null) {
Object.entries(data[field.name]).forEach(([localeKey, localeData]) => {
if (Array.isArray(localeData)) {
const newRows = transformSelects({
id: insideArrayOrBlock ? data._uuid || data.id : undefined,
data: localeData,
locale: localeKey,
})
selects[selectTableName] = selects[selectTableName].concat(newRows)
}
})
}
} else if (Array.isArray(data[field.name])) {
const newRows = transformSelects({
id: insideArrayOrBlock ? data._uuid || data.id : undefined,
data: data[field.name],
locale: withinArrayOrBlockLocale,
})
selects[selectTableName] = selects[selectTableName].concat(newRows)
}
return
}
const valuesToTransform: { localeKey?: string; ref: unknown; value: unknown }[] = []
if (isLocalized) {
if (typeof fieldData === 'object' && fieldData !== null) {
Object.entries(fieldData).forEach(([localeKey, localeData]) => {
if (!locales[localeKey]) {
locales[localeKey] = {}
}
valuesToTransform.push({
localeKey,
ref: locales,
value: localeData,
})
})
}
} else {
let ref = row
if (forcedLocale) {
if (!locales[forcedLocale]) {
locales[forcedLocale] = {}
}
ref = locales[forcedLocale]
}
valuesToTransform.push({ ref, value: fieldData })
}
valuesToTransform.forEach(({ localeKey, ref, value }) => {
let formattedValue = value
if (typeof value !== 'undefined') {
if (value && field.type === 'point' && adapter.name !== 'sqlite') {
formattedValue = sql`ST_GeomFromGeoJSON(${JSON.stringify(value)})`
}
if (field.type === 'date') {
if (typeof value === 'number' && !Number.isNaN(value)) {
formattedValue = new Date(value).toISOString()
} else if (value instanceof Date) {
formattedValue = value.toISOString()
}
}
}
if (field.type === 'date' && fieldName === 'updatedAt') {
// let the db handle this
formattedValue = new Date().toISOString()
}
if (typeof formattedValue !== 'undefined') {
if (localeKey) {
ref[localeKey][fieldName] = formattedValue
} else {
ref[fieldName] = formattedValue
}
}
})
})
}