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,12 +54,75 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
const drizzle = db as LibSQLDatabase
if (ignoreResult) {
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 {
return ignoreResult === 'idOnly' ? ({ id } as T) : null
}
const findManyArgs = buildFindManyArgs({
adapter,
depth: 0,
fields,
joinQuery: false,
select,
tableName,
})
const findManyKeysLength = Object.keys(findManyArgs).length
const hasOnlyColumns = Object.keys(findManyArgs.columns || {}).length > 0
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({
@@ -482,7 +546,6 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
throw error
}
}
}
if (ignoreResult === 'idOnly') {
return { id: insertedRow.id } as T

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