feat(db-mongodb,drizzle): add atomic array operations for relationship fields (#13891)
### What?
This PR adds atomic array operations ($append and $remove) for
relationship fields with `hasMany: true` across all database adapters.
These operations allow developers to add or remove specific items from
relationship arrays without replacing the entire array.
New API:
```
// Append relationships (prevents duplicates)
await payload.db.updateOne({
collection: 'posts',
id: 'post123',
data: {
categories: { $append: ['featured', 'trending'] }
}
})
// Remove specific relationships
await payload.db.updateOne({
collection: 'posts',
id: 'post123',
data: {
tags: { $remove: ['draft', 'private'] }
}
})
// Works with polymorphic relationships
await payload.db.updateOne({
collection: 'posts',
id: 'post123',
data: {
relatedItems: {
$append: [
{ relationTo: 'categories', value: 'category-id' },
{ relationTo: 'tags', value: 'tag-id' }
]
}
}
})
```
### Why?
Currently, updating relationship arrays requires replacing the entire
array which requires fetching existing data before updates. Requiring
more implementation effort and potential for errors when using the API,
in particular for bulk updates.
### How?
#### Cross-Adapter Features:
- Polymorphic relationships: Full support for relationTo:
['collection1', 'collection2']
- Localized relationships: Proper locale handling when fields are
localized
- Duplicate prevention: Ensures `$append` doesn't create duplicates
- Order preservation: Appends to end of array maintaining order
- Bulk operations: Works with `updateMany` for bulk updates
#### MongoDB Implementation:
- Converts `$append` to native `$addToSet` (prevents duplicates in
contrast to `$push`)
- Converts `$remove` to native `$pull` (targeted removal)
#### Drizzle Implementation (Postgres/SQLite):
- Uses optimized batch `INSERT` with duplicate checking for `$append`
- Uses targeted `DELETE` queries for `$remove`
- Implements timestamp-based ordering for performance
- Handles locale columns conditionally based on schema
### Limitations
The current implementation is only on database-adapter level and not
(yet) for the local API. Implementation in the localAPI will be done
separately.
This commit is contained in:
@@ -55,9 +55,13 @@ export const updateJobs: UpdateJobs = async function updateMany(
|
||||
|
||||
const $inc: Record<string, number> = {}
|
||||
const $push: Record<string, { $each: any[] } | any> = {}
|
||||
const $addToSet: Record<string, { $each: any[] } | any> = {}
|
||||
const $pull: Record<string, { $in: any[] } | any> = {}
|
||||
|
||||
transform({
|
||||
$addToSet,
|
||||
$inc,
|
||||
$pull,
|
||||
$push,
|
||||
adapter: this,
|
||||
data,
|
||||
@@ -73,6 +77,12 @@ export const updateJobs: UpdateJobs = async function updateMany(
|
||||
if (Object.keys($push).length) {
|
||||
updateOps.$push = $push
|
||||
}
|
||||
if (Object.keys($addToSet).length) {
|
||||
updateOps.$addToSet = $addToSet
|
||||
}
|
||||
if (Object.keys($pull).length) {
|
||||
updateOps.$pull = $pull
|
||||
}
|
||||
if (Object.keys(updateOps).length) {
|
||||
updateOps.$set = updateData
|
||||
updateData = updateOps
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MongooseUpdateQueryOptions } from 'mongoose'
|
||||
import type { MongooseUpdateQueryOptions, UpdateQuery } from 'mongoose'
|
||||
|
||||
import { flattenWhereToOperators, type UpdateMany } from 'payload'
|
||||
|
||||
@@ -70,7 +70,40 @@ export const updateMany: UpdateMany = async function updateMany(
|
||||
where,
|
||||
})
|
||||
|
||||
transform({ adapter: this, data, fields: collectionConfig.fields, operation: 'write' })
|
||||
const $inc: Record<string, number> = {}
|
||||
const $push: Record<string, { $each: any[] } | any> = {}
|
||||
const $addToSet: Record<string, { $each: any[] } | any> = {}
|
||||
const $pull: Record<string, { $in: any[] } | any> = {}
|
||||
|
||||
transform({
|
||||
$addToSet,
|
||||
$inc,
|
||||
$pull,
|
||||
$push,
|
||||
adapter: this,
|
||||
data,
|
||||
fields: collectionConfig.fields,
|
||||
operation: 'write',
|
||||
})
|
||||
|
||||
const updateOps: UpdateQuery<any> = {}
|
||||
|
||||
if (Object.keys($inc).length) {
|
||||
updateOps.$inc = $inc
|
||||
}
|
||||
if (Object.keys($push).length) {
|
||||
updateOps.$push = $push
|
||||
}
|
||||
if (Object.keys($addToSet).length) {
|
||||
updateOps.$addToSet = $addToSet
|
||||
}
|
||||
if (Object.keys($pull).length) {
|
||||
updateOps.$pull = $pull
|
||||
}
|
||||
if (Object.keys(updateOps).length) {
|
||||
updateOps.$set = data
|
||||
data = updateOps
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof limit === 'number' && limit > 0) {
|
||||
|
||||
@@ -56,8 +56,19 @@ export const updateOne: UpdateOne = async function updateOne(
|
||||
|
||||
const $inc: Record<string, number> = {}
|
||||
const $push: Record<string, { $each: any[] } | any> = {}
|
||||
const $addToSet: Record<string, { $each: any[] } | any> = {}
|
||||
const $pull: Record<string, { $in: any[] } | any> = {}
|
||||
|
||||
transform({ $inc, $push, adapter: this, data, fields, operation: 'write' })
|
||||
transform({
|
||||
$addToSet,
|
||||
$inc,
|
||||
$pull,
|
||||
$push,
|
||||
adapter: this,
|
||||
data,
|
||||
fields,
|
||||
operation: 'write',
|
||||
})
|
||||
|
||||
const updateOps: UpdateQuery<any> = {}
|
||||
|
||||
@@ -67,6 +78,12 @@ export const updateOne: UpdateOne = async function updateOne(
|
||||
if (Object.keys($push).length) {
|
||||
updateOps.$push = $push
|
||||
}
|
||||
if (Object.keys($addToSet).length) {
|
||||
updateOps.$addToSet = $addToSet
|
||||
}
|
||||
if (Object.keys($pull).length) {
|
||||
updateOps.$pull = $pull
|
||||
}
|
||||
if (Object.keys(updateOps).length) {
|
||||
updateOps.$set = updateData
|
||||
updateData = updateOps
|
||||
|
||||
@@ -26,6 +26,52 @@ function isValidRelationObject(value: unknown): value is RelationObject {
|
||||
return typeof value === 'object' && value !== null && 'relationTo' in value && 'value' in value
|
||||
}
|
||||
|
||||
/**
|
||||
* Process relationship values for polymorphic and simple relationships
|
||||
* Used by both $push and $remove operations
|
||||
*/
|
||||
const processRelationshipValues = (
|
||||
items: unknown[],
|
||||
field: RelationshipField | UploadField,
|
||||
config: SanitizedConfig,
|
||||
operation: 'read' | 'write',
|
||||
validateRelationships: boolean,
|
||||
) => {
|
||||
return items.map((item) => {
|
||||
// Handle polymorphic relationships
|
||||
if (Array.isArray(field.relationTo) && isValidRelationObject(item)) {
|
||||
const relatedCollection = config.collections?.find(({ slug }) => slug === item.relationTo)
|
||||
if (relatedCollection) {
|
||||
return {
|
||||
relationTo: item.relationTo,
|
||||
value: convertRelationshipValue({
|
||||
operation,
|
||||
relatedCollection,
|
||||
validateRelationships,
|
||||
value: item.value,
|
||||
}),
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// Handle simple relationships
|
||||
if (typeof field.relationTo === 'string') {
|
||||
const relatedCollection = config.collections?.find(({ slug }) => slug === field.relationTo)
|
||||
if (relatedCollection) {
|
||||
return convertRelationshipValue({
|
||||
operation,
|
||||
relatedCollection,
|
||||
validateRelationships,
|
||||
value: item,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
const convertRelationshipValue = ({
|
||||
operation,
|
||||
relatedCollection,
|
||||
@@ -208,7 +254,9 @@ const sanitizeDate = ({
|
||||
}
|
||||
|
||||
type Args = {
|
||||
$addToSet?: Record<string, { $each: any[] } | any>
|
||||
$inc?: Record<string, number>
|
||||
$pull?: Record<string, { $in: any[] } | any>
|
||||
$push?: Record<string, { $each: any[] } | any>
|
||||
/** instance of the adapter */
|
||||
adapter: MongooseAdapter
|
||||
@@ -398,7 +446,9 @@ const stripFields = ({
|
||||
}
|
||||
|
||||
export const transform = ({
|
||||
$addToSet,
|
||||
$inc,
|
||||
$pull,
|
||||
$push,
|
||||
adapter,
|
||||
data,
|
||||
@@ -415,7 +465,9 @@ export const transform = ({
|
||||
if (Array.isArray(data)) {
|
||||
for (const item of data) {
|
||||
transform({
|
||||
$addToSet,
|
||||
$inc,
|
||||
$pull,
|
||||
$push,
|
||||
adapter,
|
||||
data: item,
|
||||
@@ -464,6 +516,7 @@ export const transform = ({
|
||||
field,
|
||||
parentIsLocalized,
|
||||
parentPath,
|
||||
parentRef: incomingParentRef,
|
||||
ref: incomingRef,
|
||||
}) => {
|
||||
if (!incomingRef || typeof incomingRef !== 'object') {
|
||||
@@ -471,6 +524,24 @@ export const transform = ({
|
||||
}
|
||||
|
||||
const ref = incomingRef as Record<string, unknown>
|
||||
const parentRef = (incomingParentRef || {}) as Record<string, unknown>
|
||||
|
||||
// Clear empty parent containers by setting them to undefined.
|
||||
const clearEmptyContainer = () => {
|
||||
if (!parentRef || typeof parentRef !== 'object') {
|
||||
return
|
||||
}
|
||||
if (!ref || typeof ref !== 'object') {
|
||||
return
|
||||
}
|
||||
if (Object.keys(ref).length > 0) {
|
||||
return
|
||||
}
|
||||
const containerKey = Object.keys(parentRef).find((k) => parentRef[k] === ref)
|
||||
if (containerKey) {
|
||||
parentRef[containerKey] = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
$inc &&
|
||||
@@ -483,6 +554,7 @@ export const transform = ({
|
||||
if (value && typeof value === 'object' && '$inc' in value && typeof value.$inc === 'number') {
|
||||
$inc[`${parentPath}${field.name}`] = value.$inc
|
||||
delete ref[field.name]
|
||||
clearEmptyContainer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,28 +566,179 @@ export const transform = ({
|
||||
ref[field.name]
|
||||
) {
|
||||
const value = ref[field.name]
|
||||
if (value && typeof value === 'object' && '$push' in value) {
|
||||
const push = value.$push
|
||||
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
('$push' in value ||
|
||||
(config.localization &&
|
||||
fieldShouldBeLocalized({ field, parentIsLocalized }) &&
|
||||
Object.values(value).some(
|
||||
(localeValue) =>
|
||||
localeValue && typeof localeValue === 'object' && '$push' in localeValue,
|
||||
)))
|
||||
) {
|
||||
if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) {
|
||||
if (typeof push === 'object' && push !== null) {
|
||||
Object.entries(push).forEach(([localeKey, localeData]) => {
|
||||
if (Array.isArray(localeData)) {
|
||||
$push[`${parentPath}${field.name}.${localeKey}`] = { $each: localeData }
|
||||
} else if (typeof localeData === 'object') {
|
||||
$push[`${parentPath}${field.name}.${localeKey}`] = localeData
|
||||
// Handle localized fields: { field: { locale: { $push: data } } }
|
||||
let hasLocaleOperations = false
|
||||
Object.entries(value).forEach(([localeKey, localeValue]) => {
|
||||
if (localeValue && typeof localeValue === 'object' && '$push' in localeValue) {
|
||||
hasLocaleOperations = true
|
||||
const push = localeValue.$push
|
||||
if (Array.isArray(push)) {
|
||||
$push[`${parentPath}${field.name}.${localeKey}`] = { $each: push }
|
||||
} else if (typeof push === 'object') {
|
||||
$push[`${parentPath}${field.name}.${localeKey}`] = push
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (hasLocaleOperations) {
|
||||
delete ref[field.name]
|
||||
clearEmptyContainer()
|
||||
}
|
||||
} else {
|
||||
} else if (value && typeof value === 'object' && '$push' in value) {
|
||||
// Handle non-localized fields: { field: { $push: data } }
|
||||
const push = value.$push
|
||||
if (Array.isArray(push)) {
|
||||
$push[`${parentPath}${field.name}`] = { $each: push }
|
||||
} else if (typeof push === 'object') {
|
||||
$push[`${parentPath}${field.name}`] = push
|
||||
}
|
||||
delete ref[field.name]
|
||||
clearEmptyContainer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete ref[field.name]
|
||||
// Handle $push operation for relationship fields (converts to $addToSet)
|
||||
|
||||
// Handle $push operation for relationship fields (converts to $addToSet) - unified approach
|
||||
if (
|
||||
$addToSet &&
|
||||
(field.type === 'relationship' || field.type === 'upload') &&
|
||||
'hasMany' in field &&
|
||||
field.hasMany &&
|
||||
operation === 'write' &&
|
||||
field.name in ref &&
|
||||
ref[field.name]
|
||||
) {
|
||||
const value = ref[field.name]
|
||||
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
('$push' in value ||
|
||||
(config.localization &&
|
||||
fieldShouldBeLocalized({ field, parentIsLocalized }) &&
|
||||
Object.values(value).some(
|
||||
(localeValue) =>
|
||||
localeValue &&
|
||||
typeof localeValue === 'object' &&
|
||||
'$push' in (localeValue as Record<string, unknown>),
|
||||
)))
|
||||
) {
|
||||
if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) {
|
||||
// Handle localized fields: { field: { locale: { $push: data } } }
|
||||
let hasLocaleOperations = false
|
||||
Object.entries(value).forEach(([localeKey, localeValue]) => {
|
||||
if (localeValue && typeof localeValue === 'object' && '$push' in localeValue) {
|
||||
hasLocaleOperations = true
|
||||
const push = localeValue.$push
|
||||
const localeItems = Array.isArray(push) ? push : [push]
|
||||
const processedLocaleItems = processRelationshipValues(
|
||||
localeItems,
|
||||
field,
|
||||
config,
|
||||
operation,
|
||||
validateRelationships,
|
||||
)
|
||||
$addToSet[`${parentPath}${field.name}.${localeKey}`] = { $each: processedLocaleItems }
|
||||
}
|
||||
})
|
||||
|
||||
if (hasLocaleOperations) {
|
||||
delete ref[field.name]
|
||||
clearEmptyContainer()
|
||||
}
|
||||
} else if (value && typeof value === 'object' && '$push' in value) {
|
||||
// Handle non-localized fields: { field: { $push: data } }
|
||||
const itemsToAppend = Array.isArray(value.$push) ? value.$push : [value.$push]
|
||||
const processedItems = processRelationshipValues(
|
||||
itemsToAppend,
|
||||
field,
|
||||
config,
|
||||
operation,
|
||||
validateRelationships,
|
||||
)
|
||||
$addToSet[`${parentPath}${field.name}`] = { $each: processedItems }
|
||||
delete ref[field.name]
|
||||
clearEmptyContainer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle $remove operation for relationship fields (converts to $pull)
|
||||
if (
|
||||
$pull &&
|
||||
(field.type === 'relationship' || field.type === 'upload') &&
|
||||
'hasMany' in field &&
|
||||
field.hasMany &&
|
||||
operation === 'write' &&
|
||||
field.name in ref &&
|
||||
ref[field.name]
|
||||
) {
|
||||
const value = ref[field.name]
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
('$remove' in value ||
|
||||
(config.localization &&
|
||||
fieldShouldBeLocalized({ field, parentIsLocalized }) &&
|
||||
Object.values(value).some(
|
||||
(localeValue) =>
|
||||
localeValue &&
|
||||
typeof localeValue === 'object' &&
|
||||
'$remove' in (localeValue as Record<string, unknown>),
|
||||
)))
|
||||
) {
|
||||
if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) {
|
||||
// Handle localized fields: { field: { locale: { $remove: data } } }
|
||||
let hasLocaleOperations = false
|
||||
Object.entries(value).forEach(([localeKey, localeValue]) => {
|
||||
if (localeValue && typeof localeValue === 'object' && '$remove' in localeValue) {
|
||||
hasLocaleOperations = true
|
||||
const remove = localeValue.$remove
|
||||
const localeItems = Array.isArray(remove) ? remove : [remove]
|
||||
const processedLocaleItems = processRelationshipValues(
|
||||
localeItems,
|
||||
field,
|
||||
config,
|
||||
operation,
|
||||
validateRelationships,
|
||||
)
|
||||
$pull[`${parentPath}${field.name}.${localeKey}`] = { $in: processedLocaleItems }
|
||||
}
|
||||
})
|
||||
|
||||
if (hasLocaleOperations) {
|
||||
delete ref[field.name]
|
||||
clearEmptyContainer()
|
||||
}
|
||||
} else if (value && typeof value === 'object' && '$remove' in value) {
|
||||
// Handle non-localized fields: { field: { $remove: data } }
|
||||
const itemsToRemove = Array.isArray(value.$remove) ? value.$remove : [value.$remove]
|
||||
const processedItems = processRelationshipValues(
|
||||
itemsToRemove,
|
||||
field,
|
||||
config,
|
||||
operation,
|
||||
validateRelationships,
|
||||
)
|
||||
$pull[`${parentPath}${field.name}`] = { $in: processedItems }
|
||||
delete ref[field.name]
|
||||
clearEmptyContainer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ export const transformArray = ({
|
||||
parentTableName: arrayTableName,
|
||||
path: `${path || ''}${field.name}.${i}.`,
|
||||
relationships,
|
||||
relationshipsToAppend: [],
|
||||
relationshipsToDelete,
|
||||
row: newRow.row,
|
||||
selects,
|
||||
|
||||
@@ -133,6 +133,7 @@ export const transformBlocks = ({
|
||||
parentTableName: blockTableName,
|
||||
path: `${path || ''}${field.name}.${i}.`,
|
||||
relationships,
|
||||
relationshipsToAppend: [],
|
||||
relationshipsToDelete,
|
||||
row: newRow.row,
|
||||
selects,
|
||||
|
||||
@@ -34,6 +34,7 @@ export const transformForWrite = ({
|
||||
numbers: [],
|
||||
numbersToDelete: [],
|
||||
relationships: [],
|
||||
relationshipsToAppend: [],
|
||||
relationshipsToDelete: [],
|
||||
row: {},
|
||||
selects: {},
|
||||
@@ -62,6 +63,7 @@ export const transformForWrite = ({
|
||||
parentTableName: tableName,
|
||||
path,
|
||||
relationships: rowToInsert.relationships,
|
||||
relationshipsToAppend: rowToInsert.relationshipsToAppend,
|
||||
relationshipsToDelete: rowToInsert.relationshipsToDelete,
|
||||
row: rowToInsert.row,
|
||||
selects: rowToInsert.selects,
|
||||
|
||||
@@ -4,7 +4,13 @@ import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared'
|
||||
import toSnakeCase from 'to-snake-case'
|
||||
|
||||
import type { DrizzleAdapter } from '../../types.js'
|
||||
import type { NumberToDelete, RelationshipToDelete, RowToInsert, TextToDelete } from './types.js'
|
||||
import type {
|
||||
NumberToDelete,
|
||||
RelationshipToAppend,
|
||||
RelationshipToDelete,
|
||||
RowToInsert,
|
||||
TextToDelete,
|
||||
} from './types.js'
|
||||
|
||||
import { isArrayOfRows } from '../../utilities/isArrayOfRows.js'
|
||||
import { resolveBlockTableName } from '../../utilities/validateExistingBlockIsIdentical.js'
|
||||
@@ -63,6 +69,7 @@ type Args = {
|
||||
parentTableName: string
|
||||
path: string
|
||||
relationships: Record<string, unknown>[]
|
||||
relationshipsToAppend: RelationshipToAppend[]
|
||||
relationshipsToDelete: RelationshipToDelete[]
|
||||
row: Record<string, unknown>
|
||||
selects: {
|
||||
@@ -99,6 +106,7 @@ export const traverseFields = ({
|
||||
parentTableName,
|
||||
path,
|
||||
relationships,
|
||||
relationshipsToAppend,
|
||||
relationshipsToDelete,
|
||||
row,
|
||||
selects,
|
||||
@@ -106,6 +114,8 @@ export const traverseFields = ({
|
||||
textsToDelete,
|
||||
withinArrayOrBlockLocale,
|
||||
}: Args) => {
|
||||
let fieldsMatched = false
|
||||
|
||||
if (row._uuid) {
|
||||
data._uuid = row._uuid
|
||||
}
|
||||
@@ -119,6 +129,11 @@ export const traverseFields = ({
|
||||
return
|
||||
}
|
||||
|
||||
// Mark that we found a matching field
|
||||
if (data[field.name] !== undefined) {
|
||||
fieldsMatched = true
|
||||
}
|
||||
|
||||
columnName = `${columnPrefix || ''}${toSnakeCase(field.name)}`
|
||||
fieldName = `${fieldPrefix || ''}${field.name}`
|
||||
fieldData = data[field.name]
|
||||
@@ -129,21 +144,19 @@ export const traverseFields = ({
|
||||
const arrayTableName = adapter.tableNameMap.get(`${parentTableName}_${columnName}`)
|
||||
|
||||
if (isLocalized) {
|
||||
let value: {
|
||||
[locale: string]: unknown[]
|
||||
} = data[field.name] as any
|
||||
|
||||
let push = false
|
||||
if (typeof value === 'object' && '$push' in value) {
|
||||
value = value.$push as any
|
||||
push = true
|
||||
}
|
||||
const value = data[field.name]
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
Object.entries(value).forEach(([localeKey, _localeData]) => {
|
||||
let localeData = _localeData
|
||||
if (push && !Array.isArray(localeData)) {
|
||||
localeData = [localeData]
|
||||
Object.entries(value).forEach(([localeKey, localeValue]) => {
|
||||
let localeData = localeValue
|
||||
let push = false
|
||||
|
||||
if (localeValue && typeof localeValue === 'object' && '$push' in localeValue) {
|
||||
localeData = localeValue.$push
|
||||
push = true
|
||||
if (!Array.isArray(localeData)) {
|
||||
localeData = [localeData]
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(localeData)) {
|
||||
@@ -320,6 +333,7 @@ export const traverseFields = ({
|
||||
parentTableName,
|
||||
path: `${path || ''}${field.name}.`,
|
||||
relationships,
|
||||
relationshipsToAppend,
|
||||
relationshipsToDelete,
|
||||
row,
|
||||
selects,
|
||||
@@ -353,6 +367,7 @@ export const traverseFields = ({
|
||||
parentTableName,
|
||||
path: `${path || ''}${field.name}.`,
|
||||
relationships,
|
||||
relationshipsToAppend,
|
||||
relationshipsToDelete,
|
||||
row,
|
||||
selects,
|
||||
@@ -369,6 +384,164 @@ export const traverseFields = ({
|
||||
if (field.type === 'relationship' || field.type === 'upload') {
|
||||
const relationshipPath = `${path || ''}${field.name}`
|
||||
|
||||
// Handle $push operation for relationship fields
|
||||
if (
|
||||
fieldData &&
|
||||
typeof fieldData === 'object' &&
|
||||
'hasMany' in field &&
|
||||
field.hasMany &&
|
||||
('$push' in fieldData ||
|
||||
(field.localized &&
|
||||
Object.values(fieldData).some(
|
||||
(localeValue) =>
|
||||
localeValue &&
|
||||
typeof localeValue === 'object' &&
|
||||
'$push' in (localeValue as Record<string, unknown>),
|
||||
)))
|
||||
) {
|
||||
let itemsToAppend: unknown[]
|
||||
|
||||
if (field.localized) {
|
||||
let hasLocaleOperations = false
|
||||
Object.entries(fieldData).forEach(([localeKey, localeValue]) => {
|
||||
if (localeValue && typeof localeValue === 'object' && '$push' in localeValue) {
|
||||
hasLocaleOperations = true
|
||||
const push = localeValue.$push
|
||||
const localeItems = Array.isArray(push) ? push : [push]
|
||||
|
||||
localeItems.forEach((item) => {
|
||||
const relationshipToAppend: RelationshipToAppend = {
|
||||
locale: localeKey,
|
||||
path: relationshipPath,
|
||||
value: item,
|
||||
}
|
||||
|
||||
// Handle polymorphic relationships
|
||||
if (
|
||||
Array.isArray(field.relationTo) &&
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
'relationTo' in item
|
||||
) {
|
||||
relationshipToAppend.relationTo = item.relationTo
|
||||
relationshipToAppend.value = item.value
|
||||
} else if (typeof field.relationTo === 'string') {
|
||||
// Simple relationship
|
||||
relationshipToAppend.relationTo = field.relationTo
|
||||
relationshipToAppend.value = item
|
||||
}
|
||||
|
||||
relationshipsToAppend.push(relationshipToAppend)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (hasLocaleOperations) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Handle non-localized fields: { field: { $push: data } }
|
||||
itemsToAppend = Array.isArray((fieldData as any).$push)
|
||||
? (fieldData as any).$push
|
||||
: [(fieldData as any).$push]
|
||||
|
||||
itemsToAppend.forEach((item) => {
|
||||
const relationshipToAppend: RelationshipToAppend = {
|
||||
locale: isLocalized ? withinArrayOrBlockLocale : undefined,
|
||||
path: relationshipPath,
|
||||
value: item,
|
||||
}
|
||||
|
||||
// Handle polymorphic relationships
|
||||
if (
|
||||
Array.isArray(field.relationTo) &&
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
'relationTo' in item &&
|
||||
'value' in item
|
||||
) {
|
||||
relationshipToAppend.relationTo = item.relationTo as string
|
||||
relationshipToAppend.value = item.value as number | string
|
||||
} else if (typeof field.relationTo === 'string') {
|
||||
// Simple relationship
|
||||
relationshipToAppend.relationTo = field.relationTo
|
||||
relationshipToAppend.value = item
|
||||
}
|
||||
|
||||
relationshipsToAppend.push(relationshipToAppend)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle $remove operation for relationship fields
|
||||
if (
|
||||
fieldData &&
|
||||
typeof fieldData === 'object' &&
|
||||
'hasMany' in field &&
|
||||
field.hasMany &&
|
||||
('$remove' in fieldData ||
|
||||
(field.localized &&
|
||||
Object.values(fieldData).some(
|
||||
(localeValue) =>
|
||||
localeValue &&
|
||||
typeof localeValue === 'object' &&
|
||||
'$remove' in (localeValue as Record<string, unknown>),
|
||||
)))
|
||||
) {
|
||||
// Check for new locale-first syntax: { field: { locale: { $remove: data } } }
|
||||
if (field.localized) {
|
||||
let hasLocaleOperations = false
|
||||
Object.entries(fieldData).forEach(([localeKey, localeValue]) => {
|
||||
if (localeValue && typeof localeValue === 'object' && '$remove' in localeValue) {
|
||||
hasLocaleOperations = true
|
||||
const remove = localeValue.$remove
|
||||
const localeItems = Array.isArray(remove) ? remove : [remove]
|
||||
|
||||
localeItems.forEach((item) => {
|
||||
const relationshipToDelete: RelationshipToDelete = {
|
||||
itemToRemove: item,
|
||||
locale: localeKey,
|
||||
path: relationshipPath,
|
||||
}
|
||||
|
||||
// Store relationTo for simple relationships
|
||||
if (typeof field.relationTo === 'string') {
|
||||
relationshipToDelete.relationTo = field.relationTo
|
||||
}
|
||||
|
||||
relationshipsToDelete.push(relationshipToDelete)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (hasLocaleOperations) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Handle non-localized fields: { field: { $remove: data } }
|
||||
const itemsToRemove = Array.isArray((fieldData as any).$remove)
|
||||
? (fieldData as any).$remove
|
||||
: [(fieldData as any).$remove]
|
||||
|
||||
itemsToRemove.forEach((item) => {
|
||||
const relationshipToDelete: RelationshipToDelete = {
|
||||
itemToRemove: item,
|
||||
locale: isLocalized ? withinArrayOrBlockLocale : undefined,
|
||||
path: relationshipPath,
|
||||
}
|
||||
|
||||
// Store relationTo for simple relationships
|
||||
if (typeof field.relationTo === 'string') {
|
||||
relationshipToDelete.relationTo = field.relationTo
|
||||
}
|
||||
|
||||
relationshipsToDelete.push(relationshipToDelete)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isLocalized &&
|
||||
(Array.isArray(field.relationTo) || ('hasMany' in field && field.hasMany))
|
||||
@@ -633,4 +806,60 @@ export const traverseFields = ({
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Handle dot-notation paths when no fields matched
|
||||
if (!fieldsMatched) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key.includes('.')) {
|
||||
// Split on first dot only
|
||||
const firstDotIndex = key.indexOf('.')
|
||||
const fieldName = key.substring(0, firstDotIndex)
|
||||
const remainingPath = key.substring(firstDotIndex + 1)
|
||||
|
||||
// Create nested structure for this field
|
||||
if (!data[fieldName]) {
|
||||
data[fieldName] = {}
|
||||
}
|
||||
|
||||
const nestedData = data[fieldName] as Record<string, unknown>
|
||||
|
||||
// Move the value to the nested structure
|
||||
nestedData[remainingPath] = data[key]
|
||||
delete data[key]
|
||||
|
||||
// Recursively process the newly created nested structure
|
||||
// The field traversal will naturally handle it if the field exists in the schema
|
||||
traverseFields({
|
||||
adapter,
|
||||
arrays,
|
||||
arraysToPush,
|
||||
baseTableName,
|
||||
blocks,
|
||||
blocksToDelete,
|
||||
columnPrefix,
|
||||
data,
|
||||
enableAtomicWrites,
|
||||
existingLocales,
|
||||
fieldPrefix,
|
||||
fields,
|
||||
forcedLocale,
|
||||
insideArrayOrBlock,
|
||||
locales,
|
||||
numbers,
|
||||
numbersToDelete,
|
||||
parentIsLocalized,
|
||||
parentTableName,
|
||||
path,
|
||||
relationships,
|
||||
relationshipsToAppend,
|
||||
relationshipsToDelete,
|
||||
row,
|
||||
selects,
|
||||
texts,
|
||||
textsToDelete,
|
||||
withinArrayOrBlockLocale,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,17 @@ export type BlockRowToInsert = {
|
||||
}
|
||||
|
||||
export type RelationshipToDelete = {
|
||||
itemToRemove?: any // For $remove operations - stores the item data to match
|
||||
locale?: string
|
||||
path: string
|
||||
relationTo?: string // For simple relationships - stores the relationTo field
|
||||
}
|
||||
|
||||
export type RelationshipToAppend = {
|
||||
locale?: string
|
||||
path: string
|
||||
relationTo?: string // For polymorphic relationships
|
||||
value: any
|
||||
}
|
||||
|
||||
export type TextToDelete = {
|
||||
@@ -56,6 +65,7 @@ export type RowToInsert = {
|
||||
numbers: Record<string, unknown>[]
|
||||
numbersToDelete: NumberToDelete[]
|
||||
relationships: Record<string, unknown>[]
|
||||
relationshipsToAppend: RelationshipToAppend[]
|
||||
relationshipsToDelete: RelationshipToDelete[]
|
||||
row: Record<string, unknown>
|
||||
selects: {
|
||||
|
||||
@@ -2,12 +2,21 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
||||
import type { SelectedFields } from 'drizzle-orm/sqlite-core'
|
||||
import type { TypeWithID } from 'payload'
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { and, desc, eq, isNull, or } from 'drizzle-orm'
|
||||
import { ValidationError } from 'payload'
|
||||
|
||||
import type { BlockRowToInsert } from '../transform/write/types.js'
|
||||
import type { Args } from './types.js'
|
||||
|
||||
type RelationshipRow = {
|
||||
[key: string]: number | string | undefined // For relationship ID columns like categoriesID, moviesID, etc.
|
||||
id?: number | string
|
||||
locale?: string
|
||||
order: number
|
||||
parent: number | string // Drizzle table uses 'parent' key
|
||||
path: string
|
||||
}
|
||||
|
||||
import { buildFindManyArgs } from '../find/buildFindManyArgs.js'
|
||||
import { transform } from '../transform/read/index.js'
|
||||
import { transformForWrite } from '../transform/write/index.js'
|
||||
@@ -186,21 +195,32 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
|
||||
if (operation === 'update') {
|
||||
const target = upsertTarget || adapter.tables[tableName].id
|
||||
|
||||
if (id) {
|
||||
rowToInsert.row.id = id
|
||||
;[insertedRow] = await adapter.insert({
|
||||
db,
|
||||
onConflictDoUpdate: { set: rowToInsert.row, target },
|
||||
tableName,
|
||||
values: rowToInsert.row,
|
||||
})
|
||||
// Check if we only have relationship operations and no main row data to update
|
||||
// Exclude timestamp-only updates when we only have relationship operations
|
||||
const rowKeys = Object.keys(rowToInsert.row)
|
||||
const hasMainRowData =
|
||||
rowKeys.length > 0 && !rowKeys.every((key) => key === 'updatedAt' || key === 'createdAt')
|
||||
|
||||
if (hasMainRowData) {
|
||||
if (id) {
|
||||
rowToInsert.row.id = id
|
||||
;[insertedRow] = await adapter.insert({
|
||||
db,
|
||||
onConflictDoUpdate: { set: rowToInsert.row, target },
|
||||
tableName,
|
||||
values: rowToInsert.row,
|
||||
})
|
||||
} else {
|
||||
;[insertedRow] = await adapter.insert({
|
||||
db,
|
||||
onConflictDoUpdate: { set: rowToInsert.row, target, where },
|
||||
tableName,
|
||||
values: rowToInsert.row,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
;[insertedRow] = await adapter.insert({
|
||||
db,
|
||||
onConflictDoUpdate: { set: rowToInsert.row, target, where },
|
||||
tableName,
|
||||
values: rowToInsert.row,
|
||||
})
|
||||
// No main row data to update, just use the existing ID
|
||||
insertedRow = { id }
|
||||
}
|
||||
} else {
|
||||
if (adapter.allowIDOnCreate && data.id) {
|
||||
@@ -314,6 +334,11 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
|
||||
const relationshipsTableName = `${tableName}${adapter.relationshipsSuffix}`
|
||||
|
||||
if (operation === 'update') {
|
||||
// Filter out specific item deletions (those with itemToRemove) from general path deletions
|
||||
const generalRelationshipDeletes = rowToInsert.relationshipsToDelete.filter(
|
||||
(rel) => !('itemToRemove' in rel),
|
||||
)
|
||||
|
||||
await deleteExistingRowsByPath({
|
||||
adapter,
|
||||
db,
|
||||
@@ -321,7 +346,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
|
||||
parentColumnName: 'parent',
|
||||
parentID: insertedRow.id,
|
||||
pathColumnName: 'path',
|
||||
rows: [...relationsToInsert, ...rowToInsert.relationshipsToDelete],
|
||||
rows: [...relationsToInsert, ...generalRelationshipDeletes],
|
||||
tableName: relationshipsTableName,
|
||||
})
|
||||
}
|
||||
@@ -334,6 +359,186 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
|
||||
})
|
||||
}
|
||||
|
||||
// //////////////////////////////////
|
||||
// HANDLE RELATIONSHIP $push OPERATIONS
|
||||
// //////////////////////////////////
|
||||
|
||||
if (rowToInsert.relationshipsToAppend.length > 0) {
|
||||
// Prepare all relationships for batch insert (order will be set after max query)
|
||||
const relationshipsToInsert = rowToInsert.relationshipsToAppend.map((rel) => {
|
||||
const parentId = id || insertedRow.id
|
||||
const row: Record<string, unknown> = {
|
||||
parent: parentId as number | string, // Use 'parent' key for Drizzle table
|
||||
path: rel.path,
|
||||
}
|
||||
|
||||
// Only add locale if this relationship table has a locale column
|
||||
const relationshipTable = adapter.rawTables[relationshipsTableName]
|
||||
if (rel.locale && relationshipTable && relationshipTable.columns.locale) {
|
||||
row.locale = rel.locale
|
||||
}
|
||||
|
||||
if (rel.relationTo) {
|
||||
// Use camelCase key for Drizzle table (e.g., categoriesID not categories_id)
|
||||
row[`${rel.relationTo}ID`] = rel.value
|
||||
}
|
||||
|
||||
return row
|
||||
})
|
||||
|
||||
if (relationshipsToInsert.length > 0) {
|
||||
// Check for potential duplicates
|
||||
const relationshipTable = adapter.tables[relationshipsTableName]
|
||||
|
||||
if (relationshipTable) {
|
||||
// Build conditions only if we have relationships to check
|
||||
if (relationshipsToInsert.length === 0) {
|
||||
return // No relationships to insert
|
||||
}
|
||||
|
||||
const conditions = relationshipsToInsert.map((row: RelationshipRow) => {
|
||||
const parts = [
|
||||
eq(relationshipTable.parent, row.parent),
|
||||
eq(relationshipTable.path, row.path),
|
||||
]
|
||||
|
||||
// Add locale condition
|
||||
if (row.locale !== undefined && relationshipTable.locale) {
|
||||
parts.push(eq(relationshipTable.locale, row.locale))
|
||||
} else if (relationshipTable.locale) {
|
||||
parts.push(isNull(relationshipTable.locale))
|
||||
}
|
||||
|
||||
// Add all relationship ID matches using schema fields
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (key.endsWith('ID') && value != null) {
|
||||
const column = relationshipTable[key]
|
||||
if (column && typeof column === 'object') {
|
||||
parts.push(eq(column, value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return and(...parts)
|
||||
})
|
||||
|
||||
// Get both existing relationships AND max order in a single query
|
||||
let existingRels: Record<string, unknown>[] = []
|
||||
let maxOrder = 0
|
||||
|
||||
if (conditions.length > 0) {
|
||||
// Query for existing relationships
|
||||
existingRels = await (db as any)
|
||||
.select()
|
||||
.from(relationshipTable)
|
||||
.where(or(...conditions))
|
||||
}
|
||||
|
||||
// Get max order for this parent across all paths in a single query
|
||||
const parentId = id || insertedRow.id
|
||||
const maxOrderResult = await (db as any)
|
||||
.select({ maxOrder: relationshipTable.order })
|
||||
.from(relationshipTable)
|
||||
.where(eq(relationshipTable.parent, parentId))
|
||||
.orderBy(desc(relationshipTable.order))
|
||||
.limit(1)
|
||||
|
||||
if (maxOrderResult.length > 0 && maxOrderResult[0].maxOrder) {
|
||||
maxOrder = maxOrderResult[0].maxOrder
|
||||
}
|
||||
|
||||
// Set order values for all relationships based on max order
|
||||
relationshipsToInsert.forEach((row, index) => {
|
||||
row.order = maxOrder + index + 1
|
||||
})
|
||||
|
||||
// Filter out relationships that already exist
|
||||
const relationshipsToActuallyInsert = relationshipsToInsert.filter((newRow) => {
|
||||
return !existingRels.some((existingRow: Record<string, unknown>) => {
|
||||
// Check if this relationship already exists
|
||||
let matches = existingRow.parent === newRow.parent && existingRow.path === newRow.path
|
||||
|
||||
if (newRow.locale !== undefined) {
|
||||
matches = matches && existingRow.locale === newRow.locale
|
||||
}
|
||||
|
||||
// Check relationship value matches - convert to camelCase for comparison
|
||||
for (const key of Object.keys(newRow)) {
|
||||
if (key.endsWith('ID')) {
|
||||
// Now using camelCase keys
|
||||
matches = matches && existingRow[key] === newRow[key]
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
})
|
||||
})
|
||||
|
||||
// Insert only non-duplicate relationships
|
||||
if (relationshipsToActuallyInsert.length > 0) {
|
||||
await adapter.insert({
|
||||
db,
|
||||
tableName: relationshipsTableName,
|
||||
values: relationshipsToActuallyInsert,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// //////////////////////////////////
|
||||
// HANDLE RELATIONSHIP $remove OPERATIONS
|
||||
// //////////////////////////////////
|
||||
|
||||
if (rowToInsert.relationshipsToDelete.some((rel) => 'itemToRemove' in rel)) {
|
||||
const relationshipTable = adapter.tables[relationshipsTableName]
|
||||
|
||||
if (relationshipTable) {
|
||||
for (const relToDelete of rowToInsert.relationshipsToDelete) {
|
||||
if ('itemToRemove' in relToDelete && relToDelete.itemToRemove) {
|
||||
const item = relToDelete.itemToRemove
|
||||
const parentId = (id || insertedRow.id) as number | string
|
||||
|
||||
const conditions = [
|
||||
eq(relationshipTable.parent, parentId),
|
||||
eq(relationshipTable.path, relToDelete.path),
|
||||
]
|
||||
|
||||
// Add locale condition if this relationship table has a locale column
|
||||
if (adapter.rawTables[relationshipsTableName]?.columns.locale) {
|
||||
if (relToDelete.locale) {
|
||||
conditions.push(eq(relationshipTable.locale, relToDelete.locale))
|
||||
} else {
|
||||
conditions.push(isNull(relationshipTable.locale))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle polymorphic vs simple relationships
|
||||
if (typeof item === 'object' && 'relationTo' in item) {
|
||||
// Polymorphic relationship - convert to camelCase key
|
||||
const camelKey = `${item.relationTo}ID`
|
||||
if (relationshipTable[camelKey]) {
|
||||
conditions.push(eq(relationshipTable[camelKey], item.value))
|
||||
}
|
||||
} else if (relToDelete.relationTo) {
|
||||
// Simple relationship - convert to camelCase key
|
||||
const camelKey = `${relToDelete.relationTo}ID`
|
||||
if (relationshipTable[camelKey]) {
|
||||
conditions.push(eq(relationshipTable[camelKey], item))
|
||||
}
|
||||
}
|
||||
|
||||
// Execute DELETE using Drizzle query builder
|
||||
await adapter.deleteWhere({
|
||||
db,
|
||||
tableName: relationshipsTableName,
|
||||
where: and(...conditions),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// //////////////////////////////////
|
||||
// INSERT hasMany TEXTS
|
||||
// //////////////////////////////////
|
||||
@@ -497,7 +702,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
|
||||
}
|
||||
|
||||
if (Object.keys(arraysBlocksUUIDMap).length > 0) {
|
||||
tableRows.forEach((row: any) => {
|
||||
tableRows.forEach((row: RelationshipRow) => {
|
||||
if (row.parent in arraysBlocksUUIDMap) {
|
||||
row.parent = arraysBlocksUUIDMap[row.parent]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export const shouldUseOptimizedUpsertRow = ({
|
||||
data: Record<string, unknown>
|
||||
fields: FlattenedField[]
|
||||
}) => {
|
||||
let fieldsMatched = false
|
||||
|
||||
for (const key in data) {
|
||||
const value = data[key]
|
||||
const field = fields.find((each) => each.name === key)
|
||||
@@ -19,6 +21,8 @@ export const shouldUseOptimizedUpsertRow = ({
|
||||
continue
|
||||
}
|
||||
|
||||
fieldsMatched = true
|
||||
|
||||
if (
|
||||
field.type === 'blocks' ||
|
||||
((field.type === 'text' ||
|
||||
@@ -45,6 +49,13 @@ export const shouldUseOptimizedUpsertRow = ({
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle relationship $push and $remove operations
|
||||
if ((field.type === 'relationship' || field.type === 'upload') && field.hasMany) {
|
||||
if (typeof value === 'object' && ('$push' in value || '$remove' in value)) {
|
||||
return false // Use full upsertRow for relationship operations
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(field.type === 'group' || field.type === 'tab') &&
|
||||
value &&
|
||||
@@ -58,5 +69,23 @@ export const shouldUseOptimizedUpsertRow = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dot-notation paths when no fields matched
|
||||
if (!fieldsMatched) {
|
||||
for (const key in data) {
|
||||
if (key.includes('.')) {
|
||||
// Split on first dot only
|
||||
const firstDotIndex = key.indexOf('.')
|
||||
const fieldName = key.substring(0, firstDotIndex)
|
||||
const remainingPath = key.substring(firstDotIndex + 1)
|
||||
|
||||
const nestedData = { [fieldName]: { [remainingPath]: data[key] } }
|
||||
return shouldUseOptimizedUpsertRow({
|
||||
data: nestedData,
|
||||
fields,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export const traverseFields = ({
|
||||
parentRef = {},
|
||||
ref = {},
|
||||
}: TraverseFieldsArgs): void => {
|
||||
fields.some((field) => {
|
||||
const fieldsMatched = fields.some((field) => {
|
||||
let callbackStack: (() => ReturnType<TraverseFieldsCallback>)[] = []
|
||||
if (!isTopLevel) {
|
||||
callbackStack = _callbackStack
|
||||
@@ -374,7 +374,7 @@ export const traverseFields = ({
|
||||
isTopLevel: false,
|
||||
leavesFirst,
|
||||
parentIsLocalized: true,
|
||||
parentPath: field.name ? `${parentPath}${field.name}` : parentPath,
|
||||
parentPath: field.name ? `${parentPath}${field.name}.` : parentPath,
|
||||
parentRef: currentParentRef,
|
||||
ref: currentRef[key as keyof typeof currentRef],
|
||||
})
|
||||
@@ -473,7 +473,7 @@ export const traverseFields = ({
|
||||
isTopLevel: false,
|
||||
leavesFirst,
|
||||
parentIsLocalized,
|
||||
parentPath,
|
||||
parentPath: 'name' in field && field.name ? `${parentPath}${field.name}.` : parentPath,
|
||||
parentRef: currentParentRef,
|
||||
ref: currentRef,
|
||||
})
|
||||
@@ -486,4 +486,43 @@ export const traverseFields = ({
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback: Handle dot-notation paths when no fields matched
|
||||
if (!fieldsMatched && ref && typeof ref === 'object') {
|
||||
Object.keys(ref).forEach((key) => {
|
||||
if (key.includes('.')) {
|
||||
// Split on first dot only
|
||||
const firstDotIndex = key.indexOf('.')
|
||||
const fieldName = key.substring(0, firstDotIndex)
|
||||
const remainingPath = key.substring(firstDotIndex + 1)
|
||||
|
||||
// Create nested structure for this field
|
||||
if (!ref[fieldName as keyof typeof ref]) {
|
||||
;(ref as Record<string, unknown>)[fieldName] = {}
|
||||
}
|
||||
|
||||
const nestedRef = ref[fieldName as keyof typeof ref] as Record<string, unknown>
|
||||
|
||||
// Move the value to the nested structure
|
||||
nestedRef[remainingPath] = (ref as Record<string, unknown>)[key]
|
||||
delete (ref as Record<string, unknown>)[key]
|
||||
|
||||
// Recursively process the newly created nested structure
|
||||
// The field traversal will naturally handle it if the field exists in the schema
|
||||
traverseFields({
|
||||
callback,
|
||||
callbackStack: _callbackStack,
|
||||
config,
|
||||
fields,
|
||||
fillEmpty,
|
||||
isTopLevel: false,
|
||||
leavesFirst,
|
||||
parentIsLocalized,
|
||||
parentPath,
|
||||
parentRef,
|
||||
ref,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export interface Config {
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: string;
|
||||
defaultIDType: number;
|
||||
};
|
||||
globals: {
|
||||
menu: Menu;
|
||||
@@ -124,13 +124,13 @@ export interface UserAuthOperations {
|
||||
* via the `definition` "posts".
|
||||
*/
|
||||
export interface Post {
|
||||
id: string;
|
||||
id: number;
|
||||
title?: string | null;
|
||||
content?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: string;
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
@@ -149,7 +149,7 @@ export interface Post {
|
||||
* via the `definition` "media".
|
||||
*/
|
||||
export interface Media {
|
||||
id: string;
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
@@ -193,7 +193,7 @@ export interface Media {
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
@@ -217,24 +217,24 @@ export interface User {
|
||||
* via the `definition` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: string;
|
||||
id: number;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'posts';
|
||||
value: string | Post;
|
||||
value: number | Post;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
value: string | Media;
|
||||
value: number | Media;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
value: number | User;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
value: number | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
@@ -244,10 +244,10 @@ export interface PayloadLockedDocument {
|
||||
* via the `definition` "payload-preferences".
|
||||
*/
|
||||
export interface PayloadPreference {
|
||||
id: string;
|
||||
id: number;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
value: number | User;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
@@ -267,7 +267,7 @@ export interface PayloadPreference {
|
||||
* via the `definition` "payload-migrations".
|
||||
*/
|
||||
export interface PayloadMigration {
|
||||
id: string;
|
||||
id: number;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
@@ -393,7 +393,7 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
* via the `definition` "menu".
|
||||
*/
|
||||
export interface Menu {
|
||||
id: string;
|
||||
id: number;
|
||||
globalText?: string | null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
|
||||
@@ -148,6 +148,19 @@ export const getConfig: () => Partial<Config> = () => ({
|
||||
relationTo: 'categories-custom-id',
|
||||
name: 'categoryCustomID',
|
||||
},
|
||||
{
|
||||
type: 'relationship',
|
||||
relationTo: ['categories', 'simple'],
|
||||
hasMany: true,
|
||||
name: 'polymorphicRelations',
|
||||
},
|
||||
{
|
||||
type: 'relationship',
|
||||
relationTo: ['categories', 'simple'],
|
||||
hasMany: true,
|
||||
localized: true,
|
||||
name: 'localizedPolymorphicRelations',
|
||||
},
|
||||
{
|
||||
name: 'localized',
|
||||
type: 'text',
|
||||
@@ -188,6 +201,32 @@ export const getConfig: () => Partial<Config> = () => ({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
name: 'testNestedGroup',
|
||||
fields: [
|
||||
{
|
||||
name: 'nestedLocalizedPolymorphicRelation',
|
||||
type: 'relationship',
|
||||
relationTo: ['categories', 'simple'],
|
||||
hasMany: true,
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'nestedLocalizedText',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'nestedText1',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'nestedText2',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
|
||||
@@ -3831,12 +3831,14 @@ describe('database', () => {
|
||||
// Locales used => no optimized row update => need to pass full data, incuding title
|
||||
title: 'post',
|
||||
arrayWithIDsLocalized: {
|
||||
$push: {
|
||||
en: {
|
||||
en: {
|
||||
$push: {
|
||||
text: 'some text 2',
|
||||
id: new mongoose.Types.ObjectId().toHexString(),
|
||||
},
|
||||
es: {
|
||||
},
|
||||
es: {
|
||||
$push: {
|
||||
text: 'some text 2 es',
|
||||
id: new mongoose.Types.ObjectId().toHexString(),
|
||||
},
|
||||
@@ -3845,7 +3847,7 @@ describe('database', () => {
|
||||
},
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
})) as unknown as any
|
||||
})) as unknown as Post
|
||||
|
||||
expect(res.arrayWithIDsLocalized?.en).toHaveLength(2)
|
||||
expect(res.arrayWithIDsLocalized?.en?.[0]?.text).toBe('some text')
|
||||
@@ -3972,12 +3974,14 @@ describe('database', () => {
|
||||
// Locales used => no optimized row update => need to pass full data, incuding title
|
||||
title: 'post',
|
||||
arrayWithIDsLocalized: {
|
||||
$push: {
|
||||
en: {
|
||||
en: {
|
||||
$push: {
|
||||
text: 'some text 2',
|
||||
id: new mongoose.Types.ObjectId().toHexString(),
|
||||
},
|
||||
es: [
|
||||
},
|
||||
es: {
|
||||
$push: [
|
||||
{
|
||||
text: 'some text 2 es',
|
||||
id: new mongoose.Types.ObjectId().toHexString(),
|
||||
@@ -4004,6 +4008,781 @@ describe('database', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('relationship $push', () => {
|
||||
it('should allow appending relationships using $push with single value', async () => {
|
||||
// First create some category documents
|
||||
const cat1 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 1' },
|
||||
})
|
||||
const cat2 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 2' },
|
||||
})
|
||||
|
||||
// Create a post with initial relationship
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Post',
|
||||
categories: [cat1.id],
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
expect(post.categories).toHaveLength(1)
|
||||
expect(post.categories?.[0]).toBe(cat1.id)
|
||||
|
||||
// Append another relationship using $push
|
||||
const result = (await payload.db.updateOne({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: {
|
||||
categories: {
|
||||
$push: cat2.id,
|
||||
},
|
||||
},
|
||||
})) as unknown as Post
|
||||
|
||||
expect(result.categories).toHaveLength(2)
|
||||
// Handle both populated and non-populated relationships
|
||||
const resultIds = result.categories?.map((cat) => cat as string)
|
||||
expect(resultIds).toContain(cat1.id)
|
||||
expect(resultIds).toContain(cat2.id)
|
||||
})
|
||||
|
||||
it('should allow appending relationships using $push with array', async () => {
|
||||
// Create category documents
|
||||
const cat1 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 1' },
|
||||
})
|
||||
const cat2 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 2' },
|
||||
})
|
||||
const cat3 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 3' },
|
||||
})
|
||||
|
||||
// Create post with initial relationship
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Post',
|
||||
categories: [cat1.id],
|
||||
},
|
||||
})
|
||||
|
||||
// Append multiple relationships using $push
|
||||
const result = (await payload.db.updateOne({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: {
|
||||
categories: {
|
||||
$push: [cat2.id, cat3.id],
|
||||
},
|
||||
},
|
||||
})) as unknown as Post
|
||||
|
||||
expect(result.categories).toHaveLength(3)
|
||||
// Handle both populated and non-populated relationships
|
||||
const resultIds = result.categories?.map((cat) => cat as string)
|
||||
expect(resultIds).toContain(cat1.id)
|
||||
expect(resultIds).toContain(cat2.id)
|
||||
expect(resultIds).toContain(cat3.id)
|
||||
})
|
||||
|
||||
it('should prevent duplicates when using $push', async () => {
|
||||
// Create category documents
|
||||
const cat1 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 1' },
|
||||
})
|
||||
const cat2 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 2' },
|
||||
})
|
||||
|
||||
// Create post with initial relationships
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Post',
|
||||
categories: [cat1.id, cat2.id],
|
||||
},
|
||||
})
|
||||
|
||||
// Try to append existing relationship - should not create duplicates
|
||||
const result = (await payload.db.updateOne({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: {
|
||||
categories: {
|
||||
$push: [cat1.id, cat2.id], // Appending existing items
|
||||
},
|
||||
},
|
||||
})) as unknown as Post
|
||||
|
||||
expect(result.categories).toHaveLength(2) // Should still be 2, no duplicates
|
||||
// Handle both populated and non-populated relationships
|
||||
const resultIds = result.categories?.map((cat) => cat as string)
|
||||
expect(resultIds).toContain(cat1.id)
|
||||
expect(resultIds).toContain(cat2.id)
|
||||
})
|
||||
|
||||
it('should work with updateMany for bulk append operations', async () => {
|
||||
// Create category documents
|
||||
const cat1 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 1' },
|
||||
})
|
||||
const cat2 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 2' },
|
||||
})
|
||||
|
||||
// Create multiple posts with initial relationships
|
||||
const post1 = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Post 1',
|
||||
categories: [cat1.id],
|
||||
},
|
||||
})
|
||||
const post2 = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Post 2',
|
||||
categories: [cat1.id],
|
||||
},
|
||||
})
|
||||
|
||||
// Append cat2 to all posts using updateMany
|
||||
const result = (await payload.db.updateMany({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
id: { in: [post1.id, post2.id] },
|
||||
},
|
||||
data: {
|
||||
categories: {
|
||||
$push: cat2.id,
|
||||
},
|
||||
},
|
||||
})) as unknown as Post[]
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
result.forEach((post) => {
|
||||
expect(post.categories).toHaveLength(2)
|
||||
const categoryIds = post.categories?.map((cat) => cat as string)
|
||||
expect(categoryIds).toContain(cat1.id)
|
||||
expect(categoryIds).toContain(cat2.id)
|
||||
})
|
||||
})
|
||||
|
||||
it('should append polymorphic relationships using $push', async () => {
|
||||
// Create a category and simple document for the polymorphic relationship
|
||||
const category = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Test Category' },
|
||||
})
|
||||
const simple = await payload.create({
|
||||
collection: 'simple',
|
||||
data: { text: 'Test Simple' },
|
||||
})
|
||||
|
||||
// Create post with initial polymorphic relationship
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Post',
|
||||
polymorphicRelations: [
|
||||
{
|
||||
relationTo: 'categories',
|
||||
value: category.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
depth: 0, // Don't populate relationships
|
||||
})
|
||||
|
||||
expect(post.polymorphicRelations).toHaveLength(1)
|
||||
expect(post.polymorphicRelations?.[0]).toEqual({
|
||||
relationTo: 'categories',
|
||||
value: category.id,
|
||||
})
|
||||
|
||||
// Append another polymorphic relationship using $push
|
||||
const result = (await payload.db.updateOne({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: {
|
||||
polymorphicRelations: {
|
||||
$push: [
|
||||
{
|
||||
relationTo: 'simple',
|
||||
value: simple.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})) as unknown as Post
|
||||
|
||||
expect(result.polymorphicRelations).toHaveLength(2)
|
||||
expect(result.polymorphicRelations).toContainEqual({
|
||||
relationTo: 'categories',
|
||||
value: category.id,
|
||||
})
|
||||
expect(result.polymorphicRelations).toContainEqual({
|
||||
relationTo: 'simple',
|
||||
value: simple.id,
|
||||
})
|
||||
})
|
||||
|
||||
it('should prevent duplicates in polymorphic relationships with $push', async () => {
|
||||
// Create a category
|
||||
const category = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Test Category' },
|
||||
})
|
||||
|
||||
// Create post with polymorphic relationship
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Post',
|
||||
polymorphicRelations: [
|
||||
{
|
||||
relationTo: 'categories',
|
||||
value: category.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
// Try to append the same relationship - should not create duplicates
|
||||
const result = (await payload.db.updateOne({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: {
|
||||
polymorphicRelations: {
|
||||
$push: [
|
||||
{
|
||||
relationTo: 'categories',
|
||||
value: category.id, // Same relationship
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})) as unknown as Post
|
||||
|
||||
expect(result.polymorphicRelations).toHaveLength(1) // Should still be 1, no duplicates
|
||||
expect(result.polymorphicRelations?.[0]).toEqual({
|
||||
relationTo: 'categories',
|
||||
value: category.id,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle localized polymorphic relationships with $push', async () => {
|
||||
// Create documents for testing
|
||||
const category1 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 1' },
|
||||
})
|
||||
const category2 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 2' },
|
||||
})
|
||||
|
||||
// Create post with localized polymorphic relationships
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Post',
|
||||
localizedPolymorphicRelations: [
|
||||
{
|
||||
relationTo: 'categories',
|
||||
value: category1.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
locale: 'en',
|
||||
})
|
||||
|
||||
// Append relationship using $push with correct localized structure
|
||||
const result = (await payload.db.updateOne({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: {
|
||||
localizedPolymorphicRelations: {
|
||||
en: {
|
||||
$push: [
|
||||
{
|
||||
relationTo: 'categories',
|
||||
value: category2.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})) as unknown as Post
|
||||
|
||||
expect(result.localizedPolymorphicRelations?.en).toHaveLength(2)
|
||||
expect(result.localizedPolymorphicRelations?.en).toContainEqual({
|
||||
relationTo: 'categories',
|
||||
value: category1.id,
|
||||
})
|
||||
expect(result.localizedPolymorphicRelations?.en).toContainEqual({
|
||||
relationTo: 'categories',
|
||||
value: category2.id,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle nested localized polymorphic relationships with $push', async () => {
|
||||
// Create documents for the polymorphic relationship
|
||||
const category1 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 1' },
|
||||
})
|
||||
|
||||
const category2 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 2' },
|
||||
})
|
||||
|
||||
// Create a post with nested localized polymorphic relationship
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Nested $push',
|
||||
testNestedGroup: {
|
||||
nestedLocalizedPolymorphicRelation: [
|
||||
{
|
||||
relationTo: 'categories',
|
||||
value: category1.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
locale: 'en',
|
||||
})
|
||||
|
||||
// Use low-level API to push new items
|
||||
await payload.db.updateOne({
|
||||
collection: 'posts',
|
||||
where: { id: { equals: post.id } },
|
||||
data: {
|
||||
'testNestedGroup.nestedLocalizedPolymorphicRelation': {
|
||||
en: {
|
||||
$push: [
|
||||
{
|
||||
relationTo: 'categories',
|
||||
value: category2.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Verify the operation worked
|
||||
const result = await payload.findByID({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
locale: 'en',
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
expect(result.testNestedGroup?.nestedLocalizedPolymorphicRelation).toHaveLength(2)
|
||||
expect(result.testNestedGroup?.nestedLocalizedPolymorphicRelation).toContainEqual({
|
||||
relationTo: 'categories',
|
||||
value: category1.id,
|
||||
})
|
||||
expect(result.testNestedGroup?.nestedLocalizedPolymorphicRelation).toContainEqual({
|
||||
relationTo: 'categories',
|
||||
value: category2.id,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('relationship $remove', () => {
|
||||
it('should allow removing relationships using $remove with single value', async () => {
|
||||
// Create category documents
|
||||
const cat1 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 1' },
|
||||
})
|
||||
const cat2 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 2' },
|
||||
})
|
||||
|
||||
// Create post with relationships
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Post',
|
||||
categories: [cat1.id, cat2.id],
|
||||
},
|
||||
})
|
||||
|
||||
expect(post.categories).toHaveLength(2)
|
||||
|
||||
// Remove one relationship using $remove
|
||||
const result = (await payload.db.updateOne({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: {
|
||||
categories: {
|
||||
$remove: cat1.id,
|
||||
},
|
||||
},
|
||||
})) as unknown as Post
|
||||
|
||||
expect(result.categories).toHaveLength(1)
|
||||
expect(result.categories?.[0]).toBe(cat2.id)
|
||||
})
|
||||
|
||||
it('should allow removing relationships using $remove with array', async () => {
|
||||
// Create category documents
|
||||
const cat1 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 1' },
|
||||
})
|
||||
const cat2 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 2' },
|
||||
})
|
||||
const cat3 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 3' },
|
||||
})
|
||||
|
||||
// Create post with relationships
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Post',
|
||||
categories: [cat1.id, cat2.id, cat3.id],
|
||||
},
|
||||
})
|
||||
|
||||
expect(post.categories).toHaveLength(3)
|
||||
|
||||
// Remove multiple relationships using $remove
|
||||
const result = (await payload.db.updateOne({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: {
|
||||
categories: {
|
||||
$remove: [cat1.id, cat3.id],
|
||||
},
|
||||
},
|
||||
})) as unknown as Post
|
||||
|
||||
expect(result.categories).toHaveLength(1)
|
||||
expect(result.categories?.[0]).toBe(cat2.id)
|
||||
})
|
||||
|
||||
it('should work with updateMany for bulk remove operations', async () => {
|
||||
// Create category documents
|
||||
const cat1 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 1' },
|
||||
})
|
||||
const cat2 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 2' },
|
||||
})
|
||||
const cat3 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 3' },
|
||||
})
|
||||
|
||||
// Create multiple posts with relationships
|
||||
const post1 = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Post 1',
|
||||
categories: [cat1.id, cat2.id, cat3.id],
|
||||
},
|
||||
})
|
||||
const post2 = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Post 2',
|
||||
categories: [cat1.id, cat2.id, cat3.id],
|
||||
},
|
||||
})
|
||||
|
||||
// Remove cat1 and cat3 from all posts using updateMany
|
||||
const result = (await payload.db.updateMany({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
id: { in: [post1.id, post2.id] },
|
||||
},
|
||||
data: {
|
||||
categories: {
|
||||
$remove: [cat1.id, cat3.id],
|
||||
},
|
||||
},
|
||||
})) as unknown as Post[]
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
result.forEach((post) => {
|
||||
expect(post.categories).toHaveLength(1)
|
||||
const categoryIds = post.categories?.map((cat) => cat as string)
|
||||
expect(categoryIds).toContain(cat2.id)
|
||||
expect(categoryIds).not.toContain(cat1.id)
|
||||
expect(categoryIds).not.toContain(cat3.id)
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove polymorphic relationships using $remove', async () => {
|
||||
// Create documents
|
||||
const category1 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Test Category 1' },
|
||||
})
|
||||
const category2 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Test Category 2' },
|
||||
})
|
||||
|
||||
// Create post with multiple polymorphic relationships
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Post',
|
||||
polymorphicRelations: [
|
||||
{
|
||||
relationTo: 'categories',
|
||||
value: category1.id,
|
||||
},
|
||||
{
|
||||
relationTo: 'categories',
|
||||
value: category2.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
expect(post.polymorphicRelations).toHaveLength(2)
|
||||
|
||||
// Remove one polymorphic relationship using $remove
|
||||
const result = (await payload.db.updateOne({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: {
|
||||
polymorphicRelations: {
|
||||
$remove: [
|
||||
{
|
||||
relationTo: 'categories',
|
||||
value: category1.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})) as unknown as Post
|
||||
|
||||
expect(result.polymorphicRelations).toHaveLength(1)
|
||||
expect(result.polymorphicRelations?.[0]).toEqual({
|
||||
relationTo: 'categories',
|
||||
value: category2.id,
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove multiple polymorphic relationships using $remove', async () => {
|
||||
// Create documents
|
||||
const category1 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Test Category 1' },
|
||||
})
|
||||
const category2 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Test Category 2' },
|
||||
})
|
||||
const simple = await payload.create({
|
||||
collection: 'simple',
|
||||
data: { text: 'Test Simple' },
|
||||
})
|
||||
|
||||
// Create post with multiple polymorphic relationships
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Post',
|
||||
polymorphicRelations: [
|
||||
{ relationTo: 'categories', value: category1.id },
|
||||
{ relationTo: 'categories', value: category2.id },
|
||||
{ relationTo: 'simple', value: simple.id },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
expect(post.polymorphicRelations).toHaveLength(3)
|
||||
|
||||
// Remove multiple polymorphic relationships using $remove
|
||||
const result = (await payload.db.updateOne({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: {
|
||||
polymorphicRelations: {
|
||||
$remove: [
|
||||
{ relationTo: 'categories', value: category1.id },
|
||||
{ relationTo: 'simple', value: simple.id },
|
||||
],
|
||||
},
|
||||
},
|
||||
})) as unknown as Post
|
||||
|
||||
expect(result.polymorphicRelations).toHaveLength(1)
|
||||
expect(result.polymorphicRelations?.[0]).toEqual({
|
||||
relationTo: 'categories',
|
||||
value: category2.id,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle localized polymorphic relationships with $remove', async () => {
|
||||
// Create documents for testing
|
||||
const category1 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 1' },
|
||||
})
|
||||
const category2 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 2' },
|
||||
})
|
||||
const category3 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 3' },
|
||||
})
|
||||
|
||||
// Create post with multiple localized polymorphic relationships
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Post',
|
||||
localizedPolymorphicRelations: [
|
||||
{ relationTo: 'categories', value: category1.id },
|
||||
{ relationTo: 'categories', value: category2.id },
|
||||
{ relationTo: 'categories', value: category3.id },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
locale: 'en',
|
||||
})
|
||||
|
||||
// Remove relationships using $remove with correct localized structure
|
||||
const result = (await payload.db.updateOne({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: {
|
||||
localizedPolymorphicRelations: {
|
||||
en: {
|
||||
$remove: [
|
||||
{ relationTo: 'categories', value: category1.id },
|
||||
{ relationTo: 'categories', value: category3.id },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})) as unknown as Post
|
||||
|
||||
expect(result.localizedPolymorphicRelations?.en).toHaveLength(1)
|
||||
expect(result.localizedPolymorphicRelations?.en).toContainEqual({
|
||||
relationTo: 'categories',
|
||||
value: category2.id,
|
||||
})
|
||||
expect(result.localizedPolymorphicRelations?.en).not.toContainEqual({
|
||||
relationTo: 'categories',
|
||||
value: category1.id,
|
||||
})
|
||||
expect(result.localizedPolymorphicRelations?.en).not.toContainEqual({
|
||||
relationTo: 'categories',
|
||||
value: category3.id,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle nested localized polymorphic relationships with $remove', async () => {
|
||||
// Create documents for the polymorphic relationship
|
||||
const category1 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 1' },
|
||||
})
|
||||
|
||||
const category2 = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: 'Category 2' },
|
||||
})
|
||||
|
||||
const simple1 = await payload.create({
|
||||
collection: 'simple',
|
||||
data: { text: 'Simple 1' },
|
||||
})
|
||||
|
||||
// Create a post with multiple items in nested localized polymorphic relationship
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Nested $remove',
|
||||
testNestedGroup: {
|
||||
nestedLocalizedPolymorphicRelation: [
|
||||
{
|
||||
relationTo: 'categories',
|
||||
value: category1.id,
|
||||
},
|
||||
{
|
||||
relationTo: 'categories',
|
||||
value: category2.id,
|
||||
},
|
||||
{
|
||||
relationTo: 'simple',
|
||||
value: simple1.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
locale: 'en',
|
||||
})
|
||||
|
||||
// Use low-level API to remove items
|
||||
await payload.db.updateOne({
|
||||
collection: 'posts',
|
||||
where: { id: { equals: post.id } },
|
||||
data: {
|
||||
'testNestedGroup.nestedLocalizedPolymorphicRelation': {
|
||||
en: {
|
||||
$remove: [
|
||||
{ relationTo: 'categories', value: category1.id },
|
||||
{ relationTo: 'simple', value: simple1.id },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Verify the operation worked
|
||||
const result = await payload.findByID({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
locale: 'en',
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
expect(result.testNestedGroup?.nestedLocalizedPolymorphicRelation).toHaveLength(1)
|
||||
expect(result.testNestedGroup?.nestedLocalizedPolymorphicRelation?.[0]).toEqual({
|
||||
relationTo: 'categories',
|
||||
value: category2.id,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should support x3 nesting blocks', async () => {
|
||||
const res = await payload.create({
|
||||
collection: 'posts',
|
||||
|
||||
@@ -209,6 +209,24 @@ export interface Post {
|
||||
}[]
|
||||
| null;
|
||||
categoryCustomID?: (number | null) | CategoriesCustomId;
|
||||
polymorphicRelations?:
|
||||
| ({
|
||||
relationTo: 'categories';
|
||||
value: string | Category;
|
||||
} | {
|
||||
relationTo: 'simple';
|
||||
value: string | Simple;
|
||||
})[]
|
||||
| null;
|
||||
localizedPolymorphicRelations?:
|
||||
| ({
|
||||
relationTo: 'categories';
|
||||
value: string | Category;
|
||||
} | {
|
||||
relationTo: 'simple';
|
||||
value: string | Simple;
|
||||
})[]
|
||||
| null;
|
||||
localized?: string | null;
|
||||
text?: string | null;
|
||||
number?: number | null;
|
||||
@@ -234,6 +252,20 @@ export interface Post {
|
||||
};
|
||||
};
|
||||
};
|
||||
testNestedGroup?: {
|
||||
nestedLocalizedPolymorphicRelation?:
|
||||
| ({
|
||||
relationTo: 'categories';
|
||||
value: string | Category;
|
||||
} | {
|
||||
relationTo: 'simple';
|
||||
value: string | Simple;
|
||||
})[]
|
||||
| null;
|
||||
nestedLocalizedText?: string | null;
|
||||
nestedText1?: string | null;
|
||||
nestedText2?: string | null;
|
||||
};
|
||||
hasTransaction?: boolean | null;
|
||||
throwAfterChange?: boolean | null;
|
||||
arrayWithIDs?:
|
||||
@@ -840,6 +872,8 @@ export interface PostsSelect<T extends boolean = true> {
|
||||
categoryPoly?: T;
|
||||
categoryPolyMany?: T;
|
||||
categoryCustomID?: T;
|
||||
polymorphicRelations?: T;
|
||||
localizedPolymorphicRelations?: T;
|
||||
localized?: T;
|
||||
text?: T;
|
||||
number?: T;
|
||||
@@ -1421,6 +1455,6 @@ export interface Auth {
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
// @ts-ignore
|
||||
// @ts-ignore
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user