perf(drizzle): single-roundtrip db updates for simple collections (#13186)

Currently, an optimized DB update (simple data => no
delete-and-create-row) does the following:
1. sql UPDATE
2. sql SELECT

This PR reduces this further to one single DB call for simple
collections:
1. sql UPDATE with RETURNING()

This only works for simple collections that do not have any fields that
need to be fetched from other tables. If a collection has fields like
relationship or blocks, we'll need that separate SELECT call to join in
the other tables.

In 4.0, we can remove all "complex" fields from the jobs collection and
replace them with a JSON field to make use of this optimization

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210803039809814
This commit is contained in:
Alessio Gravili
2025-07-23 01:45:55 -07:00
committed by GitHub
parent 3f8fb6734c
commit 94f5e790f6
14 changed files with 1822 additions and 1427 deletions

View File

@@ -44,7 +44,7 @@ export const buildFindManyArgs = ({
select,
tableName,
versions,
}: BuildFindQueryArgs): Record<string, unknown> => {
}: BuildFindQueryArgs): Result => {
const result: Result = {
extras: {},
with: {},
@@ -134,5 +134,12 @@ export const buildFindManyArgs = ({
result.with._locales = _locales
}
// Delete properties that are empty
for (const key of Object.keys(result)) {
if (!Object.keys(result[key]).length) {
delete result[key]
}
}
return result
}

View File

@@ -1,4 +1,5 @@
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'
@@ -53,434 +54,496 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
const drizzle = db as LibSQLDatabase
await drizzle
.update(adapter.tables[tableName])
.set(row)
// TODO: we can skip fetching idToUpdate here with using the incoming where
.where(eq(adapter.tables[tableName].id, id))
} else {
// Split out the incoming data into the corresponding:
// base row, locales, relationships, blocks, and arrays
const rowToInsert = transformForWrite({
if (ignoreResult) {
await drizzle
.update(adapter.tables[tableName])
.set(row)
.where(eq(adapter.tables[tableName].id, id))
return ignoreResult === 'idOnly' ? ({ id } as T) : null
}
const findManyArgs = buildFindManyArgs({
adapter,
data,
enableAtomicWrites: false,
depth: 0,
fields,
path,
joinQuery: false,
select,
tableName,
})
// First, we insert the main row
try {
if (operation === 'update') {
const target = upsertTarget || adapter.tables[tableName].id
const findManyKeysLength = Object.keys(findManyArgs).length
const hasOnlyColumns = Object.keys(findManyArgs.columns || {}).length > 0
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 {
if (adapter.allowIDOnCreate && data.id) {
rowToInsert.row.id = data.id
if (findManyKeysLength === 0 || hasOnlyColumns) {
// Optimization - No need for joins => can simply use returning(). This is optimal for very simple collections
// without complex fields that live in separate tables like blocks, arrays, relationships, etc.
const selectedFields: SelectedFields = {}
if (hasOnlyColumns) {
for (const [column, enabled] of Object.entries(findManyArgs.columns)) {
if (enabled) {
selectedFields[column] = adapter.tables[tableName][column]
}
}
}
const docs = await drizzle
.update(adapter.tables[tableName])
.set(row)
.where(eq(adapter.tables[tableName].id, id))
.returning(Object.keys(selectedFields).length ? selectedFields : undefined)
return transform<T>({
adapter,
config: adapter.payload.config,
data: docs[0],
fields,
joinQuery: false,
tableName,
})
}
// DB Update that needs the result, potentially with joins => need to update first, then find. returning() does not work with joins.
await drizzle
.update(adapter.tables[tableName])
.set(row)
.where(eq(adapter.tables[tableName].id, id))
findManyArgs.where = eq(adapter.tables[tableName].id, insertedRow.id)
const doc = await db.query[tableName].findFirst(findManyArgs)
return transform<T>({
adapter,
config: adapter.payload.config,
data: doc,
fields,
joinQuery: false,
tableName,
})
}
// Split out the incoming data into the corresponding:
// base row, locales, relationships, blocks, and arrays
const rowToInsert = transformForWrite({
adapter,
data,
enableAtomicWrites: false,
fields,
path,
tableName,
})
// First, we insert the main row
try {
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,
})
} else {
;[insertedRow] = await adapter.insert({
db,
onConflictDoUpdate: { set: rowToInsert.row, target, where },
tableName,
values: rowToInsert.row,
})
}
const localesToInsert: Record<string, unknown>[] = []
const relationsToInsert: Record<string, unknown>[] = []
const textsToInsert: Record<string, unknown>[] = []
const numbersToInsert: Record<string, unknown>[] = []
const blocksToInsert: { [blockType: string]: BlockRowToInsert[] } = {}
const selectsToInsert: { [selectTableName: string]: Record<string, unknown>[] } = {}
// If there are locale rows with data, add the parent and locale to each
if (Object.keys(rowToInsert.locales).length > 0) {
Object.entries(rowToInsert.locales).forEach(([locale, localeRow]) => {
localeRow._parentID = insertedRow.id
localeRow._locale = locale
localesToInsert.push(localeRow)
})
} else {
if (adapter.allowIDOnCreate && data.id) {
rowToInsert.row.id = data.id
}
;[insertedRow] = await adapter.insert({
db,
tableName,
values: rowToInsert.row,
})
}
// If there are relationships, add parent to each
if (rowToInsert.relationships.length > 0) {
rowToInsert.relationships.forEach((relation) => {
relation.parent = insertedRow.id
relationsToInsert.push(relation)
})
}
const localesToInsert: Record<string, unknown>[] = []
const relationsToInsert: Record<string, unknown>[] = []
const textsToInsert: Record<string, unknown>[] = []
const numbersToInsert: Record<string, unknown>[] = []
const blocksToInsert: { [blockType: string]: BlockRowToInsert[] } = {}
const selectsToInsert: { [selectTableName: string]: Record<string, unknown>[] } = {}
// If there are texts, add parent to each
if (rowToInsert.texts.length > 0) {
rowToInsert.texts.forEach((textRow) => {
textRow.parent = insertedRow.id
textsToInsert.push(textRow)
})
}
// If there are locale rows with data, add the parent and locale to each
if (Object.keys(rowToInsert.locales).length > 0) {
Object.entries(rowToInsert.locales).forEach(([locale, localeRow]) => {
localeRow._parentID = insertedRow.id
localeRow._locale = locale
localesToInsert.push(localeRow)
})
}
// If there are numbers, add parent to each
if (rowToInsert.numbers.length > 0) {
rowToInsert.numbers.forEach((numberRow) => {
numberRow.parent = insertedRow.id
numbersToInsert.push(numberRow)
})
}
// If there are relationships, add parent to each
if (rowToInsert.relationships.length > 0) {
rowToInsert.relationships.forEach((relation) => {
relation.parent = insertedRow.id
relationsToInsert.push(relation)
})
}
// If there are selects, add parent to each, and then
// store by table name and rows
if (Object.keys(rowToInsert.selects).length > 0) {
Object.entries(rowToInsert.selects).forEach(([selectTableName, selectRows]) => {
selectsToInsert[selectTableName] = []
// If there are texts, add parent to each
if (rowToInsert.texts.length > 0) {
rowToInsert.texts.forEach((textRow) => {
textRow.parent = insertedRow.id
textsToInsert.push(textRow)
})
}
selectRows.forEach((row) => {
if (typeof row.parent === 'undefined') {
row.parent = insertedRow.id
}
// If there are numbers, add parent to each
if (rowToInsert.numbers.length > 0) {
rowToInsert.numbers.forEach((numberRow) => {
numberRow.parent = insertedRow.id
numbersToInsert.push(numberRow)
})
}
selectsToInsert[selectTableName].push(row)
})
})
}
// If there are selects, add parent to each, and then
// store by table name and rows
if (Object.keys(rowToInsert.selects).length > 0) {
Object.entries(rowToInsert.selects).forEach(([selectTableName, selectRows]) => {
selectsToInsert[selectTableName] = []
// If there are blocks, add parent to each, and then
// store by table name and rows
Object.keys(rowToInsert.blocks).forEach((tableName) => {
rowToInsert.blocks[tableName].forEach((blockRow) => {
blockRow.row._parentID = insertedRow.id
if (!blocksToInsert[tableName]) {
blocksToInsert[tableName] = []
selectRows.forEach((row) => {
if (typeof row.parent === 'undefined') {
row.parent = insertedRow.id
}
if (blockRow.row.uuid) {
delete blockRow.row.uuid
}
blocksToInsert[tableName].push(blockRow)
selectsToInsert[selectTableName].push(row)
})
})
}
// //////////////////////////////////
// INSERT LOCALES
// //////////////////////////////////
if (localesToInsert.length > 0) {
const localeTableName = `${tableName}${adapter.localesSuffix}`
const localeTable = adapter.tables[`${tableName}${adapter.localesSuffix}`]
if (operation === 'update') {
await adapter.deleteWhere({
db,
tableName: localeTableName,
where: eq(localeTable._parentID, insertedRow.id),
})
// If there are blocks, add parent to each, and then
// store by table name and rows
Object.keys(rowToInsert.blocks).forEach((tableName) => {
rowToInsert.blocks[tableName].forEach((blockRow) => {
blockRow.row._parentID = insertedRow.id
if (!blocksToInsert[tableName]) {
blocksToInsert[tableName] = []
}
if (blockRow.row.uuid) {
delete blockRow.row.uuid
}
blocksToInsert[tableName].push(blockRow)
})
})
await adapter.insert({
// //////////////////////////////////
// INSERT LOCALES
// //////////////////////////////////
if (localesToInsert.length > 0) {
const localeTableName = `${tableName}${adapter.localesSuffix}`
const localeTable = adapter.tables[`${tableName}${adapter.localesSuffix}`]
if (operation === 'update') {
await adapter.deleteWhere({
db,
tableName: localeTableName,
values: localesToInsert,
where: eq(localeTable._parentID, insertedRow.id),
})
}
// //////////////////////////////////
// INSERT RELATIONSHIPS
// //////////////////////////////////
await adapter.insert({
db,
tableName: localeTableName,
values: localesToInsert,
})
}
const relationshipsTableName = `${tableName}${adapter.relationshipsSuffix}`
// //////////////////////////////////
// INSERT RELATIONSHIPS
// //////////////////////////////////
if (operation === 'update') {
await deleteExistingRowsByPath({
adapter,
db,
localeColumnName: 'locale',
parentColumnName: 'parent',
parentID: insertedRow.id,
pathColumnName: 'path',
rows: [...relationsToInsert, ...rowToInsert.relationshipsToDelete],
tableName: relationshipsTableName,
})
}
const relationshipsTableName = `${tableName}${adapter.relationshipsSuffix}`
if (relationsToInsert.length > 0) {
await adapter.insert({
db,
tableName: relationshipsTableName,
values: relationsToInsert,
})
}
if (operation === 'update') {
await deleteExistingRowsByPath({
adapter,
db,
localeColumnName: 'locale',
parentColumnName: 'parent',
parentID: insertedRow.id,
pathColumnName: 'path',
rows: [...relationsToInsert, ...rowToInsert.relationshipsToDelete],
tableName: relationshipsTableName,
})
}
// //////////////////////////////////
// INSERT hasMany TEXTS
// //////////////////////////////////
if (relationsToInsert.length > 0) {
await adapter.insert({
db,
tableName: relationshipsTableName,
values: relationsToInsert,
})
}
const textsTableName = `${tableName}_texts`
// //////////////////////////////////
// INSERT hasMany TEXTS
// //////////////////////////////////
if (operation === 'update') {
await deleteExistingRowsByPath({
adapter,
db,
localeColumnName: 'locale',
parentColumnName: 'parent',
parentID: insertedRow.id,
pathColumnName: 'path',
rows: [...textsToInsert, ...rowToInsert.textsToDelete],
tableName: textsTableName,
})
}
const textsTableName = `${tableName}_texts`
if (textsToInsert.length > 0) {
await adapter.insert({
db,
tableName: textsTableName,
values: textsToInsert,
})
}
if (operation === 'update') {
await deleteExistingRowsByPath({
adapter,
db,
localeColumnName: 'locale',
parentColumnName: 'parent',
parentID: insertedRow.id,
pathColumnName: 'path',
rows: [...textsToInsert, ...rowToInsert.textsToDelete],
tableName: textsTableName,
})
}
// //////////////////////////////////
// INSERT hasMany NUMBERS
// //////////////////////////////////
if (textsToInsert.length > 0) {
await adapter.insert({
db,
tableName: textsTableName,
values: textsToInsert,
})
}
const numbersTableName = `${tableName}_numbers`
// //////////////////////////////////
// INSERT hasMany NUMBERS
// //////////////////////////////////
if (operation === 'update') {
await deleteExistingRowsByPath({
adapter,
db,
localeColumnName: 'locale',
parentColumnName: 'parent',
parentID: insertedRow.id,
pathColumnName: 'path',
rows: [...numbersToInsert, ...rowToInsert.numbersToDelete],
tableName: numbersTableName,
})
}
const numbersTableName = `${tableName}_numbers`
if (numbersToInsert.length > 0) {
await adapter.insert({
db,
tableName: numbersTableName,
values: numbersToInsert,
})
}
if (operation === 'update') {
await deleteExistingRowsByPath({
adapter,
db,
localeColumnName: 'locale',
parentColumnName: 'parent',
parentID: insertedRow.id,
pathColumnName: 'path',
rows: [...numbersToInsert, ...rowToInsert.numbersToDelete],
tableName: numbersTableName,
})
}
// //////////////////////////////////
// INSERT BLOCKS
// //////////////////////////////////
if (numbersToInsert.length > 0) {
await adapter.insert({
db,
tableName: numbersTableName,
values: numbersToInsert,
})
}
const insertedBlockRows: Record<string, Record<string, unknown>[]> = {}
// //////////////////////////////////
// INSERT BLOCKS
// //////////////////////////////////
if (operation === 'update') {
for (const tableName of rowToInsert.blocksToDelete) {
const blockTable = adapter.tables[tableName]
await adapter.deleteWhere({
db,
tableName,
where: eq(blockTable._parentID, insertedRow.id),
})
}
}
const insertedBlockRows: Record<string, Record<string, unknown>[]> = {}
// When versions are enabled, adapter is used to track mapping between blocks/arrays ObjectID to their numeric generated representation, then we use it for nested to arrays/blocks select hasMany in versions.
const arraysBlocksUUIDMap: Record<string, number | string> = {}
for (const [tableName, blockRows] of Object.entries(blocksToInsert)) {
insertedBlockRows[tableName] = await adapter.insert({
if (operation === 'update') {
for (const tableName of rowToInsert.blocksToDelete) {
const blockTable = adapter.tables[tableName]
await adapter.deleteWhere({
db,
tableName,
values: blockRows.map(({ row }) => row),
})
insertedBlockRows[tableName].forEach((row, i) => {
blockRows[i].row = row
if (
typeof row._uuid === 'string' &&
(typeof row.id === 'string' || typeof row.id === 'number')
) {
arraysBlocksUUIDMap[row._uuid] = row.id
}
})
const blockLocaleIndexMap: number[] = []
const blockLocaleRowsToInsert = blockRows.reduce((acc, blockRow, i) => {
if (Object.entries(blockRow.locales).length > 0) {
Object.entries(blockRow.locales).forEach(([blockLocale, blockLocaleData]) => {
if (Object.keys(blockLocaleData).length > 0) {
blockLocaleData._parentID = blockRow.row.id
blockLocaleData._locale = blockLocale
acc.push(blockLocaleData)
blockLocaleIndexMap.push(i)
}
})
}
return acc
}, [])
if (blockLocaleRowsToInsert.length > 0) {
await adapter.insert({
db,
tableName: `${tableName}${adapter.localesSuffix}`,
values: blockLocaleRowsToInsert,
})
}
await insertArrays({
adapter,
arrays: blockRows.map(({ arrays }) => arrays),
db,
parentRows: insertedBlockRows[tableName],
uuidMap: arraysBlocksUUIDMap,
where: eq(blockTable._parentID, insertedRow.id),
})
}
}
// //////////////////////////////////
// INSERT ARRAYS RECURSIVELY
// //////////////////////////////////
// When versions are enabled, adapter is used to track mapping between blocks/arrays ObjectID to their numeric generated representation, then we use it for nested to arrays/blocks select hasMany in versions.
const arraysBlocksUUIDMap: Record<string, number | string> = {}
if (operation === 'update') {
for (const arrayTableName of Object.keys(rowToInsert.arrays)) {
await deleteExistingArrayRows({
adapter,
db,
parentID: insertedRow.id,
tableName: arrayTableName,
for (const [tableName, blockRows] of Object.entries(blocksToInsert)) {
insertedBlockRows[tableName] = await adapter.insert({
db,
tableName,
values: blockRows.map(({ row }) => row),
})
insertedBlockRows[tableName].forEach((row, i) => {
blockRows[i].row = row
if (
typeof row._uuid === 'string' &&
(typeof row.id === 'string' || typeof row.id === 'number')
) {
arraysBlocksUUIDMap[row._uuid] = row.id
}
})
const blockLocaleIndexMap: number[] = []
const blockLocaleRowsToInsert = blockRows.reduce((acc, blockRow, i) => {
if (Object.entries(blockRow.locales).length > 0) {
Object.entries(blockRow.locales).forEach(([blockLocale, blockLocaleData]) => {
if (Object.keys(blockLocaleData).length > 0) {
blockLocaleData._parentID = blockRow.row.id
blockLocaleData._locale = blockLocale
acc.push(blockLocaleData)
blockLocaleIndexMap.push(i)
}
})
}
return acc
}, [])
if (blockLocaleRowsToInsert.length > 0) {
await adapter.insert({
db,
tableName: `${tableName}${adapter.localesSuffix}`,
values: blockLocaleRowsToInsert,
})
}
await insertArrays({
adapter,
arrays: [rowToInsert.arrays],
arrays: blockRows.map(({ arrays }) => arrays),
db,
parentRows: [insertedRow],
parentRows: insertedBlockRows[tableName],
uuidMap: arraysBlocksUUIDMap,
})
}
// //////////////////////////////////
// INSERT hasMany SELECTS
// //////////////////////////////////
// //////////////////////////////////
// INSERT ARRAYS RECURSIVELY
// //////////////////////////////////
for (const [selectTableName, tableRows] of Object.entries(selectsToInsert)) {
const selectTable = adapter.tables[selectTableName]
if (operation === 'update') {
await adapter.deleteWhere({
db,
tableName: selectTableName,
where: eq(selectTable.parent, insertedRow.id),
})
}
if (operation === 'update') {
for (const arrayTableName of Object.keys(rowToInsert.arrays)) {
await deleteExistingArrayRows({
adapter,
db,
parentID: insertedRow.id,
tableName: arrayTableName,
})
}
}
if (Object.keys(arraysBlocksUUIDMap).length > 0) {
tableRows.forEach((row: any) => {
if (row.parent in arraysBlocksUUIDMap) {
row.parent = arraysBlocksUUIDMap[row.parent]
}
})
}
await insertArrays({
adapter,
arrays: [rowToInsert.arrays],
db,
parentRows: [insertedRow],
uuidMap: arraysBlocksUUIDMap,
})
if (tableRows.length) {
await adapter.insert({
db,
tableName: selectTableName,
values: tableRows,
})
}
// //////////////////////////////////
// INSERT hasMany SELECTS
// //////////////////////////////////
for (const [selectTableName, tableRows] of Object.entries(selectsToInsert)) {
const selectTable = adapter.tables[selectTableName]
if (operation === 'update') {
await adapter.deleteWhere({
db,
tableName: selectTableName,
where: eq(selectTable.parent, insertedRow.id),
})
}
// //////////////////////////////////
// Error Handling
// //////////////////////////////////
} catch (caughtError) {
// Unique constraint violation error
// '23505' is the code for PostgreSQL, and 'SQLITE_CONSTRAINT_UNIQUE' is for SQLite
let error = caughtError
if (typeof caughtError === 'object' && 'cause' in caughtError) {
error = caughtError.cause
if (Object.keys(arraysBlocksUUIDMap).length > 0) {
tableRows.forEach((row: any) => {
if (row.parent in arraysBlocksUUIDMap) {
row.parent = arraysBlocksUUIDMap[row.parent]
}
})
}
if (error.code === '23505' || error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
let fieldName: null | string = null
// We need to try and find the right constraint for the field but if we can't we fallback to a generic message
if (error.code === '23505') {
// For PostgreSQL, we can try to extract the field name from the error constraint
if (adapter.fieldConstraints?.[tableName]?.[error.constraint]) {
fieldName = adapter.fieldConstraints[tableName]?.[error.constraint]
} else {
const replacement = `${tableName}_`
if (tableRows.length) {
await adapter.insert({
db,
tableName: selectTableName,
values: tableRows,
})
}
}
if (error.constraint.includes(replacement)) {
const replacedConstraint = error.constraint.replace(replacement, '')
// //////////////////////////////////
// Error Handling
// //////////////////////////////////
} catch (caughtError) {
// Unique constraint violation error
// '23505' is the code for PostgreSQL, and 'SQLITE_CONSTRAINT_UNIQUE' is for SQLite
if (replacedConstraint && adapter.fieldConstraints[tableName]?.[replacedConstraint]) {
fieldName = adapter.fieldConstraints[tableName][replacedConstraint]
}
let error = caughtError
if (typeof caughtError === 'object' && 'cause' in caughtError) {
error = caughtError.cause
}
if (error.code === '23505' || error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
let fieldName: null | string = null
// We need to try and find the right constraint for the field but if we can't we fallback to a generic message
if (error.code === '23505') {
// For PostgreSQL, we can try to extract the field name from the error constraint
if (adapter.fieldConstraints?.[tableName]?.[error.constraint]) {
fieldName = adapter.fieldConstraints[tableName]?.[error.constraint]
} else {
const replacement = `${tableName}_`
if (error.constraint.includes(replacement)) {
const replacedConstraint = error.constraint.replace(replacement, '')
if (replacedConstraint && adapter.fieldConstraints[tableName]?.[replacedConstraint]) {
fieldName = adapter.fieldConstraints[tableName][replacedConstraint]
}
}
}
if (!fieldName) {
// Last case scenario we extract the key and value from the detail on the error
const detail = error.detail
const regex = /Key \(([^)]+)\)=\(([^)]+)\)/
const match: string[] = detail.match(regex)
if (match && match[1]) {
const key = match[1]
fieldName = key
}
}
} else if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
/**
* For SQLite, we can try to extract the field name from the error message
* The message typically looks like:
* "UNIQUE constraint failed: table_name.field_name"
*/
const regex = /UNIQUE constraint failed: ([^.]+)\.([^.]+)/
const match: string[] = error.message.match(regex)
if (match && match[2]) {
if (adapter.fieldConstraints[tableName]) {
fieldName = adapter.fieldConstraints[tableName][`${match[2]}_idx`]
}
if (!fieldName) {
// Last case scenario we extract the key and value from the detail on the error
const detail = error.detail
const regex = /Key \(([^)]+)\)=\(([^)]+)\)/
const match: string[] = detail.match(regex)
if (match && match[1]) {
const key = match[1]
fieldName = key
}
}
} else if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
/**
* For SQLite, we can try to extract the field name from the error message
* The message typically looks like:
* "UNIQUE constraint failed: table_name.field_name"
*/
const regex = /UNIQUE constraint failed: ([^.]+)\.([^.]+)/
const match: string[] = error.message.match(regex)
if (match && match[2]) {
if (adapter.fieldConstraints[tableName]) {
fieldName = adapter.fieldConstraints[tableName][`${match[2]}_idx`]
}
if (!fieldName) {
fieldName = match[2]
}
fieldName = match[2]
}
}
throw new ValidationError(
{
id,
errors: [
{
message: req?.t ? req.t('error:valueMustBeUnique') : 'Value must be unique',
path: fieldName,
},
],
req,
},
req?.t,
)
} else {
throw error
}
throw new ValidationError(
{
id,
errors: [
{
message: req?.t ? req.t('error:valueMustBeUnique') : 'Value must be unique',
path: fieldName,
},
],
req,
},
req?.t,
)
} else {
throw error
}
}

View File

@@ -0,0 +1,19 @@
/* eslint-disable no-restricted-exports */
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { getConfig } from './getConfig.js'
const config = getConfig()
import { postgresAdapter } from '@payloadcms/db-postgres'
export const databaseAdapter = postgresAdapter({
pool: {
connectionString: process.env.POSTGRES_URL || 'postgres://127.0.0.1:5432/payloadtests',
},
logger: true,
})
export default buildConfigWithDefaults({
...config,
db: databaseAdapter,
})

View File

@@ -1,933 +1,4 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import type { TextField } from 'payload'
import { randomUUID } from 'crypto'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { seed } from './seed.js'
import {
customIDsSlug,
customSchemaSlug,
defaultValuesSlug,
errorOnUnnamedFieldsSlug,
fakeCustomIDsSlug,
fieldsPersistanceSlug,
pgMigrationSlug,
placesSlug,
postsSlug,
relationASlug,
relationBSlug,
relationshipsMigrationSlug,
} from './shared.js'
import { getConfig } from './getConfig.js'
const defaultValueField: TextField = {
name: 'defaultValue',
type: 'text',
defaultValue: 'default value from database',
}
export default buildConfigWithDefaults({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [
{
slug: 'categories',
versions: { drafts: true },
fields: [
{
type: 'text',
name: 'title',
},
],
},
{
slug: 'categories-custom-id',
versions: { drafts: true },
fields: [
{
type: 'number',
name: 'id',
},
],
},
{
slug: postsSlug,
fields: [
{
name: 'title',
type: 'text',
required: true,
// access: { read: () => false },
},
{
type: 'relationship',
relationTo: 'categories',
name: 'category',
},
{
type: 'relationship',
relationTo: 'categories-custom-id',
name: 'categoryCustomID',
},
{
name: 'localized',
type: 'text',
localized: true,
},
{
name: 'text',
type: 'text',
},
{
name: 'number',
type: 'number',
},
{
type: 'blocks',
name: 'blocks',
blocks: [
{
slug: 'block-third',
fields: [
{
type: 'blocks',
name: 'nested',
blocks: [
{
slug: 'block-fourth',
fields: [
{
type: 'blocks',
name: 'nested',
blocks: [],
},
],
},
],
},
],
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'D1',
fields: [
{
name: 'D2',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
fields: [
{
type: 'tabs',
tabs: [
{
fields: [
{
name: 'D3',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
fields: [
{
name: 'D4',
type: 'text',
},
],
label: 'Collapsible2',
},
],
},
],
},
],
label: 'Tab1',
},
],
},
],
label: 'Collapsible2',
},
],
},
],
},
],
label: 'Tab1',
},
],
},
{
name: 'hasTransaction',
type: 'checkbox',
hooks: {
beforeChange: [({ req }) => !!req.transactionID],
},
admin: {
readOnly: true,
},
},
{
name: 'throwAfterChange',
type: 'checkbox',
defaultValue: false,
hooks: {
afterChange: [
({ value }) => {
if (value) {
throw new Error('throw after change')
}
},
],
},
},
{
name: 'arrayWithIDs',
type: 'array',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
name: 'blocksWithIDs',
type: 'blocks',
blocks: [
{
slug: 'block-first',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
},
{
type: 'group',
name: 'group',
fields: [{ name: 'text', type: 'text' }],
},
{
type: 'tabs',
tabs: [
{
name: 'tab',
fields: [{ name: 'text', type: 'text' }],
},
],
},
],
hooks: {
beforeOperation: [
({ args, operation, req }) => {
if (operation === 'update') {
const defaultIDType = req.payload.db.defaultIDType
if (defaultIDType === 'number' && typeof args.id === 'string') {
throw new Error('ID was not sanitized to a number properly')
}
}
return args
},
],
},
},
{
slug: errorOnUnnamedFieldsSlug,
fields: [
{
type: 'tabs',
tabs: [
{
label: 'UnnamedTab',
fields: [
{
name: 'groupWithinUnnamedTab',
type: 'group',
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
},
],
},
],
},
],
},
{
slug: defaultValuesSlug,
fields: [
{
name: 'title',
type: 'text',
},
defaultValueField,
{
name: 'array',
type: 'array',
// default array with one object to test subfield defaultValue properties for Mongoose
defaultValue: [{}],
fields: [defaultValueField],
},
{
name: 'group',
type: 'group',
// we need to have to use as default in order to have subfield defaultValue properties directly for Mongoose
defaultValue: {},
fields: [defaultValueField],
},
{
name: 'select',
type: 'select',
defaultValue: 'default',
options: [
{ value: 'option0', label: 'Option 0' },
{ value: 'option1', label: 'Option 1' },
{ value: 'default', label: 'Default' },
],
},
{
name: 'point',
type: 'point',
defaultValue: [10, 20],
},
{
name: 'escape',
type: 'text',
defaultValue: "Thanks, we're excited for you to join us.",
},
],
},
{
slug: relationASlug,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'richText',
type: 'richText',
},
],
labels: {
plural: 'Relation As',
singular: 'Relation A',
},
},
{
slug: relationBSlug,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'relationship',
type: 'relationship',
relationTo: 'relation-a',
},
{
name: 'richText',
type: 'richText',
},
],
labels: {
plural: 'Relation Bs',
singular: 'Relation B',
},
},
{
slug: pgMigrationSlug,
fields: [
{
name: 'relation1',
type: 'relationship',
relationTo: 'relation-a',
},
{
name: 'myArray',
type: 'array',
fields: [
{
name: 'relation2',
type: 'relationship',
relationTo: 'relation-b',
},
{
name: 'mySubArray',
type: 'array',
fields: [
{
name: 'relation3',
type: 'relationship',
localized: true,
relationTo: 'relation-b',
},
],
},
],
},
{
name: 'myGroup',
type: 'group',
fields: [
{
name: 'relation4',
type: 'relationship',
localized: true,
relationTo: 'relation-b',
},
],
},
{
name: 'myBlocks',
type: 'blocks',
blocks: [
{
slug: 'myBlock',
fields: [
{
name: 'relation5',
type: 'relationship',
relationTo: 'relation-a',
},
{
name: 'relation6',
type: 'relationship',
localized: true,
relationTo: 'relation-b',
},
],
},
],
},
],
versions: true,
},
{
slug: customSchemaSlug,
dbName: 'customs',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
{
name: 'relationship',
type: 'relationship',
hasMany: true,
relationTo: 'relation-a',
},
{
name: 'select',
type: 'select',
dbName: ({ tableName }) => `${tableName}_customSelect`,
enumName: 'selectEnum',
hasMany: true,
options: ['a', 'b', 'c'],
},
{
name: 'radio',
type: 'select',
enumName: 'radioEnum',
options: ['a', 'b', 'c'],
},
{
name: 'array',
type: 'array',
dbName: 'customArrays',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
],
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'block-second',
dbName: 'customBlocks',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
],
},
],
},
],
versions: {
drafts: true,
},
},
{
slug: placesSlug,
fields: [
{
name: 'country',
type: 'text',
},
{
name: 'city',
type: 'text',
},
],
},
{
slug: 'virtual-relations',
admin: { useAsTitle: 'postTitle' },
access: { read: () => true },
fields: [
{
name: 'postTitle',
type: 'text',
virtual: 'post.title',
},
{
name: 'postTitleHidden',
type: 'text',
virtual: 'post.title',
hidden: true,
},
{
name: 'postCategoryTitle',
type: 'text',
virtual: 'post.category.title',
},
{
name: 'postCategoryID',
type: 'json',
virtual: 'post.category.id',
},
{
name: 'postCategoryCustomID',
type: 'number',
virtual: 'post.categoryCustomID.id',
},
{
name: 'postID',
type: 'json',
virtual: 'post.id',
},
{
name: 'postLocalized',
type: 'text',
virtual: 'post.localized',
},
{
name: 'post',
type: 'relationship',
relationTo: 'posts',
},
{
name: 'customID',
type: 'relationship',
relationTo: 'custom-ids',
},
{
name: 'customIDValue',
type: 'text',
virtual: 'customID.id',
},
],
versions: { drafts: true },
},
{
slug: fieldsPersistanceSlug,
fields: [
{
name: 'text',
type: 'text',
virtual: true,
},
{
name: 'textHooked',
type: 'text',
virtual: true,
hooks: { afterRead: [() => 'hooked'] },
},
{
name: 'array',
type: 'array',
virtual: true,
fields: [],
},
{
type: 'row',
fields: [
{
type: 'text',
name: 'textWithinRow',
virtual: true,
},
],
},
{
type: 'collapsible',
fields: [
{
type: 'text',
name: 'textWithinCollapsible',
virtual: true,
},
],
label: 'Colllapsible',
},
{
type: 'tabs',
tabs: [
{
label: 'tab',
fields: [
{
type: 'text',
name: 'textWithinTabs',
virtual: true,
},
],
},
],
},
],
},
{
slug: customIDsSlug,
fields: [
{
name: 'id',
type: 'text',
admin: {
readOnly: true,
},
hooks: {
beforeChange: [
({ value, operation }) => {
if (operation === 'create') {
return randomUUID()
}
return value
},
],
},
},
{
name: 'title',
type: 'text',
},
],
versions: { drafts: true },
},
{
slug: fakeCustomIDsSlug,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'id',
type: 'text',
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'myTab',
fields: [
{
name: 'id',
type: 'text',
},
],
},
],
},
],
},
{
slug: relationshipsMigrationSlug,
fields: [
{
type: 'relationship',
relationTo: 'default-values',
name: 'relationship',
},
{
type: 'relationship',
relationTo: ['default-values'],
name: 'relationship_2',
},
],
versions: true,
},
{
slug: 'compound-indexes',
fields: [
{
name: 'one',
type: 'text',
},
{
name: 'two',
type: 'text',
},
{
name: 'three',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'four',
type: 'text',
},
],
},
],
indexes: [
{
fields: ['one', 'two'],
unique: true,
},
{
fields: ['three', 'group.four'],
unique: true,
},
],
},
{
slug: 'aliases',
fields: [
{
name: 'thisIsALongFieldNameThatCanCauseAPostgresErrorEvenThoughWeSetAShorterDBName',
dbName: 'shortname',
type: 'array',
fields: [
{
name: 'nestedArray',
type: 'array',
dbName: 'short_nested_1',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
},
],
},
{
slug: 'blocks-docs',
fields: [
{
type: 'blocks',
localized: true,
blocks: [
{
slug: 'cta',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
name: 'testBlocksLocalized',
},
{
type: 'blocks',
blocks: [
{
slug: 'cta',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
name: 'testBlocks',
},
],
},
{
slug: 'unique-fields',
fields: [
{
name: 'slugField',
type: 'text',
unique: true,
},
],
},
],
globals: [
{
slug: 'header',
fields: [
{
name: 'itemsLvl1',
type: 'array',
dbName: 'header_items_lvl1',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl2',
type: 'array',
dbName: 'header_items_lvl2',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl3',
type: 'array',
dbName: 'header_items_lvl3',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl4',
type: 'array',
dbName: 'header_items_lvl4',
fields: [
{
name: 'label',
type: 'text',
},
],
},
],
},
],
},
],
},
],
},
{
slug: 'global',
dbName: 'customGlobal',
fields: [
{
name: 'text',
type: 'text',
},
],
versions: true,
},
{
slug: 'global-2',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
slug: 'global-3',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
slug: 'virtual-relation-global',
fields: [
{
type: 'text',
name: 'postTitle',
virtual: 'post.title',
},
{
type: 'relationship',
name: 'post',
relationTo: 'posts',
},
],
},
],
localization: {
defaultLocale: 'en',
locales: ['en', 'es'],
},
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await seed(payload)
}
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
export const postDoc = {
title: 'test post',
}
export default buildConfigWithDefaults(getConfig())

