diff --git a/packages/drizzle/src/find/buildFindManyArgs.ts b/packages/drizzle/src/find/buildFindManyArgs.ts index 4febf335d1b..c45bff699f7 100644 --- a/packages/drizzle/src/find/buildFindManyArgs.ts +++ b/packages/drizzle/src/find/buildFindManyArgs.ts @@ -44,7 +44,7 @@ export const buildFindManyArgs = ({ select, tableName, versions, -}: BuildFindQueryArgs): Record => { +}: 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 } diff --git a/packages/drizzle/src/upsertRow/index.ts b/packages/drizzle/src/upsertRow/index.ts index 72f89435ec2..52d686a55e0 100644 --- a/packages/drizzle/src/upsertRow/index.ts +++ b/packages/drizzle/src/upsertRow/index.ts @@ -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 | TypeWithID>( const drizzle = db as LibSQLDatabase + 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, + 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({ + 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) - // 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({ + + findManyArgs.where = eq(adapter.tables[tableName].id, insertedRow.id) + + const doc = await db.query[tableName].findFirst(findManyArgs) + + return transform({ adapter, - data, - enableAtomicWrites: false, + config: adapter.payload.config, + data: doc, fields, - path, + 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, - }) - } - } else { - if (adapter.allowIDOnCreate && data.id) { - rowToInsert.row.id = data.id - } + // 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, }) - } - - const localesToInsert: Record[] = [] - const relationsToInsert: Record[] = [] - const textsToInsert: Record[] = [] - const numbersToInsert: Record[] = [] - const blocksToInsert: { [blockType: string]: BlockRowToInsert[] } = {} - const selectsToInsert: { [selectTableName: string]: Record[] } = {} - - // 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 { + ;[insertedRow] = await adapter.insert({ + db, + onConflictDoUpdate: { set: rowToInsert.row, target, where }, + 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) - }) + } else { + if (adapter.allowIDOnCreate && data.id) { + rowToInsert.row.id = data.id } + ;[insertedRow] = await adapter.insert({ + db, + tableName, + values: rowToInsert.row, + }) + } - // If there are texts, add parent to each - if (rowToInsert.texts.length > 0) { - rowToInsert.texts.forEach((textRow) => { - textRow.parent = insertedRow.id - textsToInsert.push(textRow) - }) - } + const localesToInsert: Record[] = [] + const relationsToInsert: Record[] = [] + const textsToInsert: Record[] = [] + const numbersToInsert: Record[] = [] + const blocksToInsert: { [blockType: string]: BlockRowToInsert[] } = {} + const selectsToInsert: { [selectTableName: string]: Record[] } = {} + + // 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] = [] - } - if (blockRow.row.uuid) { - delete blockRow.row.uuid + selectRows.forEach((row) => { + if (typeof row.parent === 'undefined') { + row.parent = insertedRow.id } - blocksToInsert[tableName].push(blockRow) + + selectsToInsert[selectTableName].push(row) }) }) + } - // ////////////////////////////////// - // INSERT LOCALES - // ////////////////////////////////// + // 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) + }) + }) - if (localesToInsert.length > 0) { - const localeTableName = `${tableName}${adapter.localesSuffix}` - const localeTable = adapter.tables[`${tableName}${adapter.localesSuffix}`] + // ////////////////////////////////// + // INSERT LOCALES + // ////////////////////////////////// - if (operation === 'update') { - await adapter.deleteWhere({ - db, - tableName: localeTableName, - where: eq(localeTable._parentID, insertedRow.id), - }) - } + if (localesToInsert.length > 0) { + const localeTableName = `${tableName}${adapter.localesSuffix}` + const localeTable = adapter.tables[`${tableName}${adapter.localesSuffix}`] - await adapter.insert({ + 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, - }) - } - - if (numbersToInsert.length > 0) { - await adapter.insert({ - db, - tableName: numbersTableName, - values: numbersToInsert, - }) - } + const numbersTableName = `${tableName}_numbers` - // ////////////////////////////////// - // INSERT BLOCKS - // ////////////////////////////////// + if (operation === 'update') { + await deleteExistingRowsByPath({ + adapter, + db, + localeColumnName: 'locale', + parentColumnName: 'parent', + parentID: insertedRow.id, + pathColumnName: 'path', + rows: [...numbersToInsert, ...rowToInsert.numbersToDelete], + tableName: numbersTableName, + }) + } - const insertedBlockRows: Record[]> = {} + if (numbersToInsert.length > 0) { + await adapter.insert({ + db, + tableName: numbersTableName, + values: numbersToInsert, + }) + } - 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), - }) - } - } + // ////////////////////////////////// + // INSERT BLOCKS + // ////////////////////////////////// - // 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 = {} + const insertedBlockRows: Record[]> = {} - 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), + where: eq(blockTable._parentID, insertedRow.id), }) + } + } - 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 - } - }) + // 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 = {} - 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) - } - }) - } + 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 + } + }) - return acc - }, []) + const blockLocaleIndexMap: number[] = [] - if (blockLocaleRowsToInsert.length > 0) { - await adapter.insert({ - db, - tableName: `${tableName}${adapter.localesSuffix}`, - values: blockLocaleRowsToInsert, + 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) + } }) } - await insertArrays({ - adapter, - arrays: blockRows.map(({ arrays }) => arrays), + return acc + }, []) + + if (blockLocaleRowsToInsert.length > 0) { + await adapter.insert({ db, - parentRows: insertedBlockRows[tableName], - uuidMap: arraysBlocksUUIDMap, + tableName: `${tableName}${adapter.localesSuffix}`, + values: blockLocaleRowsToInsert, }) } - // ////////////////////////////////// - // INSERT ARRAYS RECURSIVELY - // ////////////////////////////////// - - if (operation === 'update') { - for (const arrayTableName of Object.keys(rowToInsert.arrays)) { - await deleteExistingArrayRows({ - adapter, - db, - parentID: insertedRow.id, - tableName: arrayTableName, - }) - } - } - await insertArrays({ adapter, - arrays: [rowToInsert.arrays], + arrays: blockRows.map(({ arrays }) => arrays), db, - parentRows: [insertedRow], + parentRows: insertedBlockRows[tableName], uuidMap: arraysBlocksUUIDMap, }) + } - // ////////////////////////////////// - // 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), - }) - } - - if (Object.keys(arraysBlocksUUIDMap).length > 0) { - tableRows.forEach((row: any) => { - if (row.parent in arraysBlocksUUIDMap) { - row.parent = arraysBlocksUUIDMap[row.parent] - } - }) - } + // ////////////////////////////////// + // INSERT ARRAYS RECURSIVELY + // ////////////////////////////////// - if (tableRows.length) { - await adapter.insert({ - db, - tableName: selectTableName, - values: tableRows, - }) - } + if (operation === 'update') { + for (const arrayTableName of Object.keys(rowToInsert.arrays)) { + await deleteExistingArrayRows({ + adapter, + db, + parentID: insertedRow.id, + tableName: arrayTableName, + }) } + } + + await insertArrays({ + adapter, + arrays: [rowToInsert.arrays], + db, + parentRows: [insertedRow], + uuidMap: arraysBlocksUUIDMap, + }) - // ////////////////////////////////// - // Error Handling - // ////////////////////////////////// - } catch (caughtError) { - // Unique constraint violation error - // '23505' is the code for PostgreSQL, and 'SQLITE_CONSTRAINT_UNIQUE' is for SQLite + // ////////////////////////////////// + // INSERT hasMany SELECTS + // ////////////////////////////////// - let error = caughtError - if (typeof caughtError === 'object' && 'cause' in caughtError) { - error = caughtError.cause + 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 (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 (Object.keys(arraysBlocksUUIDMap).length > 0) { + tableRows.forEach((row: any) => { + if (row.parent in arraysBlocksUUIDMap) { + row.parent = arraysBlocksUUIDMap[row.parent] } + }) + } - 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 (tableRows.length) { + await adapter.insert({ + db, + tableName: selectTableName, + values: tableRows, + }) + } + } + + // ////////////////////////////////// + // 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 (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 (match && match[1]) { - const key = match[1] + if (error.constraint.includes(replacement)) { + const replacedConstraint = error.constraint.replace(replacement, '') - fieldName = key + if (replacedConstraint && adapter.fieldConstraints[tableName]?.[replacedConstraint]) { + fieldName = adapter.fieldConstraints[tableName][replacedConstraint] } } - } 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] - } + 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`] + } - throw new ValidationError( - { - id, - errors: [ - { - message: req?.t ? req.t('error:valueMustBeUnique') : 'Value must be unique', - path: fieldName, - }, - ], - req, - }, - req?.t, - ) - } else { - throw error + if (!fieldName) { + 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 } } diff --git a/test/database/config.postgreslogs.ts b/test/database/config.postgreslogs.ts new file mode 100644 index 00000000000..d47ee88d839 --- /dev/null +++ b/test/database/config.postgreslogs.ts @@ -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, +}) diff --git a/test/database/config.ts b/test/database/config.ts index 1027491eae4..6c16a4bd2b6 100644 --- a/test/database/config.ts +++ b/test/database/config.ts @@ -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' - -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'), - }, -}) +import { getConfig } from './getConfig.js' -export const postDoc = { - title: 'test post', -} +export default buildConfigWithDefaults(getConfig()) diff --git a/test/database/getConfig.ts b/test/database/getConfig.ts new file mode 100644 index 00000000000..b5ea622a4f7 --- /dev/null +++ b/test/database/getConfig.ts @@ -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 = () => ({ + 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'), + }, +}) diff --git a/test/database/payload-types.ts b/test/database/payload-types.ts index d1f52cb4a11..18b196b38ee 100644 --- a/test/database/payload-types.ts +++ b/test/database/payload-types.ts @@ -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 | CategoriesSelect; + simple: SimpleSelect | SimpleSelect; 'categories-custom-id': CategoriesCustomIdSelect | CategoriesCustomIdSelect; posts: PostsSelect | PostsSelect; 'error-on-unnamed-fields': ErrorOnUnnamedFieldsSelect | ErrorOnUnnamedFieldsSelect; @@ -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 { createdAt?: T; _status?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "simple_select". + */ +export interface SimpleSelect { + text?: T; + number?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "categories-custom-id_select". diff --git a/test/database/postgres-logs.int.spec.ts b/test/database/postgres-logs.int.spec.ts new file mode 100644 index 00000000000..a179b64c564 --- /dev/null +++ b/test/database/postgres-logs.int.spec.ts @@ -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() + }) +}) diff --git a/test/database/postgres-vector.int.spec.ts b/test/database/postgres-vector.int.spec.ts index 81d374a108c..58a10743fd3 100644 --- a/test/database/postgres-vector.int.spec.ts +++ b/test/database/postgres-vector.int.spec.ts @@ -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, diff --git a/test/database/seed.ts b/test/database/seed.ts index 921273e4bb1..48fd373021a 100644 --- a/test/database/seed.ts +++ b/test/database/seed.ts @@ -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({ diff --git a/test/database/shared.ts b/test/database/shared.ts index 7600f66547e..59322295815 100644 --- a/test/database/shared.ts +++ b/test/database/shared.ts @@ -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, -] diff --git a/test/select/config.postgreslogs.ts b/test/select/config.postgreslogs.ts new file mode 100644 index 00000000000..d47ee88d839 --- /dev/null +++ b/test/select/config.postgreslogs.ts @@ -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, +}) diff --git a/test/select/config.ts b/test/select/config.ts index 280946aa513..6c16a4bd2b6 100644 --- a/test/select/config.ts +++ b/test/select/config.ts @@ -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' - -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) +import { getConfig } from './getConfig.js' - // await payload.create({ - // collection: 'media', - // data: {}, - // file: imageFile, - // }) - }, - typescript: { - outputFile: path.resolve(dirname, 'payload-types.ts'), - }, -}) +export default buildConfigWithDefaults(getConfig()) diff --git a/test/select/getConfig.ts b/test/select/getConfig.ts new file mode 100644 index 00000000000..7712c3e82d0 --- /dev/null +++ b/test/select/getConfig.ts @@ -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 = () => ({ + // ...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'), + }, +}) diff --git a/test/select/postgreslogs.int.spec.ts b/test/select/postgreslogs.int.spec.ts new file mode 100644 index 00000000000..58517e196e0 --- /dev/null +++ b/test/select/postgreslogs.int.spec.ts @@ -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] } }) +}