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:
Tobias Odendahl
2025-09-30 19:58:09 +02:00
committed by GitHub
parent 7601835438
commit 7eacd396b1
16 changed files with 1722 additions and 71 deletions

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -121,6 +121,7 @@ export const transformArray = ({
parentTableName: arrayTableName,
path: `${path || ''}${field.name}.${i}.`,
relationships,
relationshipsToAppend: [],
relationshipsToDelete,
row: newRow.row,
selects,

View File

@@ -133,6 +133,7 @@ export const transformBlocks = ({
parentTableName: blockTableName,
path: `${path || ''}${field.name}.${i}.`,
relationships,
relationshipsToAppend: [],
relationshipsToDelete,
row: newRow.row,
selects,

View File

@@ -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,

View File

@@ -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,
})
}
})
}
}

View File

@@ -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: {

View File

@@ -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]
}

View File

@@ -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
}

View File

@@ -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,
})
}
})
}
}

View File

@@ -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;

View File

@@ -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: [

View File

@@ -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',

View File

@@ -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 {}
}
}