feat: queriable / sortable / useAsTitle virtual fields linked with a relationship field (#11805)
This PR adds an ability to specify a virtual field in this way
```js
{
slug: 'posts',
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
},
{
slug: 'virtual-relations',
fields: [
{
name: 'postTitle',
type: 'text',
virtual: 'post.title',
},
{
name: 'post',
type: 'relationship',
relationTo: 'posts',
},
],
},
```
Then, every time you query `virtual-relations`, `postTitle` will be
automatically populated (even if using `depth: 0`) on the db level. This
field also, unlike `virtual: true` is available for querying / sorting /
`useAsTitle`.
Also, the field can be deeply nested to 2 or more relationships, for
example:
```
{
name: 'postCategoryTitle',
type: 'text',
virtual: 'post.category.title',
},
```
Where the current collection has `post` - a relationship to `posts`, the
collection `posts` has `category` that's a relationship to `categories`
and finally `categories` has `title`.
This commit is contained in:
@@ -240,8 +240,8 @@ export default buildConfig({
|
||||
// highlight-start
|
||||
cors: {
|
||||
origins: ['http://localhost:3000'],
|
||||
headers: ['x-custom-header']
|
||||
}
|
||||
headers: ['x-custom-header'],
|
||||
},
|
||||
// highlight-end
|
||||
})
|
||||
```
|
||||
|
||||
@@ -352,7 +352,7 @@ const config = buildConfig({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
{
|
||||
slug: 'collection2',
|
||||
fields: [
|
||||
{
|
||||
@@ -365,7 +365,7 @@ const config = buildConfig({
|
||||
blocks: ['TextBlock'],
|
||||
}),
|
||||
],
|
||||
})
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -63,6 +63,7 @@ To install a Database Adapter, you can run **one** of the following commands:
|
||||
```
|
||||
|
||||
- To install the [Postgres Adapter](../database/postgres), run:
|
||||
|
||||
```bash
|
||||
pnpm i @payloadcms/db-postgres
|
||||
```
|
||||
@@ -80,7 +81,7 @@ To install a Database Adapter, you can run **one** of the following commands:
|
||||
|
||||
#### 2. Copy Payload files into your Next.js app folder
|
||||
|
||||
Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template](https://github.com/payloadcms/payload/tree/main/templates/blank/src/app/(payload)) on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this:
|
||||
Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template](<https://github.com/payloadcms/payload/tree/main/templates/blank/src/app/(payload)>) on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this:
|
||||
|
||||
```plaintext
|
||||
app/
|
||||
|
||||
@@ -33,9 +33,9 @@ export const validateUseAsTitle = (config: CollectionConfig) => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (useAsTitleField && fieldIsVirtual(useAsTitleField)) {
|
||||
if (useAsTitleField && 'virtual' in useAsTitleField && useAsTitleField.virtual === true) {
|
||||
throw new InvalidConfiguration(
|
||||
`The field "${config.admin.useAsTitle}" specified in "admin.useAsTitle" in the collection "${config.slug}" is virtual. A virtual field cannot be used as the title.`,
|
||||
`The field "${config.admin.useAsTitle}" specified in "admin.useAsTitle" in the collection "${config.slug}" is virtual. A virtual field can be used as the title only when linked to a relationship field.`,
|
||||
)
|
||||
}
|
||||
if (!useAsTitleField) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { buildVersionCollectionFields } from '../../versions/buildCollectionFiel
|
||||
import { appendVersionToQueryKey } from '../../versions/drafts/appendVersionToQueryKey.js'
|
||||
import { getQueryDraftsSelect } from '../../versions/drafts/getQueryDraftsSelect.js'
|
||||
import { getQueryDraftsSort } from '../../versions/drafts/getQueryDraftsSort.js'
|
||||
import { sanitizeSortQuery } from './utilities/sanitizeSortQuery.js'
|
||||
import { buildAfterOperation } from './utils.js'
|
||||
|
||||
export type Arguments = {
|
||||
@@ -96,7 +97,7 @@ export const findOperation = async <
|
||||
req,
|
||||
select: incomingSelect,
|
||||
showHiddenFields,
|
||||
sort,
|
||||
sort: incomingSort,
|
||||
where,
|
||||
} = args
|
||||
|
||||
@@ -143,6 +144,11 @@ export const findOperation = async <
|
||||
|
||||
let fullWhere = combineQueries(where, accessResult)
|
||||
|
||||
const sort = sanitizeSortQuery({
|
||||
fields: collection.config.flattenedFields,
|
||||
sort: incomingSort,
|
||||
})
|
||||
|
||||
const sanitizedJoins = await sanitizeJoinQuery({
|
||||
collectionConfig,
|
||||
joins,
|
||||
@@ -170,7 +176,10 @@ export const findOperation = async <
|
||||
pagination: usePagination,
|
||||
req,
|
||||
select: getQueryDraftsSelect({ select }),
|
||||
sort: getQueryDraftsSort({ collectionConfig, sort }),
|
||||
sort: getQueryDraftsSort({
|
||||
collectionConfig,
|
||||
sort,
|
||||
}),
|
||||
where: fullWhere,
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -27,6 +27,7 @@ import { sanitizeSelect } from '../../utilities/sanitizeSelect.js'
|
||||
import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js'
|
||||
import { appendVersionToQueryKey } from '../../versions/drafts/appendVersionToQueryKey.js'
|
||||
import { getQueryDraftsSort } from '../../versions/drafts/getQueryDraftsSort.js'
|
||||
import { sanitizeSortQuery } from './utilities/sanitizeSortQuery.js'
|
||||
import { updateDocument } from './utilities/update.js'
|
||||
import { buildAfterOperation } from './utils.js'
|
||||
|
||||
@@ -103,7 +104,7 @@ export const updateOperation = async <
|
||||
req,
|
||||
select: incomingSelect,
|
||||
showHiddenFields,
|
||||
sort,
|
||||
sort: incomingSort,
|
||||
where,
|
||||
} = args
|
||||
|
||||
@@ -136,6 +137,11 @@ export const updateOperation = async <
|
||||
|
||||
const fullWhere = combineQueries(where, accessResult)
|
||||
|
||||
const sort = sanitizeSortQuery({
|
||||
fields: collection.config.flattenedFields,
|
||||
sort: incomingSort,
|
||||
})
|
||||
|
||||
let docs
|
||||
|
||||
if (collectionConfig.versions?.drafts && shouldSaveDraft) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { FlattenedField } from '../../../fields/config/types.js'
|
||||
|
||||
const sanitizeSort = ({ fields, sort }: { fields: FlattenedField[]; sort: string }): string => {
|
||||
let sortProperty = sort
|
||||
let desc = false
|
||||
if (sort.indexOf('-') === 0) {
|
||||
desc = true
|
||||
sortProperty = sortProperty.substring(1)
|
||||
}
|
||||
|
||||
const segments = sortProperty.split('.')
|
||||
|
||||
for (const segment of segments) {
|
||||
const field = fields.find((each) => each.name === segment)
|
||||
if (!field) {
|
||||
return sort
|
||||
}
|
||||
|
||||
if ('fields' in field) {
|
||||
fields = field.flattenedFields
|
||||
continue
|
||||
}
|
||||
|
||||
if ('virtual' in field && typeof field.virtual === 'string') {
|
||||
return `${desc ? '-' : ''}${field.virtual}`
|
||||
}
|
||||
}
|
||||
|
||||
return sort
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the sort parameter, for example virtual fields linked to relationships are replaced with the full path.
|
||||
*/
|
||||
export const sanitizeSortQuery = ({
|
||||
fields,
|
||||
sort,
|
||||
}: {
|
||||
fields: FlattenedField[]
|
||||
sort?: string | string[]
|
||||
}): string | string[] | undefined => {
|
||||
if (!sort) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (Array.isArray(sort)) {
|
||||
return sort.map((sort) => sanitizeSort({ fields, sort }))
|
||||
}
|
||||
|
||||
return sanitizeSort({ fields, sort })
|
||||
}
|
||||
@@ -28,22 +28,6 @@ type Args = {
|
||||
}
|
||||
)
|
||||
|
||||
const flattenWhere = (query: Where): WhereField[] => {
|
||||
const flattenedConstraints: WhereField[] = []
|
||||
|
||||
for (const [key, val] of Object.entries(query)) {
|
||||
if ((key === 'and' || key === 'or') && Array.isArray(val)) {
|
||||
for (const subVal of val) {
|
||||
flattenedConstraints.push(...flattenWhere(subVal))
|
||||
}
|
||||
} else {
|
||||
flattenedConstraints.push({ [key]: val })
|
||||
}
|
||||
}
|
||||
|
||||
return flattenedConstraints
|
||||
}
|
||||
|
||||
export async function validateQueryPaths({
|
||||
collectionConfig,
|
||||
errors = [],
|
||||
@@ -61,17 +45,47 @@ export async function validateQueryPaths({
|
||||
const fields = versionFields || (globalConfig || collectionConfig).flattenedFields
|
||||
|
||||
if (typeof where === 'object') {
|
||||
const whereFields = flattenWhere(where)
|
||||
// We need to determine if the whereKey is an AND, OR, or a schema path
|
||||
const promises = []
|
||||
for (const constraint of whereFields) {
|
||||
for (const path in constraint) {
|
||||
for (const operator in constraint[path]) {
|
||||
const val = constraint[path][operator]
|
||||
for (const path in where) {
|
||||
const constraint = where[path]
|
||||
|
||||
if ((path === 'and' || path === 'or') && Array.isArray(constraint)) {
|
||||
for (const item of constraint) {
|
||||
if (collectionConfig) {
|
||||
promises.push(
|
||||
validateQueryPaths({
|
||||
collectionConfig,
|
||||
errors,
|
||||
overrideAccess,
|
||||
policies,
|
||||
req,
|
||||
versionFields,
|
||||
where: item,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
promises.push(
|
||||
validateQueryPaths({
|
||||
errors,
|
||||
globalConfig,
|
||||
overrideAccess,
|
||||
policies,
|
||||
req,
|
||||
versionFields,
|
||||
where: item,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (!Array.isArray(constraint)) {
|
||||
for (const operator in constraint) {
|
||||
const val = constraint[operator]
|
||||
if (validOperatorSet.has(operator as Operator)) {
|
||||
promises.push(
|
||||
validateSearchParam({
|
||||
collectionConfig,
|
||||
constraint: where as WhereField,
|
||||
errors,
|
||||
fields,
|
||||
globalConfig,
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
|
||||
import type { FlattenedField } from '../../fields/config/types.js'
|
||||
import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
|
||||
import type { PayloadRequest } from '../../types/index.js'
|
||||
import type { PayloadRequest, WhereField } from '../../types/index.js'
|
||||
import type { EntityPolicies, PathToQuery } from './types.js'
|
||||
|
||||
import { fieldAffectsData, fieldIsVirtual } from '../../fields/config/types.js'
|
||||
import { getEntityPolicies } from '../../utilities/getEntityPolicies.js'
|
||||
import { getFieldByPath } from '../../utilities/getFieldByPath.js'
|
||||
import isolateObjectProperty from '../../utilities/isolateObjectProperty.js'
|
||||
import { getLocalizedPaths } from '../getLocalizedPaths.js'
|
||||
import { validateQueryPaths } from './validateQueryPaths.js'
|
||||
|
||||
type Args = {
|
||||
collectionConfig?: SanitizedCollectionConfig
|
||||
constraint: WhereField
|
||||
errors: { path: string }[]
|
||||
fields: FlattenedField[]
|
||||
globalConfig?: SanitizedGlobalConfig
|
||||
@@ -32,6 +34,7 @@ type Args = {
|
||||
*/
|
||||
export async function validateSearchParam({
|
||||
collectionConfig,
|
||||
constraint,
|
||||
errors,
|
||||
fields,
|
||||
globalConfig,
|
||||
@@ -100,8 +103,13 @@ export async function validateSearchParam({
|
||||
return
|
||||
}
|
||||
|
||||
if (fieldIsVirtual(field)) {
|
||||
errors.push({ path })
|
||||
if ('virtual' in field && field.virtual) {
|
||||
if (field.virtual === true) {
|
||||
errors.push({ path })
|
||||
} else {
|
||||
constraint[`${field.virtual}`] = constraint[path]
|
||||
delete constraint[path]
|
||||
}
|
||||
}
|
||||
|
||||
if (polymorphicJoin && path === 'relationTo') {
|
||||
|
||||
@@ -514,9 +514,9 @@ export interface FieldBase {
|
||||
/**
|
||||
* Pass `true` to disable field in the DB
|
||||
* for [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges):
|
||||
* A virtual field cannot be used in `admin.useAsTitle`
|
||||
* A virtual field can be used in `admin.useAsTitle` only when linked to a relationship.
|
||||
*/
|
||||
virtual?: boolean
|
||||
virtual?: boolean | string
|
||||
}
|
||||
|
||||
export interface FieldBaseClient {
|
||||
@@ -1955,7 +1955,7 @@ export function fieldShouldBeLocalized({
|
||||
}
|
||||
|
||||
export function fieldIsVirtual(field: Field | Tab): boolean {
|
||||
return 'virtual' in field && field.virtual
|
||||
return 'virtual' in field && Boolean(field.virtual)
|
||||
}
|
||||
|
||||
export type HookName =
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { RichTextAdapter } from '../../../admin/RichText.js'
|
||||
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
|
||||
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
|
||||
import type { RequestContext } from '../../../index.js'
|
||||
import type {
|
||||
JsonObject,
|
||||
PayloadRequest,
|
||||
@@ -13,6 +12,7 @@ import type {
|
||||
import type { Block, Field, TabAsField } from '../../config/types.js'
|
||||
|
||||
import { MissingEditorProp } from '../../../errors/index.js'
|
||||
import { type RequestContext } from '../../../index.js'
|
||||
import { getBlockSelect } from '../../../utilities/getBlockSelect.js'
|
||||
import { stripUnselectedFields } from '../../../utilities/stripUnselectedFields.js'
|
||||
import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../../config/types.js'
|
||||
@@ -20,6 +20,7 @@ import { getDefaultValue } from '../../getDefaultValue.js'
|
||||
import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js'
|
||||
import { relationshipPopulationPromise } from './relationshipPopulationPromise.js'
|
||||
import { traverseFields } from './traverseFields.js'
|
||||
import { virtualFieldPopulationPromise } from './virtualFieldPopulationPromise.js'
|
||||
|
||||
type Args = {
|
||||
/**
|
||||
@@ -306,6 +307,24 @@ export const promise = async ({
|
||||
}
|
||||
}
|
||||
|
||||
if ('virtual' in field && typeof field.virtual === 'string') {
|
||||
populationPromises.push(
|
||||
virtualFieldPopulationPromise({
|
||||
name: field.name,
|
||||
draft,
|
||||
fallbackLocale,
|
||||
fields: (collection || global).flattenedFields,
|
||||
locale,
|
||||
overrideAccess,
|
||||
ref: doc,
|
||||
req,
|
||||
segments: field.virtual.split('.'),
|
||||
showHiddenFields,
|
||||
siblingDoc,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Execute access control
|
||||
let allowDefaultValue = true
|
||||
if (triggerAccessControl && field.access && field.access.read) {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { PayloadRequest } from '../../../types/index.js'
|
||||
import type { FlattenedField } from '../../config/types.js'
|
||||
|
||||
import { createDataloaderCacheKey } from '../../../collections/dataloader.js'
|
||||
|
||||
export const virtualFieldPopulationPromise = async ({
|
||||
name,
|
||||
draft,
|
||||
fallbackLocale,
|
||||
fields,
|
||||
locale,
|
||||
overrideAccess,
|
||||
ref,
|
||||
req,
|
||||
segments,
|
||||
showHiddenFields,
|
||||
siblingDoc,
|
||||
}: {
|
||||
draft: boolean
|
||||
fallbackLocale: string
|
||||
fields: FlattenedField[]
|
||||
locale: string
|
||||
name: string
|
||||
overrideAccess: boolean
|
||||
ref: any
|
||||
req: PayloadRequest
|
||||
segments: string[]
|
||||
showHiddenFields: boolean
|
||||
siblingDoc: Record<string, unknown>
|
||||
}): Promise<void> => {
|
||||
const currentSegment = segments.shift()
|
||||
|
||||
if (!currentSegment) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentValue = ref[currentSegment]
|
||||
|
||||
if (typeof currentValue === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
// Final step
|
||||
if (segments.length === 0) {
|
||||
siblingDoc[name] = currentValue
|
||||
return
|
||||
}
|
||||
|
||||
const currentField = fields.find((each) => each.name === currentSegment)
|
||||
|
||||
if (!currentField) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentField.type === 'group' || currentField.type === 'tab') {
|
||||
if (!currentValue || typeof currentValue !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
return virtualFieldPopulationPromise({
|
||||
name,
|
||||
draft,
|
||||
fallbackLocale,
|
||||
fields: currentField.flattenedFields,
|
||||
locale,
|
||||
overrideAccess,
|
||||
ref: currentValue,
|
||||
req,
|
||||
segments,
|
||||
showHiddenFields,
|
||||
siblingDoc,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
(currentField.type === 'relationship' || currentField.type === 'upload') &&
|
||||
typeof currentField.relationTo === 'string' &&
|
||||
!currentField.hasMany
|
||||
) {
|
||||
let docID: number | string
|
||||
|
||||
if (typeof currentValue === 'object' && currentValue) {
|
||||
docID = currentValue.id
|
||||
} else {
|
||||
docID = currentValue
|
||||
}
|
||||
|
||||
if (typeof docID !== 'string' && typeof docID !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
const select = {}
|
||||
let currentSelectRef: any = select
|
||||
const currentFields = req.payload.collections[currentField.relationTo].config.flattenedFields
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const field = currentFields.find((each) => each.name === segments[i])
|
||||
|
||||
const shouldBreak =
|
||||
i === segments.length - 1 || field?.type === 'relationship' || field?.type === 'upload'
|
||||
|
||||
currentSelectRef[segments[i]] = shouldBreak ? true : {}
|
||||
currentSelectRef = currentSelectRef[segments[i]]
|
||||
|
||||
if (shouldBreak) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const populatedDoc = await req.payloadDataLoader.load(
|
||||
createDataloaderCacheKey({
|
||||
collectionSlug: currentField.relationTo,
|
||||
currentDepth: 0,
|
||||
depth: 0,
|
||||
docID,
|
||||
draft,
|
||||
fallbackLocale,
|
||||
locale,
|
||||
overrideAccess,
|
||||
select,
|
||||
showHiddenFields,
|
||||
transactionID: req.transactionID as number,
|
||||
}),
|
||||
)
|
||||
|
||||
if (!populatedDoc) {
|
||||
return
|
||||
}
|
||||
|
||||
return virtualFieldPopulationPromise({
|
||||
name,
|
||||
draft,
|
||||
fallbackLocale,
|
||||
fields: req.payload.collections[currentField.relationTo].config.flattenedFields,
|
||||
locale,
|
||||
overrideAccess,
|
||||
ref: populatedDoc,
|
||||
req,
|
||||
segments,
|
||||
showHiddenFields,
|
||||
siblingDoc,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,15 @@ export default buildConfigWithDefaults({
|
||||
},
|
||||
},
|
||||
collections: [
|
||||
{
|
||||
slug: 'categories',
|
||||
fields: [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'title',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: postsSlug,
|
||||
fields: [
|
||||
@@ -43,6 +52,17 @@ export default buildConfigWithDefaults({
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
// access: { read: () => false },
|
||||
},
|
||||
{
|
||||
type: 'relationship',
|
||||
relationTo: 'categories',
|
||||
name: 'category',
|
||||
},
|
||||
{
|
||||
name: 'localized',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
@@ -437,6 +457,33 @@ export default buildConfigWithDefaults({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'virtual-relations',
|
||||
admin: { useAsTitle: 'postTitle' },
|
||||
fields: [
|
||||
{
|
||||
name: 'postTitle',
|
||||
type: 'text',
|
||||
virtual: 'post.title',
|
||||
},
|
||||
{
|
||||
name: 'postCategoryTitle',
|
||||
type: 'text',
|
||||
virtual: 'post.category.title',
|
||||
},
|
||||
{
|
||||
name: 'postLocalized',
|
||||
type: 'text',
|
||||
virtual: 'post.localized',
|
||||
},
|
||||
{
|
||||
name: 'post',
|
||||
type: 'relationship',
|
||||
relationTo: 'posts',
|
||||
},
|
||||
],
|
||||
versions: { drafts: true },
|
||||
},
|
||||
{
|
||||
slug: fieldsPersistanceSlug,
|
||||
fields: [
|
||||
@@ -662,6 +709,21 @@ export default buildConfigWithDefaults({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'virtual-relation-global',
|
||||
fields: [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'postTitle',
|
||||
virtual: 'post.title',
|
||||
},
|
||||
{
|
||||
type: 'relationship',
|
||||
name: 'post',
|
||||
relationTo: 'posts',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
localization: {
|
||||
defaultLocale: 'en',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
migrateRelationshipsV2_V3,
|
||||
migrateVersionsV1_V2,
|
||||
} from '@payloadcms/db-mongodb/migration-utils'
|
||||
import { objectToFrontmatter } from '@payloadcms/richtext-lexical'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type Table } from 'drizzle-orm'
|
||||
import * as drizzlePg from 'drizzle-orm/pg-core'
|
||||
@@ -1977,6 +1978,132 @@ describe('database', () => {
|
||||
expect(res.textWithinRow).toBeUndefined()
|
||||
expect(res.textWithinTabs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should allow virtual field with reference', async () => {
|
||||
const post = await payload.create({ collection: 'posts', data: { title: 'my-title' } })
|
||||
const { id } = await payload.create({
|
||||
collection: 'virtual-relations',
|
||||
depth: 0,
|
||||
data: { post: post.id },
|
||||
})
|
||||
|
||||
const doc = await payload.findByID({ collection: 'virtual-relations', depth: 0, id })
|
||||
expect(doc.postTitle).toBe('my-title')
|
||||
const draft = await payload.find({
|
||||
collection: 'virtual-relations',
|
||||
depth: 0,
|
||||
where: { id: { equals: id } },
|
||||
draft: true,
|
||||
})
|
||||
expect(draft.docs[0]?.postTitle).toBe('my-title')
|
||||
})
|
||||
|
||||
it('should allow virtual field with reference localized', async () => {
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: { title: 'my-title', localized: 'localized en' },
|
||||
})
|
||||
|
||||
await payload.update({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
locale: 'es',
|
||||
data: { localized: 'localized es' },
|
||||
})
|
||||
|
||||
const { id } = await payload.create({
|
||||
collection: 'virtual-relations',
|
||||
depth: 0,
|
||||
data: { post: post.id },
|
||||
})
|
||||
|
||||
let doc = await payload.findByID({ collection: 'virtual-relations', depth: 0, id })
|
||||
expect(doc.postLocalized).toBe('localized en')
|
||||
|
||||
doc = await payload.findByID({ collection: 'virtual-relations', depth: 0, id, locale: 'es' })
|
||||
expect(doc.postLocalized).toBe('localized es')
|
||||
})
|
||||
|
||||
it('should allow to query by a virtual field with reference', async () => {
|
||||
await payload.delete({ collection: 'posts', where: {} })
|
||||
await payload.delete({ collection: 'virtual-relations', where: {} })
|
||||
const post_1 = await payload.create({ collection: 'posts', data: { title: 'Dan' } })
|
||||
const post_2 = await payload.create({ collection: 'posts', data: { title: 'Mr.Dan' } })
|
||||
|
||||
const doc_1 = await payload.create({
|
||||
collection: 'virtual-relations',
|
||||
depth: 0,
|
||||
data: { post: post_1.id },
|
||||
})
|
||||
const doc_2 = await payload.create({
|
||||
collection: 'virtual-relations',
|
||||
depth: 0,
|
||||
data: { post: post_2.id },
|
||||
})
|
||||
|
||||
const { docs: ascDocs } = await payload.find({
|
||||
collection: 'virtual-relations',
|
||||
sort: 'postTitle',
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
expect(ascDocs[0]?.id).toBe(doc_1.id)
|
||||
|
||||
expect(ascDocs[1]?.id).toBe(doc_2.id)
|
||||
|
||||
const { docs: descDocs } = await payload.find({
|
||||
collection: 'virtual-relations',
|
||||
sort: '-postTitle',
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
expect(descDocs[1]?.id).toBe(doc_1.id)
|
||||
|
||||
expect(descDocs[0]?.id).toBe(doc_2.id)
|
||||
})
|
||||
|
||||
it.todo('should allow to sort by a virtual field with reference')
|
||||
|
||||
it('should allow virtual field 2x deep', async () => {
|
||||
const category = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: '1-category' },
|
||||
})
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: { title: '1-post', category: category.id },
|
||||
})
|
||||
const doc = await payload.create({ collection: 'virtual-relations', data: { post: post.id } })
|
||||
expect(doc.postCategoryTitle).toBe('1-category')
|
||||
})
|
||||
|
||||
it('should allow to query by virtual field 2x deep', async () => {
|
||||
const category = await payload.create({
|
||||
collection: 'categories',
|
||||
data: { title: '2-category' },
|
||||
})
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: { title: '2-post', category: category.id },
|
||||
})
|
||||
const doc = await payload.create({ collection: 'virtual-relations', data: { post: post.id } })
|
||||
const found = await payload.find({
|
||||
collection: 'virtual-relations',
|
||||
where: { postCategoryTitle: { equals: '2-category' } },
|
||||
})
|
||||
expect(found.docs).toHaveLength(1)
|
||||
expect(found.docs[0].id).toBe(doc.id)
|
||||
})
|
||||
|
||||
it('should allow referenced virtual field in globals', async () => {
|
||||
const post = await payload.create({ collection: 'posts', data: { title: 'post' } })
|
||||
const globalData = await payload.updateGlobal({
|
||||
slug: 'virtual-relation-global',
|
||||
data: { post: post.id },
|
||||
depth: 0,
|
||||
})
|
||||
expect(globalData.postTitle).toBe('post')
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert numbers to text', async () => {
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface Config {
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
categories: Category;
|
||||
posts: Post;
|
||||
'error-on-unnamed-fields': ErrorOnUnnamedField;
|
||||
'default-values': DefaultValue;
|
||||
@@ -75,6 +76,7 @@ export interface Config {
|
||||
'pg-migrations': PgMigration;
|
||||
'custom-schema': CustomSchema;
|
||||
places: Place;
|
||||
'virtual-relations': VirtualRelation;
|
||||
'fields-persistance': FieldsPersistance;
|
||||
'custom-ids': CustomId;
|
||||
'fake-custom-ids': FakeCustomId;
|
||||
@@ -88,6 +90,7 @@ export interface Config {
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
categories: CategoriesSelect<false> | CategoriesSelect<true>;
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
'error-on-unnamed-fields': ErrorOnUnnamedFieldsSelect<false> | ErrorOnUnnamedFieldsSelect<true>;
|
||||
'default-values': DefaultValuesSelect<false> | DefaultValuesSelect<true>;
|
||||
@@ -96,6 +99,7 @@ export interface Config {
|
||||
'pg-migrations': PgMigrationsSelect<false> | PgMigrationsSelect<true>;
|
||||
'custom-schema': CustomSchemaSelect<false> | CustomSchemaSelect<true>;
|
||||
places: PlacesSelect<false> | PlacesSelect<true>;
|
||||
'virtual-relations': VirtualRelationsSelect<false> | VirtualRelationsSelect<true>;
|
||||
'fields-persistance': FieldsPersistanceSelect<false> | FieldsPersistanceSelect<true>;
|
||||
'custom-ids': CustomIdsSelect<false> | CustomIdsSelect<true>;
|
||||
'fake-custom-ids': FakeCustomIdsSelect<false> | FakeCustomIdsSelect<true>;
|
||||
@@ -114,11 +118,13 @@ export interface Config {
|
||||
global: Global;
|
||||
'global-2': Global2;
|
||||
'global-3': Global3;
|
||||
'virtual-relation-global': VirtualRelationGlobal;
|
||||
};
|
||||
globalsSelect: {
|
||||
global: GlobalSelect<false> | GlobalSelect<true>;
|
||||
'global-2': Global2Select<false> | Global2Select<true>;
|
||||
'global-3': Global3Select<false> | Global3Select<true>;
|
||||
'virtual-relation-global': VirtualRelationGlobalSelect<false> | VirtualRelationGlobalSelect<true>;
|
||||
};
|
||||
locale: 'en' | 'es';
|
||||
user: User & {
|
||||
@@ -147,6 +153,16 @@ export interface UserAuthOperations {
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "categories".
|
||||
*/
|
||||
export interface Category {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts".
|
||||
@@ -154,6 +170,9 @@ export interface UserAuthOperations {
|
||||
export interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
category?: (string | null) | Category;
|
||||
localized?: string | null;
|
||||
text?: string | null;
|
||||
number?: number | null;
|
||||
D1?: {
|
||||
D2?: {
|
||||
@@ -346,6 +365,20 @@ export interface Place {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "virtual-relations".
|
||||
*/
|
||||
export interface VirtualRelation {
|
||||
id: string;
|
||||
postTitle?: string | null;
|
||||
postCategoryTitle?: string | null;
|
||||
postLocalized?: string | null;
|
||||
post?: (string | null) | Post;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "fields-persistance".
|
||||
@@ -465,6 +498,10 @@ export interface User {
|
||||
export interface PayloadLockedDocument {
|
||||
id: string;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'categories';
|
||||
value: string | Category;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'posts';
|
||||
value: string | Post;
|
||||
@@ -497,6 +534,10 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'places';
|
||||
value: string | Place;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'virtual-relations';
|
||||
value: string | VirtualRelation;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'fields-persistance';
|
||||
value: string | FieldsPersistance;
|
||||
@@ -567,12 +608,24 @@ export interface PayloadMigration {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "categories_select".
|
||||
*/
|
||||
export interface CategoriesSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts_select".
|
||||
*/
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
category?: T;
|
||||
localized?: T;
|
||||
text?: T;
|
||||
number?: T;
|
||||
D1?:
|
||||
| T
|
||||
@@ -747,6 +800,19 @@ export interface PlacesSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "virtual-relations_select".
|
||||
*/
|
||||
export interface VirtualRelationsSelect<T extends boolean = true> {
|
||||
postTitle?: T;
|
||||
postCategoryTitle?: T;
|
||||
postLocalized?: T;
|
||||
post?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "fields-persistance_select".
|
||||
@@ -917,6 +983,17 @@ export interface Global3 {
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "virtual-relation-global".
|
||||
*/
|
||||
export interface VirtualRelationGlobal {
|
||||
id: string;
|
||||
postTitle?: string | null;
|
||||
post?: (string | null) | Post;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "global_select".
|
||||
@@ -947,6 +1024,17 @@ export interface Global3Select<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "virtual-relation-global_select".
|
||||
*/
|
||||
export interface VirtualRelationGlobalSelect<T extends boolean = true> {
|
||||
postTitle?: T;
|
||||
post?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
|
||||
Reference in New Issue
Block a user