942
test/database/getConfig.ts Normal file
View File

@@ -0,0 +1,942 @@
import type { Config, TextField } from 'payload'
import { randomUUID } from 'crypto'
import { fileURLToPath } from 'node:url'
import path from 'path'
import { seed } from './seed.js'
import {
customIDsSlug,
customSchemaSlug,
defaultValuesSlug,
errorOnUnnamedFieldsSlug,
fakeCustomIDsSlug,
fieldsPersistanceSlug,
pgMigrationSlug,
placesSlug,
postsSlug,
relationASlug,
relationBSlug,
relationshipsMigrationSlug,
} from './shared.js'
const defaultValueField: TextField = {
name: 'defaultValue',
type: 'text',
defaultValue: 'default value from database',
}
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const getConfig: () => Partial<Config> = () => ({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [
{
slug: 'categories',
versions: { drafts: true },
fields: [
{
type: 'text',
name: 'title',
},
],
},
{
slug: 'simple',
fields: [
{
type: 'text',
name: 'text',
},
{
type: 'number',
name: 'number',
},
],
},
{
slug: 'categories-custom-id',
versions: { drafts: true },
fields: [
{
type: 'number',
name: 'id',
},
],
},
{
slug: postsSlug,
fields: [
{
name: 'title',
type: 'text',
required: true,
// access: { read: () => false },
},
{
type: 'relationship',
relationTo: 'categories',
name: 'category',
},
{
type: 'relationship',
relationTo: 'categories-custom-id',
name: 'categoryCustomID',
},
{
name: 'localized',
type: 'text',
localized: true,
},
{
name: 'text',
type: 'text',
},
{
name: 'number',
type: 'number',
},
{
type: 'blocks',
name: 'blocks',
blocks: [
{
slug: 'block-third',
fields: [
{
type: 'blocks',
name: 'nested',
blocks: [
{
slug: 'block-fourth',
fields: [
{
type: 'blocks',
name: 'nested',
blocks: [],
},
],
},
],
},
],
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'D1',
fields: [
{
name: 'D2',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
fields: [
{
type: 'tabs',
tabs: [
{
fields: [
{
name: 'D3',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
fields: [
{
name: 'D4',
type: 'text',
},
],
label: 'Collapsible2',
},
],
},
],
},
],
label: 'Tab1',
},
],
},
],
label: 'Collapsible2',
},
],
},
],
},
],
label: 'Tab1',
},
],
},
{
name: 'hasTransaction',
type: 'checkbox',
hooks: {
beforeChange: [({ req }) => !!req.transactionID],
},
admin: {
readOnly: true,
},
},
{
name: 'throwAfterChange',
type: 'checkbox',
defaultValue: false,
hooks: {
afterChange: [
({ value }) => {
if (value) {
throw new Error('throw after change')
}
},
],
},
},
{
name: 'arrayWithIDs',
type: 'array',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
name: 'blocksWithIDs',
type: 'blocks',
blocks: [
{
slug: 'block-first',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
},
{
type: 'group',
name: 'group',
fields: [{ name: 'text', type: 'text' }],
},
{
type: 'tabs',
tabs: [
{
name: 'tab',
fields: [{ name: 'text', type: 'text' }],
},
],
},
],
hooks: {
beforeOperation: [
({ args, operation, req }) => {
if (operation === 'update') {
const defaultIDType = req.payload.db.defaultIDType
if (defaultIDType === 'number' && typeof args.id === 'string') {
throw new Error('ID was not sanitized to a number properly')
}
}
return args
},
],
},
},
{
slug: errorOnUnnamedFieldsSlug,
fields: [
{
type: 'tabs',
tabs: [
{
label: 'UnnamedTab',
fields: [
{
name: 'groupWithinUnnamedTab',
type: 'group',
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
},
],
},
],
},
],
},
{
slug: defaultValuesSlug,
fields: [
{
name: 'title',
type: 'text',
},
defaultValueField,
{
name: 'array',
type: 'array',
// default array with one object to test subfield defaultValue properties for Mongoose
defaultValue: [{}],
fields: [defaultValueField],
},
{
name: 'group',
type: 'group',
// we need to have to use as default in order to have subfield defaultValue properties directly for Mongoose
defaultValue: {},
fields: [defaultValueField],
},
{
name: 'select',
type: 'select',
defaultValue: 'default',
options: [
{ value: 'option0', label: 'Option 0' },
{ value: 'option1', label: 'Option 1' },
{ value: 'default', label: 'Default' },
],
},
{
name: 'point',
type: 'point',
defaultValue: [10, 20],
},
{
name: 'escape',
type: 'text',
defaultValue: "Thanks, we're excited for you to join us.",
},
],
},
{
slug: relationASlug,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'richText',
type: 'richText',
},
],
labels: {
plural: 'Relation As',
singular: 'Relation A',
},
},
{
slug: relationBSlug,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'relationship',
type: 'relationship',
relationTo: 'relation-a',
},
{
name: 'richText',
type: 'richText',
},
],
labels: {
plural: 'Relation Bs',
singular: 'Relation B',
},
},
{
slug: pgMigrationSlug,
fields: [
{
name: 'relation1',
type: 'relationship',
relationTo: 'relation-a',
},
{
name: 'myArray',
type: 'array',
fields: [
{
name: 'relation2',
type: 'relationship',
relationTo: 'relation-b',
},
{
name: 'mySubArray',
type: 'array',
fields: [
{
name: 'relation3',
type: 'relationship',
localized: true,
relationTo: 'relation-b',
},
],
},
],
},
{
name: 'myGroup',
type: 'group',
fields: [
{
name: 'relation4',
type: 'relationship',
localized: true,
relationTo: 'relation-b',
},
],
},
{
name: 'myBlocks',
type: 'blocks',
blocks: [
{
slug: 'myBlock',
fields: [
{
name: 'relation5',
type: 'relationship',
relationTo: 'relation-a',
},
{
name: 'relation6',
type: 'relationship',
localized: true,
relationTo: 'relation-b',
},
],
},
],
},
],
versions: true,
},
{
slug: customSchemaSlug,
dbName: 'customs',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
{
name: 'relationship',
type: 'relationship',
hasMany: true,
relationTo: 'relation-a',
},
{
name: 'select',
type: 'select',
dbName: ({ tableName }) => `${tableName}_customSelect`,
enumName: 'selectEnum',
hasMany: true,
options: ['a', 'b', 'c'],
},
{
name: 'radio',
type: 'select',
enumName: 'radioEnum',
options: ['a', 'b', 'c'],
},
{
name: 'array',
type: 'array',
dbName: 'customArrays',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
],
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'block-second',
dbName: 'customBlocks',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
],
},
],
},
],
versions: {
drafts: true,
},
},
{
slug: placesSlug,
fields: [
{
name: 'country',
type: 'text',
},
{
name: 'city',
type: 'text',
},
],
},
{
slug: 'virtual-relations',
admin: { useAsTitle: 'postTitle' },
access: { read: () => true },
fields: [
{
name: 'postTitle',
type: 'text',
virtual: 'post.title',
},
{
name: 'postTitleHidden',
type: 'text',
virtual: 'post.title',
hidden: true,
},
{
name: 'postCategoryTitle',
type: 'text',
virtual: 'post.category.title',
},
{
name: 'postCategoryID',
type: 'json',
virtual: 'post.category.id',
},
{
name: 'postCategoryCustomID',
type: 'number',
virtual: 'post.categoryCustomID.id',
},
{
name: 'postID',
type: 'json',
virtual: 'post.id',
},
{
name: 'postLocalized',
type: 'text',
virtual: 'post.localized',
},
{
name: 'post',
type: 'relationship',
relationTo: 'posts',
},
{
name: 'customID',
type: 'relationship',
relationTo: 'custom-ids',
},
{
name: 'customIDValue',
type: 'text',
virtual: 'customID.id',
},
],
versions: { drafts: true },
},
{
slug: fieldsPersistanceSlug,
fields: [
{
name: 'text',
type: 'text',
virtual: true,
},
{
name: 'textHooked',
type: 'text',
virtual: true,
hooks: { afterRead: [() => 'hooked'] },
},
{
name: 'array',
type: 'array',
virtual: true,
fields: [],
},
{
type: 'row',
fields: [
{
type: 'text',
name: 'textWithinRow',
virtual: true,
},
],
},
{
type: 'collapsible',
fields: [
{
type: 'text',
name: 'textWithinCollapsible',
virtual: true,
},
],
label: 'Colllapsible',
},
{
type: 'tabs',
tabs: [
{
label: 'tab',
fields: [
{
type: 'text',
name: 'textWithinTabs',
virtual: true,
},
],
},
],
},
],
},
{
slug: customIDsSlug,
fields: [
{
name: 'id',
type: 'text',
admin: {
readOnly: true,
},
hooks: {
beforeChange: [
({ value, operation }) => {
if (operation === 'create') {
return randomUUID()
}
return value
},
],
},
},
{
name: 'title',
type: 'text',
},
],
versions: { drafts: true },
},
{
slug: fakeCustomIDsSlug,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'id',
type: 'text',
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'myTab',
fields: [
{
name: 'id',
type: 'text',
},
],
},
],
},
],
},
{
slug: relationshipsMigrationSlug,
fields: [
{
type: 'relationship',
relationTo: 'default-values',
name: 'relationship',
},
{
type: 'relationship',
relationTo: ['default-values'],
name: 'relationship_2',
},
],
versions: true,
},
{
slug: 'compound-indexes',
fields: [
{
name: 'one',
type: 'text',
},
{
name: 'two',
type: 'text',
},
{
name: 'three',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'four',
type: 'text',
},
],
},
],
indexes: [
{
fields: ['one', 'two'],
unique: true,
},
{
fields: ['three', 'group.four'],
unique: true,
},
],
},
{
slug: 'aliases',
fields: [
{
name: 'thisIsALongFieldNameThatCanCauseAPostgresErrorEvenThoughWeSetAShorterDBName',
dbName: 'shortname',
type: 'array',
fields: [
{
name: 'nestedArray',
type: 'array',
dbName: 'short_nested_1',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
},
],
},
{
slug: 'blocks-docs',
fields: [
{
type: 'blocks',
localized: true,
blocks: [
{
slug: 'cta',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
name: 'testBlocksLocalized',
},
{
type: 'blocks',
blocks: [
{
slug: 'cta',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
name: 'testBlocks',
},
],
},
{
slug: 'unique-fields',
fields: [
{
name: 'slugField',
type: 'text',
unique: true,
},
],
},
],
globals: [
{
slug: 'header',
fields: [
{
name: 'itemsLvl1',
type: 'array',
dbName: 'header_items_lvl1',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl2',
type: 'array',
dbName: 'header_items_lvl2',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl3',
type: 'array',
dbName: 'header_items_lvl3',
fields: [
{
name: 'label',
type: 'text',
},
{
name: 'itemsLvl4',
type: 'array',
dbName: 'header_items_lvl4',
fields: [
{
name: 'label',
type: 'text',
},
],
},
],
},
],
},
],
},
],
},
{
slug: 'global',
dbName: 'customGlobal',
fields: [
{
name: 'text',
type: 'text',
},
],
versions: true,
},
{
slug: 'global-2',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
slug: 'global-3',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
slug: 'virtual-relation-global',
fields: [
{
type: 'text',
name: 'postTitle',
virtual: 'post.title',
},
{
type: 'relationship',
name: 'post',
relationTo: 'posts',
},
],
},
],
localization: {
defaultLocale: 'en',
locales: ['en', 'es'],
},
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await seed(payload)
}
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

View File

@@ -68,6 +68,7 @@ export interface Config {
blocks: {};
collections: {
categories: Category;
simple: Simple;
'categories-custom-id': CategoriesCustomId;
posts: Post;
'error-on-unnamed-fields': ErrorOnUnnamedField;
@@ -94,6 +95,7 @@ export interface Config {
collectionsJoins: {};
collectionsSelect: {
categories: CategoriesSelect<false> | CategoriesSelect<true>;
simple: SimpleSelect<false> | SimpleSelect<true>;
'categories-custom-id': CategoriesCustomIdSelect<false> | CategoriesCustomIdSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
'error-on-unnamed-fields': ErrorOnUnnamedFieldsSelect<false> | ErrorOnUnnamedFieldsSelect<true>;
@@ -172,6 +174,17 @@ export interface Category {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "simple".
*/
export interface Simple {
id: string;
text?: string | null;
number?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories-custom-id".
@@ -608,6 +621,10 @@ export interface PayloadLockedDocument {
relationTo: 'categories';
value: string | Category;
} | null)
| ({
relationTo: 'simple';
value: string | Simple;
} | null)
| ({
relationTo: 'categories-custom-id';
value: number | CategoriesCustomId;
@@ -736,6 +753,16 @@ export interface CategoriesSelect<T extends boolean = true> {
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "simple_select".
*/
export interface SimpleSelect<T extends boolean = true> {
text?: T;
number?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories-custom-id_select".

View File

@@ -0,0 +1,91 @@
import type { Payload } from 'payload'
/* eslint-disable jest/require-top-level-describe */
import assert from 'assert'
import path from 'path'
import { fileURLToPath } from 'url'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const describePostgres = process.env.PAYLOAD_DATABASE?.startsWith('postgres')
? describe
: describe.skip
let payload: Payload
describePostgres('database - postgres logs', () => {
beforeAll(async () => {
const initialized = await initPayloadInt(
dirname,
undefined,
undefined,
'config.postgreslogs.ts',
)
assert(initialized.payload)
assert(initialized.restClient)
;({ payload } = initialized)
})
afterAll(async () => {
await payload.destroy()
})
it('ensure simple update uses optimized upsertRow with returning()', async () => {
const doc = await payload.create({
collection: 'simple',
data: {
text: 'Some title',
number: 5,
},
})
// Count every console log
const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {})
const result: any = await payload.db.updateOne({
collection: 'simple',
id: doc.id,
data: {
text: 'Updated Title',
number: 5,
},
})
expect(result.text).toEqual('Updated Title')
expect(result.number).toEqual(5) // Ensure the update did not reset the number field
expect(consoleCount).toHaveBeenCalledTimes(1) // Should be 1 single sql call if the optimization is used. If not, this would be 2 calls
consoleCount.mockRestore()
})
it('ensure simple update of complex collection uses optimized upsertRow without returning()', async () => {
const doc = await payload.create({
collection: 'posts',
data: {
title: 'Some title',
number: 5,
},
})
// Count every console log
const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {})
const result: any = await payload.db.updateOne({
collection: 'posts',
id: doc.id,
data: {
title: 'Updated Title',
number: 5,
},
})
expect(result.title).toEqual('Updated Title')
expect(result.number).toEqual(5) // Ensure the update did not reset the number field
expect(consoleCount).toHaveBeenCalledTimes(2) // Should be 2 sql call if the optimization is used (update + find). If not, this would be 5 calls
consoleCount.mockRestore()
})
})

View File

@@ -12,11 +12,11 @@ import { fileURLToPath } from 'url'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const describeToUse = process.env.PAYLOAD_DATABASE?.startsWith('postgres')
const describePostgres = process.env.PAYLOAD_DATABASE?.startsWith('postgres')
? describe
: describe.skip
describeToUse('postgres vector custom column', () => {
describePostgres('postgres vector custom column', () => {
const vectorColumnQueryTest = async (vectorType: string) => {
const {
databaseAdapter,

View File

@@ -1,15 +1,6 @@
import type { Payload } from 'payload'
import path from 'path'
import { getFileByPath } from 'payload'
import { fileURLToPath } from 'url'
import { devUser } from '../credentials.js'
import { seedDB } from '../helpers/seed.js'
import { collectionSlugs } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const _seed = async (_payload: Payload) => {
await _payload.create({

View File

@@ -20,18 +20,3 @@ export const customIDsSlug = 'custom-ids'
export const fakeCustomIDsSlug = 'fake-custom-ids'
export const relationshipsMigrationSlug = 'relationships-migration'
export const collectionSlugs = [
postsSlug,
errorOnUnnamedFieldsSlug,
defaultValuesSlug,
relationASlug,
relationBSlug,
pgMigrationSlug,
customSchemaSlug,
placesSlug,
fieldsPersistanceSlug,
customIDsSlug,
fakeCustomIDsSlug,
relationshipsMigrationSlug,
]

View File

@@ -0,0 +1,19 @@
/* eslint-disable no-restricted-exports */
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { getConfig } from './getConfig.js'
const config = getConfig()
import { postgresAdapter } from '@payloadcms/db-postgres'
export const databaseAdapter = postgresAdapter({
pool: {
connectionString: process.env.POSTGRES_URL || 'postgres://127.0.0.1:5432/payloadtests',
},
logger: true,
})
export default buildConfigWithDefaults({
...config,
db: databaseAdapter,
})

View File

@@ -1,122 +1,4 @@
import type { GlobalConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { fileURLToPath } from 'node:url'
import path from 'path'
import type { Post } from './payload-types.js'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { CustomID } from './collections/CustomID/index.js'
import { DeepPostsCollection } from './collections/DeepPosts/index.js'
import { ForceSelect } from './collections/ForceSelect/index.js'
import { LocalizedPostsCollection } from './collections/LocalizedPosts/index.js'
import { Pages } from './collections/Pages/index.js'
import { Points } from './collections/Points/index.js'
import { PostsCollection } from './collections/Posts/index.js'
import { UsersCollection } from './collections/Users/index.js'
import { VersionedPostsCollection } from './collections/VersionedPosts/index.js'
import { getConfig } from './getConfig.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
// ...extend config here
collections: [
PostsCollection,
LocalizedPostsCollection,
VersionedPostsCollection,
DeepPostsCollection,
Pages,
Points,
ForceSelect,
{
slug: 'upload',
fields: [],
upload: {
staticDir: path.resolve(dirname, 'media'),
},
},
{
slug: 'rels',
fields: [],
},
CustomID,
UsersCollection,
],
globals: [
{
slug: 'global-post',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'number',
type: 'number',
},
],
},
{
slug: 'force-select-global',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'forceSelected',
type: 'text',
},
{
name: 'array',
type: 'array',
fields: [
{
name: 'forceSelected',
type: 'text',
},
],
},
],
forceSelect: { array: { forceSelected: true }, forceSelected: true },
} satisfies GlobalConfig<'force-select-global'>,
],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
localization: {
locales: ['en', 'de'],
defaultLocale: 'en',
},
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
cors: ['http://localhost:3000', 'http://localhost:3001'],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
// // Create image
// const imageFilePath = path.resolve(dirname, '../uploads/image.png')
// const imageFile = await getFileByPath(imageFilePath)
// await payload.create({
// collection: 'media',
// data: {},
// file: imageFile,
// })
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
export default buildConfigWithDefaults(getConfig())

119
test/select/getConfig.ts Normal file
View File

@@ -0,0 +1,119 @@
import type { Config, GlobalConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { fileURLToPath } from 'node:url'
import path from 'path'
import { devUser } from '../credentials.js'
import { CustomID } from './collections/CustomID/index.js'
import { DeepPostsCollection } from './collections/DeepPosts/index.js'
import { ForceSelect } from './collections/ForceSelect/index.js'
import { LocalizedPostsCollection } from './collections/LocalizedPosts/index.js'
import { Pages } from './collections/Pages/index.js'
import { Points } from './collections/Points/index.js'
import { PostsCollection } from './collections/Posts/index.js'
import { UsersCollection } from './collections/Users/index.js'
import { VersionedPostsCollection } from './collections/VersionedPosts/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const getConfig: () => Partial<Config> = () => ({
// ...extend config here
collections: [
PostsCollection,
LocalizedPostsCollection,
VersionedPostsCollection,
DeepPostsCollection,
Pages,
Points,
ForceSelect,
{
slug: 'upload',
fields: [],
upload: {
staticDir: path.resolve(dirname, 'media'),
},
},
{
slug: 'rels',
fields: [],
},
CustomID,
UsersCollection,
],
globals: [
{
slug: 'global-post',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'number',
type: 'number',
},
],
},
{
slug: 'force-select-global',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'forceSelected',
type: 'text',
},
{
name: 'array',
type: 'array',
fields: [
{
name: 'forceSelected',
type: 'text',
},
],
},
],
forceSelect: { array: { forceSelected: true }, forceSelected: true },
} satisfies GlobalConfig<'force-select-global'>,
],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
localization: {
locales: ['en', 'de'],
defaultLocale: 'en',
},
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
cors: ['http://localhost:3000', 'http://localhost:3001'],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
// // Create image
// const imageFilePath = path.resolve(dirname, '../uploads/image.png')
// const imageFile = await getFileByPath(imageFilePath)
// await payload.create({
// collection: 'media',
// data: {},
// file: imageFile,
// })
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

View File

@@ -0,0 +1,179 @@
/* eslint-disable jest/require-top-level-describe */
import type { Payload } from 'payload'
import path from 'path'
import { assert } from 'ts-essentials'
import { fileURLToPath } from 'url'
import type { Point, Post } from './payload-types.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
let payload: Payload
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const describePostgres = process.env.PAYLOAD_DATABASE === 'postgres' ? describe : describe.skip
describePostgres('Select - with postgres logs', () => {
// --__--__--__--__--__--__--__--__--__
// Boilerplate test setup/teardown
// --__--__--__--__--__--__--__--__--__
beforeAll(async () => {
const initialized = await initPayloadInt(
dirname,
undefined,
undefined,
'config.postgreslogs.ts',
)
assert(initialized.payload)
assert(initialized.restClient)
;({ payload } = initialized)
})
afterAll(async () => {
await payload.destroy()
})
describe('Local API - Base', () => {
let post: Post
let postId: number | string
let point: Point
let pointId: number | string
beforeEach(async () => {
post = await createPost()
postId = post.id
point = await createPoint()
pointId = point.id
})
// Clean up to safely mutate in each test
afterEach(async () => {
await payload.delete({ id: postId, collection: 'posts' })
await payload.delete({ id: pointId, collection: 'points' })
})
describe('Local API - operations', () => {
it('ensure optimized db update is still used when using select', async () => {
const post = await createPost()
// Count every console log
const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {})
const res = removeEmptyAndUndefined(
(await payload.db.updateOne({
collection: 'posts',
id: post.id,
data: {
text: 'new text',
},
select: { text: true, number: true },
})) as any,
)
expect(consoleCount).toHaveBeenCalledTimes(1) // Should be 1 single sql call if the optimization is used. If not, this would be 2 calls
consoleCount.mockRestore()
expect(res.number).toEqual(1)
expect(res.text).toEqual('new text')
expect(res.id).toEqual(post.id)
expect(Object.keys(res)).toHaveLength(3)
})
})
})
})
function removeEmptyAndUndefined(obj: any): any {
if (Array.isArray(obj)) {
const cleanedArray = obj
.map(removeEmptyAndUndefined)
.filter(
(item) =>
item !== undefined && !(typeof item === 'object' && Object.keys(item).length === 0),
)
return cleanedArray.length > 0 ? cleanedArray : undefined
}
if (obj !== null && typeof obj === 'object') {
const cleanedEntries = Object.entries(obj)
.map(([key, value]) => [key, removeEmptyAndUndefined(value)])
.filter(
([, value]) =>
value !== undefined &&
!(
typeof value === 'object' &&
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0)
),
)
return cleanedEntries.length > 0 ? Object.fromEntries(cleanedEntries) : undefined
}
return obj
}
async function createPost() {
const upload = await payload.create({
collection: 'upload',
data: {},
filePath: path.resolve(dirname, 'image.jpg'),
})
const relation = await payload.create({
depth: 0,
collection: 'rels',
data: {},
})
return payload.create({
collection: 'posts',
depth: 0,
data: {
number: 1,
text: 'text',
select: 'a',
selectMany: ['a'],
group: {
number: 1,
text: 'text',
},
hasMany: [relation],
hasManyUpload: [upload],
hasOne: relation,
hasManyPoly: [{ relationTo: 'rels', value: relation }],
hasOnePoly: { relationTo: 'rels', value: relation },
blocks: [
{
blockType: 'cta',
ctaText: 'cta-text',
text: 'text',
},
{
blockType: 'intro',
introText: 'intro-text',
text: 'text',
},
],
array: [
{
text: 'text',
number: 1,
},
],
tab: {
text: 'text',
number: 1,
},
unnamedTabNumber: 2,
unnamedTabText: 'text2',
},
})
}
function createPoint() {
return payload.create({ collection: 'points', data: { text: 'some', point: [10, 20] } })
}