feat!: join field (#7518)

## Description

- Adds a new "join" field type to Payload and is supported by all database adapters
- The UI uses a table view for the new field
- `db-mongodb` changes relationships to be stored as ObjectIDs instead of strings (for now querying works using both types internally to the DB so no data migration should be necessary unless you're querying directly, see breaking changes for details
- Adds a reusable traverseFields utility to Payload to make it easier to work with nested fields, used internally and for plugin maintainers

```ts
export const Categories: CollectionConfig = {
    slug: 'categories',
    fields: [
        {
            name: 'relatedPosts',
            type: 'join',
            collection: 'posts',
            on: 'category',
        }
    ]
}
```

BREAKING CHANGES:
All mongodb relationship and upload values will be stored as MongoDB ObjectIDs instead of strings going forward. If you have existing data and you are querying data directly, outside of Payload's APIs, you get different results. For example, a `contains` query will no longer works given a partial ID of a relationship since the ObjectID requires the whole identifier to work. 

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
Co-authored-by: James <james@trbl.design>
This commit is contained in:
Dan Ribbens
2024-09-20 11:10:16 -04:00
committed by GitHub
parent b51d2bcb39
commit 6ef2bdea15
189 changed files with 11076 additions and 5882 deletions

View File

@@ -52,7 +52,6 @@ jobs:
plugin-form-builder
plugin-nested-docs
plugin-redirects
plugin-relationship-object-ids
plugin-search
plugin-sentry
plugin-seo

235
docs/fields/join.mdx Normal file
View File

@@ -0,0 +1,235 @@
---
title: Join Field
label: Join
order: 140
desc: The Join field provides the ability to work on related documents. Learn how to use Join field, see examples and options.
keywords: join, relationship, junction, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
The Join Field is used to make Relationship fields in the opposite direction. It is used to show the relationship from
the other side. The field itself acts as a virtual field, in that no new data is stored on the collection with a Join
field. Instead, the Admin UI surfaces the related documents for a better editing experience and is surfaced by Payload's
APIs.
The Join field is useful in scenarios including:
- To surface `Order`s for a given `Product`
- To view and edit `Posts` belonging to a `Category`
- To work with any bi-directional relationship data
For the Join field to work, you must have an existing [relationship](./relationship) field in the collection you are
joining. This will reference the collection and path of the field of the related documents.
To add a Relationship Field, set the `type` to `join` in your [Field Config](./overview):
```ts
import type { Field } from 'payload/types'
export const MyJoinField: Field = {
// highlight-start
name: 'relatedPosts',
type: 'join',
collection: 'posts',
on: 'category',
// highlight-end
}
// relationship field in another collection:
export const MyRelationshipField: Field = {
name: 'category',
type: 'relationship',
relationTo: 'categories',
}
```
In this example, the field is defined to show the related `posts` when added to a `category` collection. The `on`
property is used to
specify the relationship field name of the field that relates to the collection document.
With this example, if you navigate to a Category in the Admin UI or an API response, you'll now see that the Posts which
are related to the Category are populated for you. This is extremely powerful and can be used to define a wide variety
of relationship types in an easy manner.
<Banner type="success">
The Join field is extremely performant and does not add additional query overhead to your API responses until you add depth of 1 or above. It works in all database adapters. In MongoDB, we use <strong>aggregations</strong> to automatically join in related documents, and in relational databases, we use joins.
</Banner>
### Schema advice
When modeling your database, you might come across many places where you'd like to feature bi-directional relationships.
But here's an important consideration—you generally only want to store information about a given relationship in _one_
place.
Let's take the Posts and Categories example. It makes sense to define which category a post belongs to while editing the
post.
It would generally not be necessary to have a list of post IDs stored directly on the category as well, for a few
reasons:
- You want to have a "single source of truth" for relationships, and not worry about keeping two sources in sync with
one another
- If you have hundreds, thousands, or even millions of posts, you would not want to store all of those post IDs on a
given category
- Etc.
This is where the `join` field is especially powerful. With it, you only need to store the `category_id` on the `post`,
and Payload will automatically join in related posts for you when you query for categories. The related category is only
stored on the post itself - and is not duplicated on both sides. However, the `join` field is what enables
bi-directional APIs and UI for you.
### Using the Join field to have full control of your database schema
For typical polymorphic / many relationships, if you're using Postgres or SQLite, Payload will automatically create
a `posts_rels` table, which acts as a junction table to store all of a given document's relationships.
However, this might not be appropriate for your use case if you'd like to have more control over your database
architecture. You might not want to have that `_rels` table, and would prefer to maintain / control your own junction
table design.
<Banner type="success">
With the Join field, you can control your own junction table design, and avoid Payload's automatic _rels table creation.
</Banner>
The `join` field can be used in conjunction with _any_ collection - and if you wanted to define your own "junction"
collection, which, say, is called `categories_posts` and has a `post_id` and a `category_id` column, you can achieve
complete control over the shape of that junction table.
You could go a step further and leverage the `admin.hidden` property of the `categories_posts` collection to hide the
collection from appearing in the Admin UI navigation.
#### Specifying additional fields on relationships
Another very powerful use case of the `join` field is to be able to define "context" fields on your relationships. Let's
say that you have Posts and Categories, and use join fields on both your Posts and Categories collection to join in
related docs from a new pseudo-junction collection called `categories_posts`. Now, the relations are stored in this
third junction collection, and can be surfaced on both Posts and Categories. But, importantly, you could add
additional "context" fields to this shared junction collection.
For example, on this `categories_posts` collection, in addition to having the `category` and
post` fields, we could add custom "context" fields like `featured` or `
spotlight`, which would allow you to store additional information directly on relationships. The `join` field gives you
complete control over any type of relational architecture in Payload, all wrapped up in a powerful Admin UI.
## Config Options
| Option | Description |
|------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`name`** \* | To be used as the property name when retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`collection`** \* | The `slug`s having the relationship field. |
| **`on`** \* | The relationship field name of the field that relates to collection document. Use dot notation for nested paths, like 'myGroup.relationName'. |
| **`maxDepth`** | Default is 1, Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](/docs/getting-started/concepts#field-level-max-depth) |
| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. |
| **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). |
| **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). |
| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. |
| **`required`** | Require this field to have a value. |
| **`admin`** | Admin-specific configuration. |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
| **`typescriptSchema`** | Override field type generation with providing a JSON schema |
_\* An asterisk denotes that a property is required._
## Join Field Data
When a document is returned that for a Join field is populated with related documents. The structure returned is an
object with:
- `docs` an array of related documents or only IDs if the depth is reached
- `hasNextPage` a boolean indicating if there are additional documents
```json
{
"id": "66e3431a3f23e684075aae9c",
"relatedPosts": {
"docs": [
{
"id": "66e3431a3f23e684075aaeb9",
// other fields...
"category": "66e3431a3f23e684075aae9c",
},
// { ... }
],
"hasNextPage": false
},
// other fields...
}
```
## Query Options
The Join Field supports custom queries to filter, sort, and limit the related documents that will be returned. In
addition to the specific query options for each Join Field, you can pass `joins: false` to disable all Join Field from
returning. This is useful for performance reasons when you don't need the related documents.
The following query options are supported:
| Property | Description |
|-------------|--------------------------------------------------------------|
| **`limit`** | The maximum related documents to be returned, default is 10. |
| **`where`** | An optional `Where` query to filter joined documents. |
| **`sort`** | A string used to order related results |
These can be applied to the local API, GraphQL, and REST API.
### Local API
By adding `joins` to the local API you can customize the request for each join field by the `name` of the field.
```js
const result = await db.findOne('categories', {
where: {
title: {
equals: 'My Category'
}
},
joins: {
relatedPosts: {
limit: 5,
where: {
title: {
equals: 'My Post'
}
},
sort: 'title'
}
}
})
```
### Rest API
The rest API supports the same query options as the local API. You can use the `joins` query parameter to customize the
request for each join field by the `name` of the field. For example, an API call to get a document with the related
posts limited to 5 and sorted by title:
`/api/categories/${id}?joins[relatedPosts][limit]=5&joins[relatedPosts][sort]=title`
You can specify as many `joins` parameters as needed for the same or different join fields for a single request.
### GraphQL
The GraphQL API supports the same query options as the local and REST APIs. You can specify the query options for each join field in your query.
Example:
```graphql
query {
Categories {
docs {
relatedPosts(
sort: "createdAt"
limit: 5
where: {
author: {
equals: "66e3431a3f23e684075aaeb9"
}
}
) {
docs {
title
}
hasNextPage
}
}
}
}
```

View File

@@ -197,6 +197,12 @@ You can learn more about writing queries [here](/docs/queries/overview).
<strong>payload/shared</strong> in your validate function.
</Banner>
## Bi-directional relationships
The `relationship` field on its own is used to define relationships for the document that contains the relationship field, and this can be considered as a "one-way" relationship. For example, if you have a Post that has a `category` relationship field on it, the related `category` itself will not surface any information about the posts that have the category set.
However, the `relationship` field can be used in conjunction with the `Join` field to produce powerful bi-directional relationship authoring capabilities. If you're interested in bi-directional relationships, check out the [documentation for the Join field](./join).
## How the data is saved
Given the variety of options possible within the `relationship` field type, the shape of the data needed for creating

View File

@@ -34,7 +34,6 @@
"build:plugin-form-builder": "turbo build --filter \"@payloadcms/plugin-form-builder\"",
"build:plugin-nested-docs": "turbo build --filter \"@payloadcms/plugin-nested-docs\"",
"build:plugin-redirects": "turbo build --filter \"@payloadcms/plugin-redirects\"",
"build:plugin-relationship-object-ids": "turbo build --filter \"@payloadcms/plugin-relationship-object-ids\"",
"build:plugin-search": "turbo build --filter \"@payloadcms/plugin-search\"",
"build:plugin-sentry": "turbo build --filter \"@payloadcms/plugin-sentry\"",
"build:plugin-seo": "turbo build --filter \"@payloadcms/plugin-seo\"",

View File

@@ -38,13 +38,14 @@
"bson-objectid": "2.0.4",
"http-status": "1.6.2",
"mongoose": "6.12.3",
"mongoose-aggregate-paginate-v2": "1.0.6",
"mongoose-paginate-v2": "1.7.22",
"prompts": "2.4.2",
"uuid": "10.0.0"
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/mongoose-aggregate-paginate-v2": "1.0.9",
"@types/mongoose-aggregate-paginate-v2": "1.0.6",
"mongodb": "4.17.1",
"mongodb-memory-server": "^9",
"payload": "workspace:*"

View File

@@ -3,6 +3,7 @@ import type { Create, Document, PayloadRequest } from 'payload'
import type { MongooseAdapter } from './index.js'
import { handleError } from './utilities/handleError.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { withSession } from './withSession.js'
export const create: Create = async function create(
@@ -12,8 +13,15 @@ export const create: Create = async function create(
const Model = this.collections[collection]
const options = await withSession(this, req)
let doc
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
data,
fields: this.payload.collections[collection].config.fields,
})
try {
;[doc] = await Model.create([data], options)
;[doc] = await Model.create([sanitizedData], options)
} catch (error) {
handleError({ collection, error, req })
}

View File

@@ -3,6 +3,7 @@ import type { CreateGlobal, PayloadRequest } from 'payload'
import type { MongooseAdapter } from './index.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { withSession } from './withSession.js'
export const createGlobal: CreateGlobal = async function createGlobal(
@@ -10,10 +11,16 @@ export const createGlobal: CreateGlobal = async function createGlobal(
{ slug, data, req = {} as PayloadRequest },
) {
const Model = this.globals
const global = {
const global = sanitizeRelationshipIDs({
config: this.payload.config,
data: {
globalType: slug,
...data,
}
},
fields: this.payload.config.globals.find((globalConfig) => globalConfig.slug === slug).fields,
})
const options = await withSession(this, req)
let [result] = (await Model.create([global], options)) as any

View File

@@ -1,7 +1,13 @@
import type { CreateGlobalVersion, Document, PayloadRequest } from 'payload'
import {
buildVersionGlobalFields,
type CreateGlobalVersion,
type Document,
type PayloadRequest,
} from 'payload'
import type { MongooseAdapter } from './index.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { withSession } from './withSession.js'
export const createGlobalVersion: CreateGlobalVersion = async function createGlobalVersion(
@@ -21,9 +27,9 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
const VersionModel = this.versions[globalSlug]
const options = await withSession(this, req)
const [doc] = await VersionModel.create(
[
{
const data = sanitizeRelationshipIDs({
config: this.payload.config,
data: {
autosave,
createdAt,
latest: true,
@@ -33,10 +39,13 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
updatedAt,
version: versionData,
},
],
options,
req,
)
fields: buildVersionGlobalFields(
this.payload.config,
this.payload.config.globals.find((global) => global.slug === globalSlug),
),
})
const [doc] = await VersionModel.create([data], options, req)
await VersionModel.updateMany(
{

View File

@@ -1,7 +1,13 @@
import type { CreateVersion, Document, PayloadRequest } from 'payload'
import {
buildVersionCollectionFields,
type CreateVersion,
type Document,
type PayloadRequest,
} from 'payload'
import type { MongooseAdapter } from './index.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { withSession } from './withSession.js'
export const createVersion: CreateVersion = async function createVersion(
@@ -21,9 +27,9 @@ export const createVersion: CreateVersion = async function createVersion(
const VersionModel = this.versions[collectionSlug]
const options = await withSession(this, req)
const [doc] = await VersionModel.create(
[
{
const data = sanitizeRelationshipIDs({
config: this.payload.config,
data: {
autosave,
createdAt,
latest: true,
@@ -33,10 +39,13 @@ export const createVersion: CreateVersion = async function createVersion(
updatedAt,
version: versionData,
},
],
options,
req,
)
fields: buildVersionCollectionFields(
this.payload.config,
this.payload.collections[collectionSlug].config,
),
})
const [doc] = await VersionModel.create([data], options, req)
await VersionModel.updateMany(
{
@@ -48,7 +57,7 @@ export const createVersion: CreateVersion = async function createVersion(
},
{
parent: {
$eq: parent,
$eq: data.parent,
},
},
{

View File

@@ -6,12 +6,24 @@ import { flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildSortParam } from './queries/buildSortParam.js'
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { withSession } from './withSession.js'
export const find: Find = async function find(
this: MongooseAdapter,
{ collection, limit, locale, page, pagination, req = {} as PayloadRequest, sort: sortArg, where },
{
collection,
joins = {},
limit,
locale,
page,
pagination,
projection,
req = {} as PayloadRequest,
sort: sortArg,
where,
},
) {
const Model = this.collections[collection]
const collectionConfig = this.payload.collections[collection].config
@@ -50,6 +62,7 @@ export const find: Find = async function find(
options,
page,
pagination,
projection,
sort,
useEstimatedCount,
}
@@ -88,7 +101,24 @@ export const find: Find = async function find(
}
}
const result = await Model.paginate(query, paginationOptions)
let result
const aggregate = await buildJoinAggregation({
adapter: this,
collection,
collectionConfig,
joins,
limit,
locale,
query,
})
// build join aggregation
if (aggregate) {
result = await Model.aggregatePaginate(Model.aggregate(aggregate), paginationOptions)
} else {
result = await Model.paginate(query, paginationOptions)
}
const docs = JSON.parse(JSON.stringify(result.docs))
return {

View File

@@ -3,14 +3,16 @@ import type { Document, FindOne, PayloadRequest } from 'payload'
import type { MongooseAdapter } from './index.js'
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { withSession } from './withSession.js'
export const findOne: FindOne = async function findOne(
this: MongooseAdapter,
{ collection, locale, req = {} as PayloadRequest, where },
{ collection, joins, locale, req = {} as PayloadRequest, where },
) {
const Model = this.collections[collection]
const collectionConfig = this.payload.collections[collection].config
const options: MongooseQueryOptions = {
...(await withSession(this, req)),
lean: true,
@@ -22,7 +24,22 @@ export const findOne: FindOne = async function findOne(
where,
})
const doc = await Model.findOne(query, {}, options)
const aggregate = await buildJoinAggregation({
adapter: this,
collection,
collectionConfig,
joins,
limit: 1,
locale,
query,
})
let doc
if (aggregate) {
;[doc] = await Model.aggregate(aggregate, options)
} else {
doc = await Model.findOne(query, {}, options)
}
if (!doc) {
return null

View File

@@ -2,6 +2,7 @@ import type { PaginateOptions } from 'mongoose'
import type { Init, SanitizedCollectionConfig } from 'payload'
import mongoose from 'mongoose'
import mongooseAggregatePaginate from 'mongoose-aggregate-paginate-v2'
import paginate from 'mongoose-paginate-v2'
import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload'
@@ -40,12 +41,16 @@ export const init: Init = function init(this: MongooseAdapter) {
}),
)
if (Object.keys(collection.joins).length > 0) {
versionSchema.plugin(mongooseAggregatePaginate)
}
const model = mongoose.model(
versionModelName,
versionSchema,
this.autoPluralization === true ? undefined : versionModelName,
) as CollectionModel
// this.payload.versions[collection.slug] = model;
this.versions[collection.slug] = model
}

View File

@@ -1,6 +1,7 @@
import type { PaginateOptions, Schema } from 'mongoose'
import type { SanitizedCollectionConfig, SanitizedConfig } from 'payload'
import mongooseAggregatePaginate from 'mongoose-aggregate-paginate-v2'
import paginate from 'mongoose-paginate-v2'
import { getBuildQueryPlugin } from '../queries/buildQuery.js'
@@ -42,5 +43,9 @@ export const buildCollectionSchema = (
.plugin<any, PaginateOptions>(paginate, { useEstimatedCount: true })
.plugin(getBuildQueryPlugin({ collectionSlug: collection.slug }))
if (Object.keys(collection.joins).length > 0) {
schema.plugin(mongooseAggregatePaginate)
}
return schema
}

View File

@@ -165,7 +165,7 @@ export async function buildSearchParam({
const subQuery = priorQueryResult.value
const result = await SubModel.find(subQuery, subQueryOptions)
const $in = result.map((doc) => doc._id.toString())
const $in = result.map((doc) => doc._id)
// If it is the last recursion
// then pass through the search param

View File

@@ -1,5 +1,6 @@
import type { Field, TabAsField } from 'payload'
import ObjectIdImport from 'bson-objectid'
import mongoose from 'mongoose'
import { createArrayFromCommaDelineated } from 'payload'
@@ -11,6 +12,8 @@ type SanitizeQueryValueArgs = {
val: any
}
const ObjectId = (ObjectIdImport.default ||
ObjectIdImport) as unknown as typeof ObjectIdImport.default
export const sanitizeQueryValue = ({
field,
hasCustomID,
@@ -26,12 +29,21 @@ export const sanitizeQueryValue = ({
let formattedOperator = operator
// Disregard invalid _ids
if (path === '_id' && typeof val === 'string' && val.split(',').length === 1) {
if (path === '_id') {
if (typeof val === 'string' && val.split(',').length === 1) {
if (!hasCustomID) {
const isValid = mongoose.Types.ObjectId.isValid(val)
if (!isValid) {
return { operator: formattedOperator, val: undefined }
} else {
if (['in', 'not_in'].includes(operator)) {
formattedValue = createArrayFromCommaDelineated(formattedValue).map((id) =>
ObjectId(id),
)
} else {
formattedValue = ObjectId(val)
}
}
}
@@ -42,6 +54,25 @@ export const sanitizeQueryValue = ({
return { operator: formattedOperator, val: undefined }
}
}
} else if (Array.isArray(val)) {
formattedValue = formattedValue.reduce((formattedValues, inVal) => {
const newValues = [inVal]
if (!hasCustomID) {
if (mongoose.Types.ObjectId.isValid(inVal)) {
newValues.push(ObjectId(inVal))
}
}
if (field.type === 'number') {
const parsedNumber = parseFloat(inVal)
if (!Number.isNaN(parsedNumber)) {
newValues.push(parsedNumber)
}
}
return [...formattedValues, ...newValues]
}, [])
}
}
// Cast incoming values as proper searchable types
@@ -86,6 +117,13 @@ export const sanitizeQueryValue = ({
formattedValue.value &&
formattedValue.relationTo
) {
const { value } = formattedValue
const isValid = mongoose.Types.ObjectId.isValid(value)
if (isValid) {
formattedValue.value = ObjectId(value)
}
return {
rawQuery: {
$and: [
@@ -96,11 +134,11 @@ export const sanitizeQueryValue = ({
}
}
if (operator === 'in' && Array.isArray(formattedValue)) {
if (['in', 'not_in'].includes(operator) && Array.isArray(formattedValue)) {
formattedValue = formattedValue.reduce((formattedValues, inVal) => {
const newValues = [inVal]
if (mongoose.Types.ObjectId.isValid(inVal)) {
newValues.push(new mongoose.Types.ObjectId(inVal))
newValues.push(ObjectId(inVal))
}
const parsedNumber = parseFloat(inVal)
@@ -111,6 +149,12 @@ export const sanitizeQueryValue = ({
return [...formattedValues, ...newValues]
}, [])
}
if (operator === 'contains' && typeof formattedValue === 'string') {
if (mongoose.Types.ObjectId.isValid(formattedValue)) {
formattedValue = ObjectId(formattedValue)
}
}
}
// Set up specific formatting necessary by operators
@@ -152,7 +196,7 @@ export const sanitizeQueryValue = ({
}
if (path !== '_id' || (path === '_id' && hasCustomID && field.type === 'text')) {
if (operator === 'contains') {
if (operator === 'contains' && !mongoose.Types.ObjectId.isValid(formattedValue)) {
formattedValue = {
$options: 'i',
$regex: formattedValue.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'),

View File

@@ -1,4 +1,11 @@
import type { IndexDefinition, IndexOptions, Model, PaginateModel, SchemaOptions } from 'mongoose'
import type {
AggregatePaginateModel,
IndexDefinition,
IndexOptions,
Model,
PaginateModel,
SchemaOptions,
} from 'mongoose'
import type {
ArrayField,
BlocksField,
@@ -9,6 +16,7 @@ import type {
EmailField,
Field,
GroupField,
JoinField,
JSONField,
NumberField,
Payload,
@@ -27,7 +35,10 @@ import type {
import type { BuildQueryArgs } from './queries/buildQuery.js'
export interface CollectionModel extends Model<any>, PaginateModel<any> {
export interface CollectionModel
extends Model<any>,
PaginateModel<any>,
AggregatePaginateModel<any> {
/** buildQuery is used to transform payload's where operator into what can be used by mongoose (e.g. id => _id) */
buildQuery: (args: BuildQueryArgs) => Promise<Record<string, unknown>> // TODO: Delete this
}
@@ -83,6 +94,7 @@ export type FieldToSchemaMap<TSchema> = {
date: FieldGeneratorFunction<TSchema, DateField>
email: FieldGeneratorFunction<TSchema, EmailField>
group: FieldGeneratorFunction<TSchema, GroupField>
join: FieldGeneratorFunction<TSchema, JoinField>
json: FieldGeneratorFunction<TSchema, JSONField>
number: FieldGeneratorFunction<TSchema, NumberField>
point: FieldGeneratorFunction<TSchema, PointField>

View File

@@ -3,6 +3,7 @@ import type { PayloadRequest, UpdateGlobal } from 'payload'
import type { MongooseAdapter } from './index.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { withSession } from './withSession.js'
export const updateGlobal: UpdateGlobal = async function updateGlobal(
@@ -17,7 +18,14 @@ export const updateGlobal: UpdateGlobal = async function updateGlobal(
}
let result
result = await Model.findOneAndUpdate({ globalType: slug }, data, options)
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
data,
fields: this.payload.config.globals.find((global) => global.slug === slug).fields,
})
result = await Model.findOneAndUpdate({ globalType: slug }, sanitizedData, options)
result = JSON.parse(JSON.stringify(result))

View File

@@ -1,21 +1,27 @@
import type { PayloadRequest, TypeWithID, UpdateGlobalVersionArgs } from 'payload'
import {
buildVersionGlobalFields,
type PayloadRequest,
type TypeWithID,
type UpdateGlobalVersionArgs,
} from 'payload'
import type { MongooseAdapter } from './index.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { withSession } from './withSession.js'
export async function updateGlobalVersion<T extends TypeWithID>(
this: MongooseAdapter,
{
id,
global,
global: globalSlug,
locale,
req = {} as PayloadRequest,
versionData,
where,
}: UpdateGlobalVersionArgs<T>,
) {
const VersionModel = this.versions[global]
const VersionModel = this.versions[globalSlug]
const whereToUse = where || { id: { equals: id } }
const options = {
...(await withSession(this, req)),
@@ -29,7 +35,16 @@ export async function updateGlobalVersion<T extends TypeWithID>(
where: whereToUse,
})
const doc = await VersionModel.findOneAndUpdate(query, versionData, options)
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
data: versionData,
fields: buildVersionGlobalFields(
this.payload.config,
this.payload.config.globals.find((global) => global.slug === globalSlug),
),
})
const doc = await VersionModel.findOneAndUpdate(query, sanitizedData, options)
const result = JSON.parse(JSON.stringify(doc))

View File

@@ -4,6 +4,7 @@ import type { MongooseAdapter } from './index.js'
import { handleError } from './utilities/handleError.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { withSession } from './withSession.js'
export const updateOne: UpdateOne = async function updateOne(
@@ -26,8 +27,14 @@ export const updateOne: UpdateOne = async function updateOne(
let result
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
data,
fields: this.payload.collections[collection].config.fields,
})
try {
result = await Model.findOneAndUpdate(query, data, options)
result = await Model.findOneAndUpdate(query, sanitizedData, options)
} catch (error) {
handleError({ collection, error, req })
}

View File

@@ -1,7 +1,8 @@
import type { PayloadRequest, UpdateVersion } from 'payload'
import { buildVersionCollectionFields, type PayloadRequest, type UpdateVersion } from 'payload'
import type { MongooseAdapter } from './index.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
import { withSession } from './withSession.js'
export const updateVersion: UpdateVersion = async function updateVersion(
@@ -22,7 +23,16 @@ export const updateVersion: UpdateVersion = async function updateVersion(
where: whereToUse,
})
const doc = await VersionModel.findOneAndUpdate(query, versionData, options)
const sanitizedData = sanitizeRelationshipIDs({
config: this.payload.config,
data: versionData,
fields: buildVersionCollectionFields(
this.payload.config,
this.payload.collections[collection].config,
),
})
const doc = await VersionModel.findOneAndUpdate(query, sanitizedData, options)
const result = JSON.parse(JSON.stringify(doc))

View File

@@ -0,0 +1,174 @@
import type { PipelineStage } from 'mongoose'
import type { CollectionSlug, JoinQuery, SanitizedCollectionConfig, Where } from 'payload'
import type { MongooseAdapter } from '../index.js'
import { buildSortParam } from '../queries/buildSortParam.js'
type BuildJoinAggregationArgs = {
adapter: MongooseAdapter
collection: CollectionSlug
collectionConfig: SanitizedCollectionConfig
joins: JoinQuery
// the number of docs to get at the top collection level
limit?: number
locale: string
// the where clause for the top collection
query?: Where
}
export const buildJoinAggregation = async ({
adapter,
collection,
collectionConfig,
joins,
limit,
locale,
query,
}: BuildJoinAggregationArgs): Promise<PipelineStage[] | undefined> => {
if (Object.keys(collectionConfig.joins).length === 0 || joins === false) {
return
}
const joinConfig = adapter.payload.collections[collection].config.joins
const aggregate: PipelineStage[] = [
{
$sort: { createdAt: -1 },
},
]
if (query) {
aggregate.push({
$match: query,
})
}
if (limit) {
aggregate.push({
$limit: limit,
})
}
for (const slug of Object.keys(joinConfig)) {
for (const join of joinConfig[slug]) {
const joinModel = adapter.collections[join.field.collection]
const {
limit: limitJoin = 10,
sort: sortJoin,
where: whereJoin,
} = joins?.[join.schemaPath] || {}
const sort = buildSortParam({
config: adapter.payload.config,
fields: adapter.payload.collections[slug].config.fields,
locale,
sort: sortJoin || collectionConfig.defaultSort,
timestamps: true,
})
const sortProperty = Object.keys(sort)[0]
const sortDirection = sort[sortProperty] === 'asc' ? 1 : -1
const $match = await joinModel.buildQuery({
locale,
payload: adapter.payload,
where: whereJoin,
})
const pipeline: Exclude<PipelineStage, PipelineStage.Merge | PipelineStage.Out>[] = [
{ $match },
{
$sort: { [sortProperty]: sortDirection },
},
]
if (limitJoin > 0) {
pipeline.push({
$limit: limitJoin + 1,
})
}
if (adapter.payload.config.localization && locale === 'all') {
adapter.payload.config.localization.localeCodes.forEach((code) => {
const as = `${join.schemaPath}${code}`
aggregate.push(
{
$lookup: {
as: `${as}.docs`,
foreignField: `${join.field.on}${code}`,
from: slug,
localField: '_id',
pipeline,
},
},
{
$addFields: {
[`${as}.docs`]: {
$map: {
as: 'doc',
in: '$$doc._id',
input: `$${as}.docs`,
},
}, // Slicing the docs to match the limit
[`${as}.hasNextPage`]: {
$gt: [{ $size: `$${as}.docs` }, limitJoin || Number.MAX_VALUE],
}, // Boolean indicating if more docs than limit
},
},
)
if (limitJoin > 0) {
aggregate.push({
$addFields: {
[`${as}.docs`]: {
$slice: [`$${as}.docs`, limitJoin],
},
},
})
}
})
} else {
const localeSuffix =
join.field.localized && adapter.payload.config.localization && locale ? `.${locale}` : ''
const as = `${join.schemaPath}${localeSuffix}`
aggregate.push(
{
$lookup: {
as: `${as}.docs`,
foreignField: `${join.field.on}${localeSuffix}`,
from: slug,
localField: '_id',
pipeline,
},
},
{
$addFields: {
[`${as}.docs`]: {
$map: {
as: 'doc',
in: '$$doc._id',
input: `$${as}.docs`,
},
}, // Slicing the docs to match the limit
[`${as}.hasNextPage`]: {
$gt: [{ $size: `$${as}.docs` }, limitJoin || Number.MAX_VALUE],
}, // Boolean indicating if more docs than limit
},
},
)
if (limitJoin > 0) {
aggregate.push({
$addFields: {
[`${as}.docs`]: {
$slice: [`$${as}.docs`, limitJoin],
},
},
})
}
}
}
}
return aggregate
}

View File

@@ -0,0 +1,140 @@
import type { CollectionConfig, Field, SanitizedConfig, TraverseFieldsCallback } from 'payload'
import mongoose from 'mongoose'
import { traverseFields } from 'payload'
import { fieldAffectsData } from 'payload/shared'
type Args = {
config: SanitizedConfig
data: Record<string, unknown>
fields: Field[]
}
interface RelationObject {
relationTo: string
value: number | string
}
function isValidRelationObject(value: unknown): value is RelationObject {
return typeof value === 'object' && value !== null && 'relationTo' in value && 'value' in value
}
const convertValue = ({
relatedCollection,
value,
}: {
relatedCollection: CollectionConfig
value: number | string
}): mongoose.Types.ObjectId | number | string => {
const customIDField = relatedCollection.fields.find(
(field) => fieldAffectsData(field) && field.name === 'id',
)
if (!customIDField) {
return new mongoose.Types.ObjectId(value)
}
return value
}
const sanitizeRelationship = ({ config, field, locale, ref, value }) => {
let relatedCollection: CollectionConfig | undefined
let result = value
const hasManyRelations = typeof field.relationTo !== 'string'
if (!hasManyRelations) {
relatedCollection = config.collections?.find(({ slug }) => slug === field.relationTo)
}
if (Array.isArray(value)) {
result = value.map((val) => {
// Handle has many
if (relatedCollection && val && (typeof val === 'string' || typeof val === 'number')) {
return convertValue({
relatedCollection,
value: val,
})
}
// Handle has many - polymorphic
if (isValidRelationObject(val)) {
const relatedCollectionForSingleValue = config.collections?.find(
({ slug }) => slug === val.relationTo,
)
if (relatedCollectionForSingleValue) {
return {
relationTo: val.relationTo,
value: convertValue({
relatedCollection: relatedCollectionForSingleValue,
value: val.value,
}),
}
}
}
return val
})
}
// Handle has one - polymorphic
if (isValidRelationObject(value)) {
relatedCollection = config.collections?.find(({ slug }) => slug === value.relationTo)
if (relatedCollection) {
result = {
relationTo: value.relationTo,
value: convertValue({ relatedCollection, value: value.value }),
}
}
}
// Handle has one
if (relatedCollection && value && (typeof value === 'string' || typeof value === 'number')) {
result = convertValue({
relatedCollection,
value,
})
}
if (locale) {
ref[locale] = result
} else {
ref[field.name] = result
}
}
export const sanitizeRelationshipIDs = ({
config,
data,
fields,
}: Args): Record<string, unknown> => {
const sanitize: TraverseFieldsCallback = ({ field, ref }) => {
if (field.type === 'relationship' || field.type === 'upload') {
// handle localized relationships
if (config.localization && field.localized) {
const locales = config.localization.locales
const fieldRef = ref[field.name]
for (const { code } of locales) {
if (ref[field.name]?.[code]) {
const value = ref[field.name][code]
sanitizeRelationship({ config, field, locale: code, ref: fieldRef, value })
}
}
} else {
// handle non-localized relationships
sanitizeRelationship({
config,
field,
locale: undefined,
ref,
value: ref[field.name],
})
}
}
}
traverseFields({ callback: sanitize, fields, ref: data })
return data
}

View File

@@ -10,7 +10,7 @@ import type { MongooseAdapter } from './index.js'
export async function withSession(
db: MongooseAdapter,
req: PayloadRequest,
): Promise<{ session: ClientSession } | object> {
): Promise<{ session: ClientSession } | Record<string, never>> {
let transactionID = req.transactionID
if (transactionID instanceof Promise) {

View File

@@ -35,7 +35,15 @@ export type BaseExtraConfig = Record<
}) => ForeignKeyBuilder | IndexBuilder | UniqueConstraintBuilder
>
export type RelationMap = Map<string, { localized: boolean; target: string; type: 'many' | 'one' }>
export type RelationMap = Map<
string,
{
localized: boolean
relationName?: string
target: string
type: 'many' | 'one'
}
>
type Args = {
adapter: SQLiteAdapter
@@ -144,9 +152,9 @@ export const buildTable = ({
const localizedRelations = new Map()
const nonLocalizedRelations = new Map()
relationsToBuild.forEach(({ type, localized, target }, key) => {
relationsToBuild.forEach(({ type, localized, relationName, target }, key) => {
const map = localized ? localizedRelations : nonLocalizedRelations
map.set(key, { type, target })
map.set(key, { type, relationName, target })
})
if (timestamps) {
@@ -458,7 +466,7 @@ export const buildTable = ({
adapter.relations[`relations_${tableName}`] = relations(table, ({ many, one }) => {
const result: Record<string, Relation<string>> = {}
nonLocalizedRelations.forEach(({ type, target }, key) => {
nonLocalizedRelations.forEach(({ type, relationName, target }, key) => {
if (type === 'one') {
result[key] = one(adapter.tables[target], {
fields: [table[key]],
@@ -467,7 +475,7 @@ export const buildTable = ({
})
}
if (type === 'many') {
result[key] = many(adapter.tables[target], { relationName: key })
result[key] = many(adapter.tables[target], { relationName: relationName || key })
}
})

View File

@@ -898,6 +898,21 @@ export const traverseFields = ({
break
case 'join': {
// fieldName could be 'posts' or 'group_posts'
// using on as the key for the relation
const localized = adapter.payload.config.localization && field.localized
const target = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${localized ? adapter.localesSuffix : ''}`
relationsToBuild.set(fieldName, {
type: 'many',
// joins are not localized on the parent table
localized: false,
relationName: toSnakeCase(field.on),
target,
})
break
}
default:
break
}

View File

@@ -16,7 +16,7 @@ export const count: Count = async function count(
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const { joins, where } = await buildQuery({
const { joins, where } = buildQuery({
adapter: this,
fields: collectionConfig.fields,
locale,

View File

@@ -12,7 +12,7 @@ import { transform } from './transform/read/index.js'
export const deleteOne: DeleteOne = async function deleteOne(
this: DrizzleAdapter,
{ collection: collectionSlug, req = {} as PayloadRequest, where: whereArg },
{ collection: collectionSlug, joins: joinQuery, req = {} as PayloadRequest, where: whereArg },
) {
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const collection = this.payload.collections[collectionSlug].config
@@ -21,7 +21,7 @@ export const deleteOne: DeleteOne = async function deleteOne(
let docToDelete: Record<string, unknown>
const { joins, selectFields, where } = await buildQuery({
const { joins, selectFields, where } = buildQuery({
adapter: this,
fields: collection.fields,
locale: req.locale,
@@ -48,6 +48,7 @@ export const deleteOne: DeleteOne = async function deleteOne(
adapter: this,
depth: 0,
fields: collection.fields,
joinQuery,
tableName,
})
@@ -61,6 +62,7 @@ export const deleteOne: DeleteOne = async function deleteOne(
config: this.payload.config,
data: docToDelete,
fields: collection.fields,
joinQuery,
})
await this.deleteWhere({

View File

@@ -10,6 +10,7 @@ export const find: Find = async function find(
this: DrizzleAdapter,
{
collection,
joins,
limit,
locale,
page = 1,
@@ -27,6 +28,7 @@ export const find: Find = async function find(
return findMany({
adapter: this,
fields: collectionConfig.fields,
joins,
limit,
locale,
page,

View File

@@ -1,7 +1,7 @@
import type { DBQueryConfig } from 'drizzle-orm'
import type { Field } from 'payload'
import type { Field, JoinQuery } from 'payload'
import type { DrizzleAdapter } from '../types.js'
import type { BuildQueryJoinAliases, DrizzleAdapter } from '../types.js'
import { traverseFields } from './traverseFields.js'
@@ -9,6 +9,12 @@ type BuildFindQueryArgs = {
adapter: DrizzleAdapter
depth: number
fields: Field[]
joinQuery?: JoinQuery
/**
* The joins array will be mutated by pushing any joins needed for the where queries of join field joins
*/
joins?: BuildQueryJoinAliases
locale?: string
tableName: string
}
@@ -24,6 +30,9 @@ export const buildFindManyArgs = ({
adapter,
depth,
fields,
joinQuery,
joins = [],
locale,
tableName,
}: BuildFindQueryArgs): Record<string, unknown> => {
const result: Result = {
@@ -79,6 +88,9 @@ export const buildFindManyArgs = ({
currentTableName: tableName,
depth,
fields,
joinQuery,
joins,
locale,
path: '',
tablePath: '',
topLevelArgs: result,

View File

@@ -19,6 +19,7 @@ type Args = {
export const findMany = async function find({
adapter,
fields,
joins: joinQuery,
limit: limitArg,
locale,
page = 1,
@@ -42,7 +43,7 @@ export const findMany = async function find({
limit = undefined
}
const { joins, orderBy, selectFields, where } = await buildQuery({
const { joins, orderBy, selectFields, where } = buildQuery({
adapter,
fields,
locale,
@@ -67,6 +68,8 @@ export const findMany = async function find({
adapter,
depth: 0,
fields,
joinQuery,
joins,
tableName,
})
@@ -151,6 +154,7 @@ export const findMany = async function find({
config: adapter.payload.config,
data,
fields,
joinQuery,
})
})

View File

@@ -1,11 +1,15 @@
import type { Field } from 'payload'
import type { DBQueryConfig } from 'drizzle-orm'
import type { Field, JoinQuery } from 'payload'
import { fieldAffectsData, fieldIsVirtual, tabHasName } from 'payload/shared'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from '../types.js'
import type { BuildQueryJoinAliases, DrizzleAdapter } from '../types.js'
import type { Result } from './buildFindManyArgs.js'
import { buildOrderBy } from '../queries/buildOrderBy.js'
import buildQuery from '../queries/buildQuery.js'
type TraverseFieldArgs = {
_locales: Result
adapter: DrizzleAdapter
@@ -13,6 +17,9 @@ type TraverseFieldArgs = {
currentTableName: string
depth?: number
fields: Field[]
joinQuery: JoinQuery
joins?: BuildQueryJoinAliases
locale?: string
path: string
tablePath: string
topLevelArgs: Record<string, unknown>
@@ -26,6 +33,9 @@ export const traverseFields = ({
currentTableName,
depth,
fields,
joinQuery = {},
joins,
locale,
path,
tablePath,
topLevelArgs,
@@ -58,6 +68,8 @@ export const traverseFields = ({
currentTableName,
depth,
fields: field.fields,
joinQuery,
joins,
path,
tablePath,
topLevelArgs,
@@ -79,6 +91,8 @@ export const traverseFields = ({
currentTableName,
depth,
fields: tab.fields,
joinQuery,
joins,
path: tabPath,
tablePath: tabTablePath,
topLevelArgs,
@@ -124,6 +138,7 @@ export const traverseFields = ({
currentTableName: arrayTableName,
depth,
fields: field.fields,
joinQuery,
path: '',
tablePath: '',
topLevelArgs,
@@ -181,6 +196,7 @@ export const traverseFields = ({
currentTableName: tableName,
depth,
fields: block.fields,
joinQuery,
path: '',
tablePath: '',
topLevelArgs,
@@ -199,6 +215,8 @@ export const traverseFields = ({
currentTableName,
depth,
fields: field.fields,
joinQuery,
joins,
path: `${path}${field.name}_`,
tablePath: `${tablePath}${toSnakeCase(field.name)}_`,
topLevelArgs,
@@ -208,6 +226,67 @@ export const traverseFields = ({
break
}
case 'join': {
// when `joinsQuery` is false, do not join
if (joinQuery === false) {
break
}
const {
limit: limitArg = 10,
sort,
where,
} = joinQuery[`${path.replaceAll('_', '.')}${field.name}`] || {}
let limit = limitArg
if (limit !== 0) {
// get an additional document and slice it later to determine if there is a next page
limit += 1
}
const fields = adapter.payload.collections[field.collection].config.fields
const joinTableName = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${
field.localized && adapter.payload.config.localization ? adapter.localesSuffix : ''
}`
const selectFields = {}
const orderBy = buildOrderBy({
adapter,
fields,
joins: [],
locale,
selectFields,
sort,
tableName: joinTableName,
})
const withJoin: DBQueryConfig<'many', true, any, any> = {
columns: selectFields,
orderBy: () => [orderBy.order(orderBy.column)],
}
if (limit) {
withJoin.limit = limit
}
if (field.localized) {
withJoin.columns._locale = true
withJoin.columns._parentID = true
} else {
withJoin.columns.id = true
}
if (where) {
const { where: joinWhere } = buildQuery({
adapter,
fields,
joins,
locale,
sort,
tableName: joinTableName,
where,
})
withJoin.where = () => joinWhere
}
currentArgs.with[`${path.replaceAll('.', '_')}${field.name}`] = withJoin
break
}
default: {
break
}

View File

@@ -8,7 +8,7 @@ import { findMany } from './find/findMany.js'
export async function findOne<T extends TypeWithID>(
this: DrizzleAdapter,
{ collection, locale, req = {} as PayloadRequest, where }: FindOneArgs,
{ collection, joins, locale, req = {} as PayloadRequest, where }: FindOneArgs,
): Promise<T> {
const collectionConfig: SanitizedCollectionConfig = this.payload.collections[collection].config
@@ -17,6 +17,7 @@ export async function findOne<T extends TypeWithID>(
const { docs } = await findMany({
adapter: this,
fields: collectionConfig.fields,
joins,
limit: 1,
locale,
page: 1,

View File

@@ -138,9 +138,9 @@ export const buildTable = ({
const localizedRelations = new Map()
const nonLocalizedRelations = new Map()
relationsToBuild.forEach(({ type, localized, target }, key) => {
relationsToBuild.forEach(({ type, localized, relationName, target }, key) => {
const map = localized ? localizedRelations : nonLocalizedRelations
map.set(key, { type, target })
map.set(key, { type, relationName, target })
})
if (timestamps) {
@@ -444,7 +444,7 @@ export const buildTable = ({
adapter.relations[`relations_${tableName}`] = relations(table, ({ many, one }) => {
const result: Record<string, Relation<string>> = {}
nonLocalizedRelations.forEach(({ type, target }, key) => {
nonLocalizedRelations.forEach(({ type, relationName, target }, key) => {
if (type === 'one') {
result[key] = one(adapter.tables[target], {
fields: [table[key]],
@@ -453,7 +453,7 @@ export const buildTable = ({
})
}
if (type === 'many') {
result[key] = many(adapter.tables[target], { relationName: key })
result[key] = many(adapter.tables[target], { relationName: relationName || key })
}
})

View File

@@ -906,6 +906,21 @@ export const traverseFields = ({
break
case 'join': {
// fieldName could be 'posts' or 'group_posts'
// using on as the key for the relation
const localized = adapter.payload.config.localization && field.localized
const target = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${localized ? adapter.localesSuffix : ''}`
relationsToBuild.set(fieldName, {
type: 'many',
// joins are not localized on the parent table
localized: false,
relationName: toSnakeCase(field.on),
target,
})
break
}
default:
break
}

View File

@@ -31,7 +31,15 @@ export type BaseExtraConfig = Record<
(cols: GenericColumns) => ForeignKeyBuilder | IndexBuilder | UniqueConstraintBuilder
>
export type RelationMap = Map<string, { localized: boolean; target: string; type: 'many' | 'one' }>
export type RelationMap = Map<
string,
{
localized: boolean
relationName?: string
target: string
type: 'many' | 'one'
}
>
export type GenericColumn = PgColumn<
ColumnBaseConfig<ColumnDataType, string>,

View File

@@ -6,7 +6,7 @@ import type { BuildQueryJoinAliases } from './buildQuery.js'
import { parseParams } from './parseParams.js'
export async function buildAndOrConditions({
export function buildAndOrConditions({
adapter,
fields,
joins,
@@ -24,7 +24,7 @@ export async function buildAndOrConditions({
selectFields: Record<string, GenericColumn>
tableName: string
where: Where[]
}): Promise<SQL[]> {
}): SQL[] {
const completedConditions = []
// Loop over all AND / OR operations and add them to the AND / OR query param
// Operations should come through as an array
@@ -32,7 +32,7 @@ export async function buildAndOrConditions({
for (const condition of where) {
// If the operation is properly formatted as an object
if (typeof condition === 'object') {
const result = await parseParams({
const result = parseParams({
adapter,
fields,
joins,

View File

@@ -0,0 +1,82 @@
import type { Field } from 'payload'
import { asc, desc } from 'drizzle-orm'
import type { DrizzleAdapter, GenericColumn } from '../types.js'
import type { BuildQueryJoinAliases, BuildQueryResult } from './buildQuery.js'
import { getTableColumnFromPath } from './getTableColumnFromPath.js'
type Args = {
adapter: DrizzleAdapter
fields: Field[]
joins: BuildQueryJoinAliases
locale?: string
selectFields: Record<string, GenericColumn>
sort?: string
tableName: string
}
/**
* Gets the order by column and direction constructed from the sort argument adds the column to the select fields and joins if necessary
*/
export const buildOrderBy = ({
adapter,
fields,
joins,
locale,
selectFields,
sort,
tableName,
}: Args): BuildQueryResult['orderBy'] => {
const orderBy: BuildQueryResult['orderBy'] = {
column: null,
order: null,
}
if (sort) {
let sortPath
if (sort[0] === '-') {
sortPath = sort.substring(1)
orderBy.order = desc
} else {
sortPath = sort
orderBy.order = asc
}
try {
const { columnName: sortTableColumnName, table: sortTable } = getTableColumnFromPath({
adapter,
collectionPath: sortPath,
fields,
joins,
locale,
pathSegments: sortPath.replace(/__/g, '.').split('.'),
selectFields,
tableName,
value: sortPath,
})
orderBy.column = sortTable?.[sortTableColumnName]
} catch (err) {
// continue
}
}
if (!orderBy?.column) {
orderBy.order = desc
const createdAt = adapter.tables[tableName]?.createdAt
if (createdAt) {
orderBy.column = createdAt
} else {
orderBy.column = adapter.tables[tableName].id
}
}
if (orderBy.column) {
selectFields.sort = orderBy.column
}
return orderBy
}

View File

@@ -1,12 +1,10 @@
import type { SQL } from 'drizzle-orm'
import type { asc, desc, SQL } from 'drizzle-orm'
import type { PgTableWithColumns } from 'drizzle-orm/pg-core'
import type { Field, Where } from 'payload'
import { asc, desc } from 'drizzle-orm'
import type { DrizzleAdapter, GenericColumn, GenericTable } from '../types.js'
import { getTableColumnFromPath } from './getTableColumnFromPath.js'
import { buildOrderBy } from './buildOrderBy.js'
import { parseParams } from './parseParams.js'
export type BuildQueryJoinAliases = {
@@ -17,13 +15,14 @@ export type BuildQueryJoinAliases = {
type BuildQueryArgs = {
adapter: DrizzleAdapter
fields: Field[]
joins?: BuildQueryJoinAliases
locale?: string
sort?: string
tableName: string
where: Where
}
type Result = {
export type BuildQueryResult = {
joins: BuildQueryJoinAliases
orderBy: {
column: GenericColumn
@@ -32,72 +31,33 @@ type Result = {
selectFields: Record<string, GenericColumn>
where: SQL
}
const buildQuery = async function buildQuery({
const buildQuery = function buildQuery({
adapter,
fields,
joins = [],
locale,
sort,
tableName,
where: incomingWhere,
}: BuildQueryArgs): Promise<Result> {
}: BuildQueryArgs): BuildQueryResult {
const selectFields: Record<string, GenericColumn> = {
id: adapter.tables[tableName].id,
}
const joins: BuildQueryJoinAliases = []
const orderBy: Result['orderBy'] = {
column: null,
order: null,
}
if (sort) {
let sortPath
if (sort[0] === '-') {
sortPath = sort.substring(1)
orderBy.order = desc
} else {
sortPath = sort
orderBy.order = asc
}
try {
const { columnName: sortTableColumnName, table: sortTable } = getTableColumnFromPath({
const orderBy = buildOrderBy({
adapter,
collectionPath: sortPath,
fields,
joins,
locale,
pathSegments: sortPath.replace(/__/g, '.').split('.'),
selectFields,
sort,
tableName,
value: sortPath,
})
orderBy.column = sortTable?.[sortTableColumnName]
} catch (err) {
// continue
}
}
if (!orderBy?.column) {
orderBy.order = desc
const createdAt = adapter.tables[tableName]?.createdAt
if (createdAt) {
orderBy.column = createdAt
} else {
orderBy.column = adapter.tables[tableName].id
}
}
if (orderBy.column) {
selectFields.sort = orderBy.column
}
let where: SQL
if (incomingWhere && Object.keys(incomingWhere).length > 0) {
where = await parseParams({
where = parseParams({
adapter,
fields,
joins,

View File

@@ -22,7 +22,7 @@ type Args = {
where: Where
}
export async function parseParams({
export function parseParams({
adapter,
fields,
joins,
@@ -30,7 +30,7 @@ export async function parseParams({
selectFields,
tableName,
where,
}: Args): Promise<SQL> {
}: Args): SQL {
let result: SQL
const constraints: SQL[] = []
@@ -46,7 +46,7 @@ export async function parseParams({
conditionOperator = or
}
if (Array.isArray(condition)) {
const builtConditions = await buildAndOrConditions({
const builtConditions = buildAndOrConditions({
adapter,
fields,
joins,

View File

@@ -1,4 +1,4 @@
import type { Field, SanitizedConfig, TypeWithID } from 'payload'
import type { Field, JoinQuery, SanitizedConfig, TypeWithID } from 'payload'
import type { DrizzleAdapter } from '../../types.js'
@@ -12,6 +12,7 @@ type TransformArgs = {
data: Record<string, unknown>
fallbackLocale?: false | string
fields: Field[]
joinQuery?: JoinQuery
locale?: string
}
@@ -22,6 +23,7 @@ export const transform = <T extends Record<string, unknown> | TypeWithID>({
config,
data,
fields,
joinQuery,
}: TransformArgs): T => {
let relationships: Record<string, Record<string, unknown>[]> = {}
let texts: Record<string, Record<string, unknown>[]> = {}
@@ -55,6 +57,7 @@ export const transform = <T extends Record<string, unknown> | TypeWithID>({
deletions,
fieldPrefix: '',
fields,
joinQuery,
numbers,
path: '',
relationships,

View File

@@ -1,4 +1,4 @@
import type { Field, SanitizedConfig, TabAsField } from 'payload'
import type { Field, JoinQuery, SanitizedConfig, TabAsField } from 'payload'
import { fieldAffectsData, fieldIsVirtual } from 'payload/shared'
@@ -38,6 +38,10 @@ type TraverseFieldsArgs = {
* An array of Payload fields to traverse
*/
fields: (Field | TabAsField)[]
/**
*
*/
joinQuery?: JoinQuery
/**
* All hasMany number fields, as returned by Drizzle, keyed on an object by field path
*/
@@ -74,6 +78,7 @@ export const traverseFields = <T extends Record<string, unknown>>({
deletions,
fieldPrefix,
fields,
joinQuery,
numbers,
path,
relationships,
@@ -93,6 +98,7 @@ export const traverseFields = <T extends Record<string, unknown>>({
deletions,
fieldPrefix,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
joinQuery,
numbers,
path,
relationships,
@@ -115,6 +121,7 @@ export const traverseFields = <T extends Record<string, unknown>>({
deletions,
fieldPrefix,
fields: field.fields,
joinQuery,
numbers,
path,
relationships,
@@ -390,6 +397,44 @@ export const traverseFields = <T extends Record<string, unknown>>({
}
}
if (field.type === 'join') {
const { limit = 10 } = joinQuery?.[`${fieldPrefix.replaceAll('_', '.')}${field.name}`] || {}
let fieldResult:
| { docs: unknown[]; hasNextPage: boolean }
| Record<string, { docs: unknown[]; hasNextPage: boolean }>
if (Array.isArray(fieldData)) {
if (field.localized) {
fieldResult = fieldData.reduce((joinResult, row) => {
if (typeof row._locale === 'string') {
if (!joinResult[row._locale]) {
joinResult[row._locale] = {
docs: [],
hasNextPage: false,
}
}
joinResult[row._locale].docs.push(row._parentID)
}
return joinResult
}, {})
Object.keys(fieldResult).forEach((locale) => {
fieldResult[locale].hasNextPage = fieldResult[locale].docs.length > limit
fieldResult[locale].docs = fieldResult[locale].docs.slice(0, limit)
})
} else {
const hasNextPage = limit !== 0 && fieldData.length > limit
fieldResult = {
docs: hasNextPage ? fieldData.slice(0, limit) : fieldData,
hasNextPage,
}
}
}
result[field.name] = fieldResult
return result
}
if (field.type === 'text' && field?.hasMany) {
const textPathMatch = texts[`${sanitizedPath}${field.name}`]
if (!textPathMatch) {

View File

@@ -10,7 +10,7 @@ import { upsertRow } from './upsertRow/index.js'
export const updateOne: UpdateOne = async function updateOne(
this: DrizzleAdapter,
{ id, collection: collectionSlug, data, draft, locale, req, where: whereArg },
{ id, collection: collectionSlug, data, draft, joins: joinQuery, locale, req, where: whereArg },
) {
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const collection = this.payload.collections[collectionSlug].config
@@ -18,7 +18,7 @@ export const updateOne: UpdateOne = async function updateOne(
const whereToUse = whereArg || { id: { equals: id } }
let idToUpdate = id
const { joins, selectFields, where } = await buildQuery({
const { joins, selectFields, where } = buildQuery({
adapter: this,
fields: collection.fields,
locale,
@@ -46,6 +46,7 @@ export const updateOne: UpdateOne = async function updateOne(
data,
db,
fields: collection.fields,
joinQuery,
operation: 'update',
req,
tableName,

View File

@@ -37,7 +37,7 @@ export async function updateGlobalVersion<T extends TypeWithID>(
const fields = buildVersionGlobalFields(this.payload.config, globalConfig)
const { where } = await buildQuery({
const { where } = buildQuery({
adapter: this,
fields,
locale,

View File

@@ -34,7 +34,7 @@ export async function updateVersion<T extends TypeWithID>(
const fields = buildVersionCollectionFields(this.payload.config, collectionConfig)
const { where } = await buildQuery({
const { where } = buildQuery({
adapter: this,
fields,
locale,

View File

@@ -20,6 +20,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
db,
fields,
ignoreResult,
joinQuery,
operation,
path = '',
req,
@@ -413,6 +414,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
adapter,
depth: 0,
fields,
joinQuery,
tableName,
})
@@ -429,6 +431,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
config: adapter.payload.config,
data: doc,
fields,
joinQuery,
})
return result

View File

@@ -1,5 +1,5 @@
import type { SQL } from 'drizzle-orm'
import type { Field, PayloadRequest } from 'payload'
import type { Field, JoinQuery, PayloadRequest } from 'payload'
import type { DrizzleAdapter, DrizzleTransaction, GenericColumn } from '../types.js'
@@ -13,6 +13,7 @@ type BaseArgs = {
* @default false
*/
ignoreResult?: boolean
joinQuery?: JoinQuery
path?: string
req: PayloadRequest
tableName: string
@@ -20,6 +21,7 @@ type BaseArgs = {
type CreateArgs = {
id?: never
joinQuery?: never
operation: 'create'
upsertTarget?: never
where?: never
@@ -27,6 +29,7 @@ type CreateArgs = {
type UpdateArgs = {
id?: number | string
joinQuery?: JoinQuery
operation: 'update'
upsertTarget?: GenericColumn
where?: SQL<unknown>

View File

@@ -10,6 +10,7 @@ import type {
Field,
GraphQLInfo,
GroupField,
JoinField,
JSONField,
NumberField,
PointField,
@@ -38,7 +39,7 @@ import {
GraphQLUnionType,
} from 'graphql'
import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars'
import { createDataloaderCacheKey, MissingEditorProp, toWords } from 'payload'
import { combineQueries, createDataloaderCacheKey, MissingEditorProp, toWords } from 'payload'
import { tabHasName } from 'payload/shared'
import type { Context } from '../resolvers/types.js'
@@ -209,7 +210,69 @@ export function buildObjectType({
return {
...objectTypeConfig,
[field.name]: { type: graphqlResult.types.groupTypes[interfaceName] },
[field.name]: {
type: graphqlResult.types.groupTypes[interfaceName],
resolve: (parent, args, context: Context) => {
return {
...parent[field.name],
_id: parent._id ?? parent.id,
}
},
},
}
},
join: (objectTypeConfig: ObjectTypeConfig, field: JoinField) => {
const joinName = combineParentName(parentName, toWords(field.name, true))
const joinType = {
type: new GraphQLObjectType({
name: joinName,
fields: {
docs: {
type: new GraphQLList(graphqlResult.collections[field.collection].graphQL.type),
},
hasNextPage: { type: GraphQLBoolean },
},
}),
args: {
limit: {
type: GraphQLInt,
},
sort: {
type: GraphQLString,
},
where: {
type: graphqlResult.collections[field.collection].graphQL.whereInputType,
},
},
extensions: { complexity: 10 },
async resolve(parent, args, context: Context) {
const { collection } = field
const { limit, sort, where } = args
const { req } = context
const fullWhere = combineQueries(where, {
[field.on]: { equals: parent._id ?? parent.id },
})
const results = await req.payload.find({
collection,
depth: 0,
fallbackLocale: req.fallbackLocale,
limit,
locale: req.locale,
req,
sort,
where: fullWhere,
})
return results
},
}
return {
...objectTypeConfig,
[field.name]: joinType,
}
},
json: (objectTypeConfig: ObjectTypeConfig, field: JSONField) => ({
@@ -577,7 +640,15 @@ export function buildObjectType({
return {
...tabSchema,
[tab.name]: { type: graphqlResult.types.groupTypes[interfaceName] },
[tab.name]: {
type: graphqlResult.types.groupTypes[interfaceName],
resolve(parent, args, context: Context) {
return {
...parent[tab.name],
_id: parent._id ?? parent.id,
}
},
},
}
}

View File

@@ -1,4 +1,4 @@
import type { Where } from 'payload'
import type { JoinQuery, Where } from 'payload'
import httpStatus from 'http-status'
import { findOperation } from 'payload'
@@ -7,11 +7,13 @@ import { isNumber } from 'payload/shared'
import type { CollectionRouteHandler } from '../types.js'
import { headersWithCors } from '../../../utilities/headersWithCors.js'
import { sanitizeJoinParams } from '../utilities/sanitizeJoinParams.js'
export const find: CollectionRouteHandler = async ({ collection, req }) => {
const { depth, draft, limit, page, sort, where } = req.query as {
const { depth, draft, joins, limit, page, sort, where } = req.query as {
depth?: string
draft?: string
joins?: JoinQuery
limit?: string
page?: string
sort?: string
@@ -22,6 +24,7 @@ export const find: CollectionRouteHandler = async ({ collection, req }) => {
collection,
depth: isNumber(depth) ? Number(depth) : undefined,
draft: draft === 'true',
joins: sanitizeJoinParams(joins),
limit: isNumber(limit) ? Number(limit) : undefined,
page: isNumber(page) ? Number(page) : undefined,
req,

View File

@@ -1,3 +1,5 @@
import type { JoinQuery } from 'payload'
import httpStatus from 'http-status'
import { findByIDOperation } from 'payload'
import { isNumber } from 'payload/shared'
@@ -6,6 +8,7 @@ import type { CollectionRouteHandlerWithID } from '../types.js'
import { headersWithCors } from '../../../utilities/headersWithCors.js'
import { sanitizeCollectionID } from '../utilities/sanitizeCollectionID.js'
import { sanitizeJoinParams } from '../utilities/sanitizeJoinParams.js'
export const findByID: CollectionRouteHandlerWithID = async ({
id: incomingID,
@@ -26,6 +29,7 @@ export const findByID: CollectionRouteHandlerWithID = async ({
collection,
depth: isNumber(depth) ? Number(depth) : undefined,
draft: searchParams.get('draft') === 'true',
joins: sanitizeJoinParams(req.query.joins as JoinQuery),
req,
})

View File

@@ -0,0 +1,31 @@
import type { JoinQuery } from 'payload'
import { isNumber } from 'payload/shared'
/**
* Convert request JoinQuery object from strings to numbers
* @param joins
*/
export const sanitizeJoinParams = (
joins:
| {
[schemaPath: string]: {
limit?: unknown
sort?: string
where?: unknown
}
}
| false = {},
): JoinQuery => {
const joinQuery = {}
Object.keys(joins).forEach((schemaPath) => {
joinQuery[schemaPath] = {
limit: isNumber(joins[schemaPath]?.limit) ? Number(joins[schemaPath].limit) : undefined,
sort: joins[schemaPath]?.sort ? joins[schemaPath].sort : undefined,
where: joins[schemaPath]?.where ? joins[schemaPath].where : undefined,
}
})
return joinQuery
}

View File

@@ -28,14 +28,14 @@ export const buildVersionColumns = ({
const columns: Column[] = [
{
Heading: <SortColumn Label={t('general:updatedAt')} name="updatedAt" />,
accessor: 'updatedAt',
active: true,
cellProps: {
field: {
name: '',
type: 'date',
},
},
admin: {
components: {
Cell: {
type: 'client',
@@ -48,28 +48,38 @@ export const buildVersionColumns = ({
/>
),
},
Heading: <SortColumn Label={t('general:updatedAt')} name="updatedAt" />,
Label: {
type: 'client',
Component: null,
},
},
},
},
},
Label: '',
},
{
Heading: <SortColumn Label={t('version:versionID')} disable name="id" />,
accessor: 'id',
active: true,
cellProps: {
field: {
name: '',
type: 'text',
},
},
admin: {
components: {
Cell: {
type: 'client',
Component: null,
RenderedComponent: <IDCell />,
},
Heading: <SortColumn disable Label={t('version:versionID')} name="id" />,
Label: {
type: 'client',
Component: null,
},
},
},
},
},
Label: '',
},
]
@@ -78,14 +88,14 @@ export const buildVersionColumns = ({
(entityConfig?.versions?.drafts && entityConfig.versions.drafts?.autosave)
) {
columns.push({
Heading: <SortColumn Label={t('version:status')} disable name="status" />,
accessor: '_status',
active: true,
cellProps: {
field: {
name: '',
type: 'checkbox',
},
},
admin: {
components: {
Cell: {
type: 'client',
@@ -97,10 +107,14 @@ export const buildVersionColumns = ({
/>
),
},
Heading: <SortColumn disable Label={t('version:status')} name="status" />,
Label: {
type: 'client',
Component: null,
},
},
},
},
},
Label: '',
})
}

View File

@@ -0,0 +1,39 @@
import type { MarkOptional } from 'ts-essentials'
import type { JoinField, JoinFieldClient } from '../../fields/config/types.js'
import type { FieldErrorClientComponent, FieldErrorServerComponent } from '../forms/Error.js'
import type {
ClientFieldBase,
FieldClientComponent,
FieldServerComponent,
ServerFieldBase,
} from '../forms/Field.js'
import type {
FieldDescriptionClientComponent,
FieldDescriptionServerComponent,
FieldLabelClientComponent,
FieldLabelServerComponent,
} from '../types.js'
type JoinFieldClientWithoutType = MarkOptional<JoinFieldClient, 'type'>
export type JoinFieldClientProps = ClientFieldBase<JoinFieldClientWithoutType>
export type JoinFieldServerProps = ServerFieldBase<JoinField>
export type JoinFieldServerComponent = FieldServerComponent<JoinField>
export type JoinFieldClientComponent = FieldClientComponent<JoinFieldClientWithoutType>
export type JoinFieldLabelServerComponent = FieldLabelServerComponent<JoinField>
export type JoinFieldLabelClientComponent = FieldLabelClientComponent<JoinFieldClientWithoutType>
export type JoinFieldDescriptionServerComponent = FieldDescriptionServerComponent<JoinField>
export type JoinFieldDescriptionClientComponent =
FieldDescriptionClientComponent<JoinFieldClientWithoutType>
export type JoinFieldErrorServerComponent = FieldErrorServerComponent<JoinField>
export type JoinFieldErrorClientComponent = FieldErrorClientComponent<JoinFieldClientWithoutType>

View File

@@ -129,6 +129,19 @@ export type {
export type { HiddenFieldProps } from './fields/Hidden.js'
export type {
JoinFieldClientComponent,
JoinFieldClientProps,
JoinFieldDescriptionClientComponent,
JoinFieldDescriptionServerComponent,
JoinFieldErrorClientComponent,
JoinFieldErrorServerComponent,
JoinFieldLabelClientComponent,
JoinFieldLabelServerComponent,
JoinFieldServerComponent,
JoinFieldServerProps,
} from './fields/Join.js'
export type {
JSONFieldClientComponent,
JSONFieldClientProps,

View File

@@ -6,7 +6,7 @@ import type { SanitizedCollectionConfig } from './types.js'
export type ServerOnlyCollectionProperties = keyof Pick<
SanitizedCollectionConfig,
'access' | 'custom' | 'endpoints' | 'hooks'
'access' | 'custom' | 'endpoints' | 'hooks' | 'joins'
>
export type ServerOnlyCollectionAdminProperties = keyof Pick<
@@ -58,7 +58,7 @@ export type ClientCollectionConfig = {
livePreview?: Omit<LivePreviewConfig, ServerOnlyLivePreviewProperties>
} & Omit<
SanitizedCollectionConfig['admin'],
'components' | 'description' | 'livePreview' | ServerOnlyCollectionAdminProperties
'components' | 'description' | 'joins' | 'livePreview' | ServerOnlyCollectionAdminProperties
>
fields: ClientField[]
} & Omit<SanitizedCollectionConfig, 'admin' | 'fields' | ServerOnlyCollectionProperties>

View File

@@ -1,6 +1,6 @@
import type { LoginWithUsernameOptions } from '../../auth/types.js'
import type { Config, SanitizedConfig } from '../../config/types.js'
import type { CollectionConfig, SanitizedCollectionConfig } from './types.js'
import type { CollectionConfig, SanitizedCollectionConfig, SanitizedJoins } from './types.js'
import { getBaseAuthFields } from '../../auth/getAuthFields.js'
import { TimestampsRequired } from '../../errors/TimestampsRequired.js'
@@ -36,12 +36,15 @@ export const sanitizeCollection = async (
// /////////////////////////////////
const validRelationships = config.collections.map((c) => c.slug) || []
const joins: SanitizedJoins = {}
sanitized.fields = await sanitizeFields({
collectionConfig: sanitized,
config,
fields: sanitized.fields,
joins,
parentIsLocalized: false,
richTextSanitizationPromises,
schemaPath: '',
validRelationships,
})
@@ -193,5 +196,9 @@ export const sanitizeCollection = async (
validateUseAsTitle(sanitized)
return sanitized as SanitizedCollectionConfig
const sanitizedConfig = sanitized as SanitizedCollectionConfig
sanitizedConfig.joins = joins
return sanitizedConfig
}

View File

@@ -29,7 +29,7 @@ import type {
StaticLabel,
} from '../../config/types.js'
import type { DBIdentifierName } from '../../database/types.js'
import type { Field } from '../../fields/config/types.js'
import type { Field, JoinField } from '../../fields/config/types.js'
import type {
CollectionSlug,
JsonObject,
@@ -478,6 +478,21 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
versions?: boolean | IncomingCollectionVersions
}
export type SanitizedJoin = {
/**
* The field configuration defining the join
*/
field: JoinField
/**
* The schemaPath of the join field in dot notation
*/
schemaPath: string
}
export type SanitizedJoins = {
[collectionSlug: string]: SanitizedJoin[]
}
export interface SanitizedCollectionConfig
extends Omit<
DeepRequired<CollectionConfig>,
@@ -486,6 +501,10 @@ export interface SanitizedCollectionConfig
auth: Auth
endpoints: Endpoint[] | false
fields: Field[]
/**
* Object of collections to join 'Join Fields object keyed by collection
*/
joins: SanitizedJoins
upload: SanitizedUploadConfig
versions: SanitizedCollectionVersions
}

View File

@@ -1,6 +1,6 @@
import type { AccessResult } from '../../config/types.js'
import type { PaginatedDocs } from '../../database/types.js'
import type { CollectionSlug } from '../../index.js'
import type { CollectionSlug, JoinQuery } from '../../index.js'
import type { PayloadRequest, Where } from '../../types/index.js'
import type { Collection, DataFromCollectionSlug } from '../config/types.js'
@@ -21,6 +21,7 @@ export type Arguments = {
disableErrors?: boolean
draft?: boolean
includeLockStatus?: boolean
joins?: JoinQuery
limit?: number
overrideAccess?: boolean
page?: number
@@ -62,6 +63,7 @@ export const findOperation = async <TSlug extends CollectionSlug>(
disableErrors,
draft: draftsEnabled,
includeLockStatus,
joins,
limit,
overrideAccess,
page,
@@ -142,6 +144,7 @@ export const findOperation = async <TSlug extends CollectionSlug>(
result = await payload.db.find<DataFromCollectionSlug<TSlug>>({
collection: collectionConfig.slug,
joins: req.payloadAPI === 'GraphQL' ? false : joins,
limit: sanitizedLimit,
locale,
page: sanitizedPage,

View File

@@ -1,5 +1,5 @@
import type { FindOneArgs } from '../../database/types.js'
import type { CollectionSlug } from '../../index.js'
import type { CollectionSlug, JoinQuery } from '../../index.js'
import type { PayloadRequest } from '../../types/index.js'
import type { Collection, DataFromCollectionSlug } from '../config/types.js'
@@ -19,6 +19,7 @@ export type Arguments = {
draft?: boolean
id: number | string
includeLockStatus?: boolean
joins?: JoinQuery
overrideAccess?: boolean
req: PayloadRequest
showHiddenFields?: boolean
@@ -55,6 +56,7 @@ export const findByIDOperation = async <TSlug extends CollectionSlug>(
disableErrors,
draft: draftEnabled = false,
includeLockStatus,
joins,
overrideAccess = false,
req: { fallbackLocale, locale, t },
req,
@@ -76,6 +78,7 @@ export const findByIDOperation = async <TSlug extends CollectionSlug>(
const findOneArgs: FindOneArgs = {
collection: collectionConfig.slug,
joins: req.payloadAPI === 'GraphQL' ? false : joins,
locale,
req: {
transactionID: req.transactionID,

View File

@@ -1,5 +1,5 @@
import type { PaginatedDocs } from '../../../database/types.js'
import type { CollectionSlug, Payload, TypedLocale } from '../../../index.js'
import type { CollectionSlug, JoinQuery, Payload, TypedLocale } from '../../../index.js'
import type { Document, PayloadRequest, RequestContext, Where } from '../../../types/index.js'
import type { DataFromCollectionSlug } from '../../config/types.js'
@@ -19,6 +19,7 @@ export type Options<TSlug extends CollectionSlug> = {
draft?: boolean
fallbackLocale?: TypedLocale
includeLockStatus?: boolean
joins?: JoinQuery
limit?: number
locale?: 'all' | TypedLocale
overrideAccess?: boolean
@@ -42,6 +43,7 @@ export async function findLocal<TSlug extends CollectionSlug>(
disableErrors,
draft = false,
includeLockStatus,
joins,
limit,
overrideAccess = true,
page,
@@ -66,6 +68,7 @@ export async function findLocal<TSlug extends CollectionSlug>(
disableErrors,
draft,
includeLockStatus,
joins,
limit,
overrideAccess,
page,

View File

@@ -1,4 +1,4 @@
import type { CollectionSlug, Payload, TypedLocale } from '../../../index.js'
import type { CollectionSlug, JoinQuery, Payload, TypedLocale } from '../../../index.js'
import type { Document, PayloadRequest, RequestContext } from '../../../types/index.js'
import type { DataFromCollectionSlug } from '../../config/types.js'
@@ -19,6 +19,7 @@ export type Options<TSlug extends CollectionSlug = CollectionSlug> = {
fallbackLocale?: TypedLocale
id: number | string
includeLockStatus?: boolean
joins?: JoinQuery
locale?: 'all' | TypedLocale
overrideAccess?: boolean
req?: PayloadRequest
@@ -42,6 +43,7 @@ export default async function findByIDLocal<TOptions extends Options>(
disableErrors = false,
draft = false,
includeLockStatus,
joins,
overrideAccess = true,
showHiddenFields,
} = options
@@ -62,6 +64,7 @@ export default async function findByIDLocal<TOptions extends Options>(
disableErrors,
draft,
includeLockStatus,
joins,
overrideAccess,
req: await createLocalReq(options, payload),
showHiddenFields,

View File

@@ -1,5 +1,5 @@
import type { TypeWithID } from '../collections/config/types.js'
import type { Document, Payload, PayloadRequest, Where } from '../types/index.js'
import type { Document, JoinQuery, Payload, PayloadRequest, Where } from '../types/index.js'
import type { TypeWithVersion } from '../versions/types.js'
export type { TypeWithVersion }
@@ -49,7 +49,7 @@ export interface BaseDatabaseAdapter {
*/
destroy?: Destroy
find: <T = TypeWithID>(args: FindArgs) => Promise<PaginatedDocs<T>>
find: Find
findGlobal: FindGlobal
@@ -185,6 +185,7 @@ export type QueryDrafts = <T = TypeWithID>(args: QueryDraftsArgs) => Promise<Pag
export type FindOneArgs = {
collection: string
joins?: JoinQuery
locale?: string
req: PayloadRequest
where?: Where
@@ -194,11 +195,13 @@ export type FindOne = <T extends TypeWithID>(args: FindOneArgs) => Promise<null
export type FindArgs = {
collection: string
joins?: JoinQuery
/** Setting limit to 1 is equal to the previous Model.findOne(). Setting limit to 0 disables the limit */
limit?: number
locale?: string
page?: number
pagination?: boolean
projection?: Record<string, unknown>
req: PayloadRequest
skip?: number
sort?: string
@@ -375,6 +378,7 @@ export type UpdateOneArgs = {
collection: string
data: Record<string, unknown>
draft?: boolean
joins?: JoinQuery
locale?: string
req: PayloadRequest
} & (
@@ -392,6 +396,7 @@ export type UpdateOne = (args: UpdateOneArgs) => Promise<Document>
export type DeleteOneArgs = {
collection: string
joins?: JoinQuery
req: PayloadRequest
where: Where
}
@@ -400,6 +405,7 @@ export type DeleteOne = (args: DeleteOneArgs) => Promise<Document>
export type DeleteManyArgs = {
collection: string
joins?: JoinQuery
req: PayloadRequest
where: Where
}

View File

@@ -0,0 +1,11 @@
import type { JoinField } from '../fields/config/types.js'
import { APIError } from './APIError.js'
export class InvalidFieldJoin extends APIError {
constructor(field: JoinField) {
super(
`Invalid join field ${field.name}. The config does not have a field '${field.on}' in collection '${field.collection}'.`,
)
}
}

View File

@@ -1,6 +1,6 @@
import { deepMergeSimple } from '@payloadcms/translations/utilities'
import type { CollectionConfig } from '../../collections/config/types.js'
import type { CollectionConfig, SanitizedJoins } from '../../collections/config/types.js'
import type { Config, SanitizedConfig } from '../../config/types.js'
import type { Field } from './types.js'
@@ -8,14 +8,15 @@ import {
DuplicateFieldName,
InvalidFieldName,
InvalidFieldRelationship,
MissingEditorProp,
MissingFieldType,
} from '../../errors/index.js'
import { MissingEditorProp } from '../../errors/MissingEditorProp.js'
import { formatLabels, toWords } from '../../utilities/formatLabels.js'
import { baseBlockFields } from '../baseFields/baseBlockFields.js'
import { baseIDField } from '../baseFields/baseIDField.js'
import { setDefaultBeforeDuplicate } from '../setDefaultBeforeDuplicate.js'
import validations from '../validations.js'
import { sanitizeJoinField } from './sanitizeJoinField.js'
import { fieldAffectsData, tabHasName } from './types.js'
type Args = {
@@ -23,6 +24,10 @@ type Args = {
config: Config
existingFieldNames?: Set<string>
fields: Field[]
/**
* When not passed in, assume that join are not supported (globals, arrays, blocks)
*/
joins?: SanitizedJoins
parentIsLocalized: boolean
/**
* If true, a richText field will require an editor property to be set, as the sanitizeFields function will not add it from the payload config if not present.
@@ -36,6 +41,7 @@ type Args = {
* so that you can sanitize them together, after the config has been sanitized.
*/
richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise<void>>
schemaPath?: string
/**
* If not null, will validate that upload and relationship fields do not relate to a collection that is not in this array.
* This validation will be skipped if validRelationships is null.
@@ -47,15 +53,19 @@ export const sanitizeFields = async ({
config,
existingFieldNames = new Set(),
fields,
joins,
parentIsLocalized,
requireFieldLevelRichTextEditor = false,
richTextSanitizationPromises,
schemaPath: schemaPathArg,
validRelationships,
}: Args): Promise<Field[]> => {
if (!fields) {
return []
}
let schemaPath = schemaPathArg
for (let i = 0; i < fields.length; i++) {
const field = fields[i]
@@ -92,6 +102,10 @@ export const sanitizeFields = async ({
field.defaultValue = false
}
if (field.type === 'join') {
sanitizeJoinField({ config, field, joins, schemaPath })
}
if (field.type === 'relationship' || field.type === 'upload') {
if (validRelationships) {
const relationships = Array.isArray(field.relationTo)
@@ -234,13 +248,18 @@ export const sanitizeFields = async ({
}
if ('fields' in field && field.fields) {
if ('name' in field && field.name) {
schemaPath = `${schemaPath || ''}${schemaPath ? '.' : ''}${field.name}`
}
field.fields = await sanitizeFields({
config,
existingFieldNames: fieldAffectsData(field) ? new Set() : existingFieldNames,
fields: field.fields,
joins,
parentIsLocalized: parentIsLocalized || field.localized,
requireFieldLevelRichTextEditor,
richTextSanitizationPromises,
schemaPath,
validRelationships,
})
}
@@ -248,17 +267,22 @@ export const sanitizeFields = async ({
if (field.type === 'tabs') {
for (let j = 0; j < field.tabs.length; j++) {
const tab = field.tabs[j]
if (tabHasName(tab) && typeof tab.label === 'undefined') {
if (tabHasName(tab)) {
schemaPath = `${schemaPath || ''}${schemaPath ? '.' : ''}${tab.name}`
if (typeof tab.label === 'undefined') {
tab.label = toWords(tab.name)
}
}
tab.fields = await sanitizeFields({
config,
existingFieldNames: tabHasName(tab) ? new Set() : existingFieldNames,
fields: tab.fields,
joins,
parentIsLocalized: parentIsLocalized || (tabHasName(tab) && tab.localized),
requireFieldLevelRichTextEditor,
richTextSanitizationPromises,
schemaPath,
validRelationships,
})
field.tabs[j] = tab

View File

@@ -0,0 +1,85 @@
import type { SanitizedJoins } from '../../collections/config/types.js'
import type { Config } from '../../config/types.js'
import type { JoinField, RelationshipField } from './types.js'
import { APIError } from '../../errors/index.js'
import { InvalidFieldJoin } from '../../errors/InvalidFieldJoin.js'
import { traverseFields } from '../../utilities/traverseFields.js'
export const sanitizeJoinField = ({
config,
field,
joins,
schemaPath,
}: {
config: Config
field: JoinField
joins?: SanitizedJoins
schemaPath?: string
}) => {
// the `joins` arg is not passed for globals or when recursing on fields that do not allow a join field
if (typeof joins === 'undefined') {
throw new APIError('Join fields cannot be added to arrays, blocks or globals.')
}
if (!field.maxDepth) {
field.maxDepth = 1
}
const join = {
field,
schemaPath: `${schemaPath || ''}${schemaPath ? '.' : ''}${field.name}`,
}
const joinCollection = config.collections.find(
(collection) => collection.slug === field.collection,
)
if (!joinCollection) {
throw new InvalidFieldJoin(field)
}
let joinRelationship: RelationshipField | undefined
const pathSegments = field.on.split('.') // Split the schema path into segments
let currentSegmentIndex = 0
// Traverse fields and match based on the schema path
traverseFields({
callback: ({ field, next }) => {
const currentSegment = pathSegments[currentSegmentIndex]
// match field on path segments
if ('name' in field && field.name === currentSegment) {
// Check if this is the last segment in the path
if (
currentSegmentIndex === pathSegments.length - 1 &&
'type' in field &&
field.type === 'relationship'
) {
joinRelationship = field // Return the matched field
next()
return true
} else {
// Move to the next path segment and continue traversal
currentSegmentIndex++
}
} else {
// skip fields in non-matching path segments
next()
return
}
},
fields: joinCollection.fields,
})
if (!joinRelationship) {
throw new InvalidFieldJoin(join.field)
}
if (joinRelationship.hasMany) {
throw new APIError('Join fields cannot be used with hasMany relationships.')
}
// override the join field localized property to use whatever the relationship field has
field.localized = joinRelationship.localized
if (!joins[field.collection]) {
joins[field.collection] = [join]
} else {
joins[field.collection].push(join)
}
}

View File

@@ -5,6 +5,12 @@ import type { JSONSchema4 } from 'json-schema'
import type { CSSProperties } from 'react'
import type { DeepUndefinable } from 'ts-essentials'
import type {
JoinFieldErrorClientComponent,
JoinFieldErrorServerComponent,
JoinFieldLabelClientComponent,
JoinFieldLabelServerComponent,
} from '../../admin/fields/Join.js'
import type { FieldClientComponent, FieldServerComponent } from '../../admin/forms/Field.js'
import type { RichTextAdapter, RichTextAdapterProvider } from '../../admin/RichText.js'
import type {
@@ -1419,6 +1425,53 @@ export type PointFieldClient = {
} & FieldBaseClient &
Pick<PointField, 'type'>
/**
* A virtual field that loads in related collections by querying a relationship or upload field.
*/
export type JoinField = {
access?: {
create?: never
read?: FieldAccess
update?: never
}
admin?: {
components?: {
Error?: CustomComponent<JoinFieldErrorClientComponent | JoinFieldErrorServerComponent>
Label?: CustomComponent<JoinFieldLabelClientComponent | JoinFieldLabelServerComponent>
} & Admin['components']
disableBulkEdit?: never
readOnly?: never
} & Admin
/**
* The slug of the collection to relate with.
*/
collection: CollectionSlug
defaultValue?: never
hidden?: false
index?: never
/**
* This does not need to be set and will be overridden by the relationship field's localized property.
*/
localized?: boolean
maxDepth?: number
/**
* A string for the field in the collection being joined to.
*/
on: string
type: 'join'
validate?: never
} & FieldBase
export type JoinFieldClient = {
admin?: {
components?: {
Label?: MappedComponent
} & AdminClient['components']
} & AdminClient &
Pick<JoinField['admin'], 'disableBulkEdit' | 'readOnly'>
} & FieldBaseClient &
Pick<JoinField, 'collection' | 'index' | 'maxDepth' | 'on' | 'type'>
export type Field =
| ArrayField
| BlocksField
@@ -1428,6 +1481,7 @@ export type Field =
| DateField
| EmailField
| GroupField
| JoinField
| JSONField
| NumberField
| PointField
@@ -1451,6 +1505,7 @@ export type ClientField =
| DateFieldClient
| EmailFieldClient
| GroupFieldClient
| JoinFieldClient
| JSONFieldClient
| NumberFieldClient
| PointFieldClient
@@ -1499,6 +1554,7 @@ export type FieldAffectingData =
| DateField
| EmailField
| GroupField
| JoinField
| JSONField
| NumberField
| PointField
@@ -1519,6 +1575,7 @@ export type FieldAffectingDataClient =
| DateFieldClient
| EmailFieldClient
| GroupFieldClient
| JoinFieldClient
| JSONFieldClient
| NumberFieldClient
| PointFieldClient
@@ -1598,7 +1655,7 @@ export type FieldWithMany = RelationshipField | SelectField
export type FieldWithManyClient = RelationshipFieldClient | SelectFieldClient
export type FieldWithMaxDepth = RelationshipField | UploadField
export type FieldWithMaxDepthClient = RelationshipFieldClient | UploadFieldClient
export type FieldWithMaxDepthClient = JoinFieldClient | RelationshipFieldClient | UploadFieldClient
export function fieldHasSubFields<TField extends ClientField | Field>(
field: TField,
@@ -1651,7 +1708,8 @@ export function fieldHasMaxDepth<TField extends ClientField | Field>(
field: TField,
): field is TField & (TField extends ClientField ? FieldWithMaxDepthClient : FieldWithMaxDepth) {
return (
(field.type === 'upload' || field.type === 'relationship') && typeof field.maxDepth === 'number'
(field.type === 'upload' || field.type === 'relationship' || field.type === 'join') &&
typeof field.maxDepth === 'number'
)
}

View File

@@ -292,7 +292,7 @@ export const promise = async ({
})
}
if (field.type === 'relationship' || field.type === 'upload') {
if (field.type === 'relationship' || field.type === 'upload' || field.type === 'join') {
populationPromises.push(
relationshipPopulationPromise({
currentDepth,

View File

@@ -1,5 +1,5 @@
import type { PayloadRequest } from '../../../types/index.js'
import type { RelationshipField, UploadField } from '../../config/types.js'
import type { JoinField, RelationshipField, UploadField } from '../../config/types.js'
import { createDataloaderCacheKey } from '../../../collections/dataloader.js'
import { fieldHasMaxDepth, fieldSupportsMany } from '../../config/types.js'
@@ -11,7 +11,7 @@ type PopulateArgs = {
depth: number
draft: boolean
fallbackLocale: null | string
field: RelationshipField | UploadField
field: JoinField | RelationshipField | UploadField
index?: number
key?: string
locale: null | string
@@ -36,11 +36,16 @@ const populate = async ({
showHiddenFields,
}: PopulateArgs) => {
const dataToUpdate = dataReference
const relation = Array.isArray(field.relationTo) ? (data.relationTo as string) : field.relationTo
let relation
if (field.type === 'join') {
relation = field.collection
} else {
relation = Array.isArray(field.relationTo) ? (data.relationTo as string) : field.relationTo
}
const relatedCollection = req.payload.collections[relation]
if (relatedCollection) {
let id = Array.isArray(field.relationTo) ? data.value : data
let id = field.type !== 'join' && Array.isArray(field.relationTo) ? data.value : data
let relationshipValue
const shouldPopulate = depth && currentDepth <= depth
@@ -74,20 +79,21 @@ const populate = async ({
// ids are visible regardless of access controls
relationshipValue = id
}
if (typeof index === 'number' && typeof key === 'string') {
if (Array.isArray(field.relationTo)) {
if (field.type !== 'join' && Array.isArray(field.relationTo)) {
dataToUpdate[field.name][key][index].value = relationshipValue
} else {
dataToUpdate[field.name][key][index] = relationshipValue
}
} else if (typeof index === 'number' || typeof key === 'string') {
if (Array.isArray(field.relationTo)) {
if (field.type === 'join') {
dataToUpdate[field.name].docs[index ?? key] = relationshipValue
} else if (Array.isArray(field.relationTo)) {
dataToUpdate[field.name][index ?? key].value = relationshipValue
} else {
dataToUpdate[field.name][index ?? key] = relationshipValue
}
} else if (Array.isArray(field.relationTo)) {
} else if (field.type !== 'join' && Array.isArray(field.relationTo)) {
dataToUpdate[field.name].value = relationshipValue
} else {
dataToUpdate[field.name] = relationshipValue
@@ -100,7 +106,7 @@ type PromiseArgs = {
depth: number
draft: boolean
fallbackLocale: null | string
field: RelationshipField | UploadField
field: JoinField | RelationshipField | UploadField
locale: null | string
overrideAccess: boolean
req: PayloadRequest
@@ -124,7 +130,7 @@ export const relationshipPopulationPromise = async ({
const populateDepth = fieldHasMaxDepth(field) && field.maxDepth < depth ? field.maxDepth : depth
const rowPromises = []
if (fieldSupportsMany(field) && field.hasMany) {
if (field.type === 'join' || (fieldSupportsMany(field) && field.hasMany)) {
if (
field.localized &&
locale === 'all' &&
@@ -155,13 +161,19 @@ export const relationshipPopulationPromise = async ({
})
}
})
} else if (Array.isArray(siblingDoc[field.name])) {
siblingDoc[field.name].forEach((relatedDoc, index) => {
} else if (
Array.isArray(siblingDoc[field.name]) ||
Array.isArray(siblingDoc[field.name]?.docs)
) {
;(Array.isArray(siblingDoc[field.name])
? siblingDoc[field.name]
: siblingDoc[field.name].docs
).forEach((relatedDoc, index) => {
const rowPromise = async () => {
if (relatedDoc) {
await populate({
currentDepth,
data: relatedDoc,
data: relatedDoc?.id ? relatedDoc.id : relatedDoc,
dataReference: resultingDoc,
depth: populateDepth,
draft,

View File

@@ -52,6 +52,7 @@ import type { Options as FindGlobalVersionsOptions } from './globals/operations/
import type { Options as RestoreGlobalVersionOptions } from './globals/operations/local/restoreVersion.js'
import type { Options as UpdateGlobalOptions } from './globals/operations/local/update.js'
import type { JsonObject } from './types/index.js'
import type { TraverseFieldsCallback } from './utilities/traverseFields.js'
import type { TypeWithVersion } from './versions/types.js'
import { decrypt, encrypt } from './auth/crypto.js'
@@ -63,9 +64,9 @@ import { consoleEmailAdapter } from './email/consoleEmailAdapter.js'
import { fieldAffectsData } from './fields/config/types.js'
import localGlobalOperations from './globals/operations/local/index.js'
import { checkDependencies } from './utilities/dependencies/dependencyChecker.js'
import flattenFields from './utilities/flattenTopLevelFields.js'
import { getLogger } from './utilities/logger.js'
import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js'
import { traverseFields } from './utilities/traverseFields.js'
export interface GeneratedTypes {
authUntyped: {
@@ -458,15 +459,22 @@ export class BasePayload {
}
this.config.collections.forEach((collection) => {
const customID = flattenFields(collection.fields).find(
(field) => fieldAffectsData(field) && field.name === 'id',
)
let customIDType
if (customID?.type === 'number' || customID?.type === 'text') {
customIDType = customID.type
let customIDType = undefined
const findCustomID: TraverseFieldsCallback = ({ field, next }) => {
if (['array', 'blocks'].includes(field.type)) {
next()
return
}
if (!fieldAffectsData(field)) {
return
}
if (field.name === 'id') {
customIDType = field.type
return true
}
}
traverseFields({ callback: findCustomID, fields: collection.fields })
this.collections[collection.slug] = {
config: collection,
@@ -879,6 +887,8 @@ export type {
GroupField,
GroupFieldClient,
HookName,
JoinField,
JoinFieldClient,
JSONField,
JSONFieldClient,
Labels,
@@ -1038,6 +1048,8 @@ export { isValidID } from './utilities/isValidID.js'
export { killTransaction } from './utilities/killTransaction.js'
export { mapAsync } from './utilities/mapAsync.js'
export { mergeListSearchAndWhere } from './utilities/mergeListSearchAndWhere.js'
export { traverseFields } from './utilities/traverseFields.js'
export type { TraverseFieldsCallback } from './utilities/traverseFields.js'
export { buildVersionCollectionFields } from './versions/buildCollectionFields.js'
export { buildVersionGlobalFields } from './versions/buildGlobalFields.js'
export { checkDependencies }
@@ -1047,6 +1059,6 @@ export { enforceMaxVersions } from './versions/enforceMaxVersions.js'
export { getLatestCollectionVersion } from './versions/getLatestCollectionVersion.js'
export { getLatestGlobalVersion } from './versions/getLatestGlobalVersion.js'
export { saveVersion } from './versions/saveVersion.js'
export type { TypeWithVersion } from './versions/types.js'
export type { TypeWithVersion } from './versions/types.js'
export { deepMergeSimple } from '@payloadcms/translations/utilities'

View File

@@ -93,7 +93,7 @@ export type Operator = (typeof validOperators)[number]
// Makes it so things like passing new Date() will error
export type JsonValue = JsonArray | JsonObject | unknown //Date | JsonArray | JsonObject | boolean | null | number | string // TODO: Evaluate proper, strong type for this
export interface JsonArray extends Array<JsonValue> {}
export type JsonArray = Array<JsonValue>
export interface JsonObject {
[key: string]: any
@@ -110,6 +110,19 @@ export type Where = {
or?: Where[]
}
/**
* Applies pagination for join fields for including collection relationships
*/
export type JoinQuery =
| {
[schemaPath: string]: {
limit?: number
sort?: string
where?: Where
}
}
| false
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Document = any

View File

@@ -299,6 +299,30 @@ export function fieldsToJSONSchema(
break
}
case 'join': {
fieldSchema = {
type: withNullableJSONSchemaType('object', false),
additionalProperties: false,
properties: {
docs: {
type: withNullableJSONSchemaType('array', false),
items: {
oneOf: [
{
type: collectionIDFieldTypes[field.collection],
},
{
$ref: `#/definitions/${field.collection}`,
},
],
},
},
hasNextPage: { type: withNullableJSONSchemaType('boolean', false) },
},
}
break
}
case 'upload':
case 'relationship': {
if (Array.isArray(field.relationTo)) {

View File

@@ -0,0 +1,91 @@
import type { Field, TabAsField } from '../fields/config/types.js'
import { fieldHasSubFields } from '../fields/config/types.js'
export type TraverseFieldsCallback = (args: {
/**
* The current field
*/
field: Field | TabAsField
/**
* Function that when called will skip the current field and continue to the next
*/
next?: () => void
/**
* The parent reference object
*/
parentRef?: Record<string, unknown> | unknown
/**
* The current reference object
*/
ref?: Record<string, unknown> | unknown
}) => boolean | void
type TraverseFieldsArgs = {
callback: TraverseFieldsCallback
fields: (Field | TabAsField)[]
parentRef?: Record<string, unknown> | unknown
ref?: Record<string, unknown> | unknown
}
/**
* Iterate a recurse an array of fields, calling a callback for each field
*
* @param fields
* @param callback callback called for each field, discontinue looping if callback returns truthy
* @param ref
* @param parentRef
*/
export const traverseFields = ({
callback,
fields,
parentRef = {},
ref = {},
}: TraverseFieldsArgs): void => {
fields.some((field) => {
let skip = false
const next = () => {
skip = true
}
if (callback && callback({ field, next, parentRef, ref })) {
return true
}
if (skip) {
return false
}
if (field.type === 'tabs' && 'tabs' in field) {
field.tabs.forEach((tab) => {
if ('name' in tab && tab.name) {
if (typeof ref[tab.name] === 'undefined') {
ref[tab.name] = {}
}
ref = ref[tab.name]
}
if (callback && callback({ field: { ...tab, type: 'tab' }, next, parentRef, ref })) {
return true
}
traverseFields({ callback, fields: tab.fields, parentRef, ref })
})
return
}
if (field.type !== 'tab' && fieldHasSubFields(field)) {
const parentRef = ref
if ('name' in field && field.name) {
if (typeof ref[field.name] === 'undefined') {
if (field.type === 'array') {
if (field.localized) {
ref[field.name] = {}
} else {
ref[field.name] = []
}
}
if (field.type === 'group') {
ref[field.name] = {}
}
}
ref = ref[field.name]
}
traverseFields({ callback, fields: field.fields, parentRef, ref })
}
})
}

View File

@@ -56,6 +56,8 @@ export const saveVersion = async ({
;({ docs } = await payload.db.findVersions({
...findVersionArgs,
collection: collection.slug,
limit: 1,
pagination: false,
req,
where: {
parent: {
@@ -67,6 +69,8 @@ export const saveVersion = async ({
;({ docs } = await payload.db.findGlobalVersions({
...findVersionArgs,
global: global.slug,
limit: 1,
pagination: false,
req,
}))
}
@@ -131,7 +135,7 @@ export const saveVersion = async ({
if (publishSpecificLocale && snapshot) {
const snapshotData = deepCopyObjectSimple(snapshot)
if (snapshotData._id) delete snapshotData._id
if (snapshotData._id) {delete snapshotData._id}
snapshotData._status = 'draft'

View File

@@ -1,7 +0,0 @@
node_modules
.env
dist
demo/uploads
build
.DS_Store
package-lock.json

View File

@@ -1,15 +0,0 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
}
},
"module": {
"type": "es6"
}
}

View File

@@ -1,40 +0,0 @@
# Payload Relationship ObjectID Plugin
This plugin automatically enables all Payload `relationship` and `upload` field types to be stored as `ObjectID`s in MongoDB.
Minimum required version of Payload: `1.9.5`
## What it does
It injects a `beforeChange` field hook into each `relationship` and `upload` field, which converts string-based IDs to `ObjectID`s immediately prior to storage.
By default, it also injects an `afterRead` field hook into the above fields, which ensures that the values are re-formatted back to strings after having been read from the database.
#### Usage
Simply import and install the plugin to make it work:
```ts
import { relationshipsAsObjectID } from '@payloadcms/plugin-relationship-object-ids'
import { buildConfig } from 'payload'
export default buildConfig({
// your config here
plugins: [
// Call the plugin within your `plugins` array
relationshipsAsObjectID({
// Optionally keep relationship values as ObjectID
// when they are retrieved from the database.
keepAfterRead: true,
}),
],
})
```
### Migration
Note - this plugin will only store newly created or resaved documents' relations as `ObjectID`s. It will not modify any of your existing data. If you'd like to convert existing data into an `ObjectID` format, you should write a migration script to loop over all documents in your database and then simply resave each one.
### Support
If you need help with this plugin, [join our Discord](https://t.co/30APlsQUPB) and we'd be happy to give you a hand.

View File

@@ -1,19 +0,0 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.FlatConfig} */
let FlatConfig
/** @type {FlatConfig[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -1,62 +0,0 @@
{
"name": "@payloadcms/plugin-relationship-object-ids",
"version": "3.0.0-beta.107",
"description": "A Payload plugin to store all relationship IDs as ObjectIDs",
"repository": {
"type": "git",
"url": "https://github.com/payloadcms/payload.git",
"directory": "packages/plugin-relationship-object-ids"
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"maintainers": [
{
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
],
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"prepublishOnly": "pnpm clean && pnpm turbo build"
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"payload": "workspace:*"
},
"peerDependencies": {
"mongoose": "6.12.3",
"payload": "workspace:*"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"homepage:": "https://payloadcms.com"
}

View File

@@ -1,101 +0,0 @@
import type { CollectionConfig, Config, FieldHook, RelationshipField, UploadField } from 'payload'
import mongoose from 'mongoose'
import { fieldAffectsData } from 'payload/shared'
const convertValue = ({
relatedCollection,
value,
}: {
relatedCollection: CollectionConfig
value: number | string
}): mongoose.Types.ObjectId | number | string => {
const customIDField = relatedCollection.fields.find(
(field) => fieldAffectsData(field) && field.name === 'id',
)
if (!customIDField && mongoose.Types.ObjectId.isValid(value)) {
return value.toString()
}
return value
}
interface RelationObject {
relationTo: string
value: number | string
}
function isValidRelationObject(value: unknown): value is RelationObject {
return typeof value === 'object' && value !== null && 'relationTo' in value && 'value' in value
}
interface Args {
config: Config
field: RelationshipField | UploadField
}
export const getAfterReadHook =
({ config, field }: Args): FieldHook =>
({ value }) => {
let relatedCollection: CollectionConfig | undefined
const hasManyRelations = typeof field.relationTo !== 'string'
if (!hasManyRelations) {
relatedCollection = config.collections?.find(({ slug }) => slug === field.relationTo)
}
if (Array.isArray(value)) {
return value.map((val) => {
// Handle has many
if (relatedCollection && val) {
return convertValue({
relatedCollection,
value: val,
})
}
// Handle has many - polymorphic
if (isValidRelationObject(val)) {
const relatedCollectionForSingleValue = config.collections?.find(
({ slug }) => slug === val.relationTo,
)
if (relatedCollectionForSingleValue) {
return {
relationTo: val.relationTo,
value: convertValue({
relatedCollection: relatedCollectionForSingleValue,
value: val.value,
}),
}
}
}
return val
})
}
// Handle has one - polymorphic
if (isValidRelationObject(value)) {
relatedCollection = config.collections?.find(({ slug }) => slug === value.relationTo)
if (relatedCollection) {
return {
relationTo: value.relationTo,
value: convertValue({ relatedCollection, value: value.value }),
}
}
}
// Handle has one
if (relatedCollection && value) {
return convertValue({
relatedCollection,
value,
})
}
return value
}

View File

@@ -1,96 +0,0 @@
import type { CollectionConfig, Config, FieldHook, RelationshipField, UploadField } from 'payload'
import mongoose from 'mongoose'
import { fieldAffectsData } from 'payload/shared'
const convertValue = ({
relatedCollection,
value,
}: {
relatedCollection: CollectionConfig
value: number | string
}): mongoose.Types.ObjectId | number | string => {
const customIDField = relatedCollection.fields.find(
(field) => fieldAffectsData(field) && field.name === 'id',
)
if (!customIDField) {
return new mongoose.Types.ObjectId(value)
}
return value
}
interface RelationObject {
relationTo: string
value: number | string
}
function isValidRelationObject(value: unknown): value is RelationObject {
return typeof value === 'object' && value !== null && 'relationTo' in value && 'value' in value
}
export const getBeforeChangeHook =
({ config, field }: { config: Config; field: RelationshipField | UploadField }): FieldHook =>
({ value }) => {
let relatedCollection: CollectionConfig | undefined
const hasManyRelations = typeof field.relationTo !== 'string'
if (!hasManyRelations) {
relatedCollection = config.collections?.find(({ slug }) => slug === field.relationTo)
}
if (Array.isArray(value)) {
return value.map((val) => {
// Handle has many
if (relatedCollection && val && (typeof val === 'string' || typeof val === 'number')) {
return convertValue({
relatedCollection,
value: val,
})
}
// Handle has many - polymorphic
if (isValidRelationObject(val)) {
const relatedCollectionForSingleValue = config.collections?.find(
({ slug }) => slug === val.relationTo,
)
if (relatedCollectionForSingleValue) {
return {
relationTo: val.relationTo,
value: convertValue({
relatedCollection: relatedCollectionForSingleValue,
value: val.value,
}),
}
}
}
return val
})
}
// Handle has one - polymorphic
if (isValidRelationObject(value)) {
relatedCollection = config.collections?.find(({ slug }) => slug === value.relationTo)
if (relatedCollection) {
return {
relationTo: value.relationTo,
value: convertValue({ relatedCollection, value: value.value }),
}
}
}
// Handle has one
if (relatedCollection && value && (typeof value === 'string' || typeof value === 'number')) {
return convertValue({
relatedCollection,
value,
})
}
return value
}

View File

@@ -1,105 +0,0 @@
import type { Config, Field, FieldHook } from 'payload'
import { getAfterReadHook } from './hooks/afterRead.js'
import { getBeforeChangeHook } from './hooks/beforeChange.js'
interface TraverseFieldsArgs {
config: Config
fields: Field[]
keepAfterRead: boolean
}
const traverseFields = ({ config, fields, keepAfterRead }: TraverseFieldsArgs): Field[] => {
return fields.map((field) => {
if (field.type === 'relationship' || field.type === 'upload') {
const afterRead: FieldHook[] = [...(field.hooks?.afterRead || [])]
if (!keepAfterRead) {
afterRead.unshift(getAfterReadHook({ config, field }))
}
return {
...field,
hooks: {
...(field.hooks || {}),
afterRead,
beforeChange: [
...(field.hooks?.beforeChange || []),
getBeforeChangeHook({ config, field }),
],
},
}
}
if ('fields' in field) {
return {
...field,
fields: traverseFields({ config, fields: field.fields, keepAfterRead }),
}
}
if (field.type === 'tabs') {
return {
...field,
tabs: field.tabs.map((tab) => {
return {
...tab,
fields: traverseFields({ config, fields: tab.fields, keepAfterRead }),
}
}),
}
}
if (field.type === 'blocks') {
return {
...field,
blocks: field.blocks.map((block) => {
return {
...block,
fields: traverseFields({ config, fields: block.fields, keepAfterRead }),
}
}),
}
}
return field
})
}
interface Args {
/*
If you want to keep ObjectIDs as ObjectIDs after read, you can enable this flag.
By default, all relationship ObjectIDs are stringified within the AfterRead hook.
*/
keepAfterRead?: boolean
}
export const relationshipsAsObjectID =
(args?: Args) =>
(config: Config): Config => {
const keepAfterRead = typeof args?.keepAfterRead === 'boolean' ? args.keepAfterRead : false
return {
...config,
collections: (config.collections || []).map((collection) => {
return {
...collection,
fields: traverseFields({
config,
fields: collection.fields,
keepAfterRead,
}),
}
}),
globals: (config.globals || []).map((global) => {
return {
...global,
fields: traverseFields({
config,
fields: global.fields,
keepAfterRead,
}),
}
}),
}
}

View File

@@ -1,24 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true, // Make sure typescript knows that this module depends on their references
"noEmit": false /* Do not emit outputs. */,
"emitDeclarationOnly": true,
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
"rootDir": "./src" /* Specify the root folder within your source files. */
},
"exclude": [
"dist",
"build",
"tests",
"test",
"node_modules",
"eslint.config.js",
"src/**/*.spec.js",
"src/**/*.spec.jsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx"
],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"],
"references": [{ "path": "../payload" }]
}

View File

@@ -3,7 +3,6 @@ import type { GenericTranslationsObject } from '@payloadcms/translations'
export const en: GenericTranslationsObject = {
$schema: './translation-schema.json',
'plugin-seo': {
missing: 'Missing',
almostThere: 'Almost there',
autoGenerate: 'Auto-generate',
bestPractices: 'best practices',
@@ -18,6 +17,7 @@ export const en: GenericTranslationsObject = {
'This should be between {{minLength}} and {{maxLength}} characters. For help in writing quality meta descriptions, see ',
lengthTipTitle:
'This should be between {{minLength}} and {{maxLength}} characters. For help in writing quality meta titles, see ',
missing: 'Missing',
noImage: 'No image',
preview: 'Preview',
previewDescription: 'Exact result listings may vary based on content and search relevancy.',

View File

@@ -3,7 +3,6 @@ import type { GenericTranslationsObject } from '@payloadcms/translations'
export const es: GenericTranslationsObject = {
$schema: './translation-schema.json',
'plugin-seo': {
missing: 'Falta',
almostThere: 'Ya casi está',
autoGenerate: 'Autogénerar',
bestPractices: 'mejores prácticas',
@@ -18,6 +17,7 @@ export const es: GenericTranslationsObject = {
'Esto debe estar entre {{minLength}} y {{maxLength}} caracteres. Para obtener ayuda sobre cómo escribir meta descripciones de calidad, consulte ',
lengthTipTitle:
'Debe tener entre {{minLength}} y {{maxLength}} caracteres. Para obtener ayuda sobre cómo escribir metatítulos de calidad, consulte ',
missing: 'Falta',
noImage: 'Sin imagen',
preview: 'Vista previa',
previewDescription:

View File

@@ -3,7 +3,6 @@ import type { GenericTranslationsObject } from '@payloadcms/translations'
export const fa: GenericTranslationsObject = {
$schema: './translation-schema.json',
'plugin-seo': {
missing: 'ناقص',
almostThere: 'چیزیی باقی نمونده',
autoGenerate: 'تولید خودکار',
bestPractices: 'آموزش بیشتر',
@@ -19,6 +18,7 @@ export const fa: GenericTranslationsObject = {
'این باید بین {{minLength}} و {{maxLength}} کلمه باشد. برای کمک در نوشتن توضیحات متا با کیفیت، مراجعه کنید به ',
lengthTipTitle:
'این باید بین {{minLength}} و {{maxLength}} کلمه باشد. برای کمک در نوشتن عناوین متا با کیفیت، مراجعه کنید به ',
missing: 'ناقص',
noImage: 'بدون تصویر',
preview: 'پیش‌نمایش',
previewDescription: 'فهرست نتایج ممکن است بر اساس محتوا و متناسب با کلمه کلیدی جستجو شده باشند',

View File

@@ -3,7 +3,6 @@ import type { GenericTranslationsObject } from '@payloadcms/translations'
export const fr: GenericTranslationsObject = {
$schema: './translation-schema.json',
'plugin-seo': {
missing: 'Manquant',
almostThere: 'On y est presque',
autoGenerate: 'Auto-générer',
bestPractices: 'bonnes pratiques',
@@ -18,6 +17,7 @@ export const fr: GenericTranslationsObject = {
"Ceci devrait contenir entre {{minLength}} et {{maxLength}} caractères. Pour obtenir de l'aide pour rédiger des descriptions meta de qualité, consultez les ",
lengthTipTitle:
"Ceci devrait contenir entre {{minLength}} et {{maxLength}} caractères. Pour obtenir de l'aide pour rédiger des titres meta de qualité, consultez les ",
missing: 'Manquant',
noImage: "Pas d'image",
preview: 'Aperçu',
previewDescription:

View File

@@ -3,7 +3,6 @@ import type { GenericTranslationsObject } from '@payloadcms/translations'
export const it: GenericTranslationsObject = {
$schema: './translation-schema.json',
'plugin-seo': {
missing: 'Mancante',
almostThere: 'Ci siamo quasi',
autoGenerate: 'Generazione automatica',
bestPractices: 'migliori pratiche',
@@ -19,6 +18,7 @@ export const it: GenericTranslationsObject = {
'Dovrebbe essere compreso tra {{minLength}} e {{maxLength}} caratteri. Per assistenza nella scrittura di meta descrizioni di qualità, vedere ',
lengthTipTitle:
'Dovrebbe essere compreso tra {{minLength}} e {{maxLength}} caratteri. Per assistenza nella scrittura di meta titoli di qualità, vedere ',
missing: 'Mancante',
noImage: 'Nessuna Immagine',
preview: 'Anteprima',
previewDescription:

View File

@@ -3,7 +3,6 @@ import type { GenericTranslationsObject } from '@payloadcms/translations'
export const nb: GenericTranslationsObject = {
$schema: './translation-schema.json',
'plugin-seo': {
missing: 'Mangler',
almostThere: 'Nesten der',
autoGenerate: 'Auto-generer',
bestPractices: 'beste praksis',
@@ -18,6 +17,7 @@ export const nb: GenericTranslationsObject = {
'Dette bør være mellom {{minLength}} og {{maxLength}} tegn. For hjelp til å skrive beskrivelser av god kvalitet, se ',
lengthTipTitle:
'Dette bør være mellom {{minLength}} og {{maxLength}} tegn. For hjelp til å skrive metatitler av god kvalitet, se ',
missing: 'Mangler',
noImage: 'Bilde mangler',
preview: 'Forhåndsvisning',
previewDescription:

View File

@@ -3,7 +3,6 @@ import type { GenericTranslationsObject } from '@payloadcms/translations'
export const pl: GenericTranslationsObject = {
$schema: './translation-schema.json',
'plugin-seo': {
missing: 'Brakuje',
almostThere: 'Prawie gotowe',
autoGenerate: 'Wygeneruj automatycznie',
bestPractices: 'najlepsze praktyki',
@@ -18,6 +17,7 @@ export const pl: GenericTranslationsObject = {
'Długość powinna wynosić od {{minLength}} do {{maxLength}} znaków. Po porady dotyczące pisania wysokiej jakości meta opisów zobacz ',
lengthTipTitle:
'Długość powinna wynosić od {{minLength}} do {{maxLength}} znaków. Po porady dotyczące pisania wysokiej jakości meta tytułów zobacz ',
missing: 'Brakuje',
noImage: 'Brak obrazu',
preview: 'Podgląd',
previewDescription:

View File

@@ -3,7 +3,6 @@ import type { GenericTranslationsObject } from '@payloadcms/translations'
export const ru: GenericTranslationsObject = {
$schema: './translation-schema.json',
'plugin-seo': {
missing: 'Отсутствует',
almostThere: 'Почти готово',
autoGenerate: 'Сгенерировать автоматически',
bestPractices: 'лучшие практики',
@@ -18,6 +17,7 @@ export const ru: GenericTranslationsObject = {
'Должно быть от {{minLength}} до {{maxLength}} символов. Для помощи в написании качественных метаописаний см.',
lengthTipTitle:
'Должно быть от {{minLength}} до {{maxLength}} символов. Для помощи в написании качественных метазаголовков см.',
missing: 'Отсутствует',
noImage: 'Нет изображения',
preview: 'Предварительный просмотр',
previewDescription:

View File

@@ -3,7 +3,6 @@ import type { GenericTranslationsObject } from '@payloadcms/translations'
export const uk: GenericTranslationsObject = {
$schema: './translation-schema.json',
'plugin-seo': {
missing: 'Відсутнє',
almostThere: 'Ще трошки',
autoGenerate: 'Згенерувати',
bestPractices: 'найкращі практики',
@@ -18,6 +17,7 @@ export const uk: GenericTranslationsObject = {
'Має бути від {{minLength}} до {{maxLength}} символів. Щоб дізнатися, як писати якісні метаописи — перегляньте ',
lengthTipTitle:
'Має бути від {{minLength}} до {{maxLength}} символів. Щоб дізнатися, як писати якісні метазаголовки — перегляньте ',
missing: 'Відсутнє',
noImage: 'Немає зображення',
preview: 'Попередній перегляд',
previewDescription:

View File

@@ -3,7 +3,6 @@ import type { GenericTranslationsObject } from '@payloadcms/translations'
export const vi: GenericTranslationsObject = {
$schema: './translation-schema.json',
'plugin-seo': {
missing: 'Không đạt',
almostThere: 'Gần đạt',
autoGenerate: 'Tự động tạo',
bestPractices: 'các phương pháp hay nhất',
@@ -18,6 +17,7 @@ export const vi: GenericTranslationsObject = {
'Độ dài nên từ {{minLength}}-{{maxLength}} kí tự. Để được hướng dẫn viết mô tả meta chất lượng, hãy xem ',
lengthTipTitle:
'Độ dài nên từ {{minLength}}-{{maxLength}} kí tự. Để được hướng dẫn viết mô tả meta chất lượng, hãy xem ',
missing: 'Không đạt',
noImage: 'Chưa có ảnh',
preview: 'Xem trước',
previewDescription: 'Kết quả hiển thị có thể thay đổi tuỳ theo nội dung và công cụ tìm kiếm.',

View File

@@ -413,8 +413,8 @@ export const arTranslations: DefaultTranslationsObject = {
problemRestoringVersion: 'حدث خطأ في استعادة هذه النّسخة',
publish: 'نشر',
publishChanges: 'نشر التّغييرات',
publishIn: 'نشر في {{locale}}',
published: 'تمّ النّشر',
publishIn: 'نشر في {{locale}}',
publishing: 'نشر',
restoreAsDraft: 'استعادة كمسودة',
restoredSuccessfully: 'تمّت الاستعادة بنحاح.',

View File

@@ -420,8 +420,8 @@ export const azTranslations: DefaultTranslationsObject = {
problemRestoringVersion: 'Bu versiyanın bərpasında problem yaşandı',
publish: 'Dərc et',
publishChanges: 'Dəyişiklikləri dərc et',
publishIn: '{{locale}} dili ilə nəşr edin',
published: 'Dərc edilmiş',
publishIn: '{{locale}} dili ilə nəşr edin',
publishing: 'Nəşr',
restoreAsDraft: 'Qaralamalar kimi bərpa et',
restoredSuccessfully: 'Uğurla bərpa edildi.',

View File

@@ -419,8 +419,8 @@ export const bgTranslations: DefaultTranslationsObject = {
problemRestoringVersion: 'Имаше проблем при възстановяването на тази версия',
publish: 'Публикувай',
publishChanges: 'Публикувай промените',
publishIn: 'Публикувайте в {{locale}}',
published: 'Публикувано',
publishIn: 'Публикувайте в {{locale}}',
publishing: 'Публикуване',
restoreAsDraft: 'Възстанови като чернова',
restoredSuccessfully: 'Успешно възстановяване.',

View File

@@ -418,8 +418,8 @@ export const csTranslations: DefaultTranslationsObject = {
problemRestoringVersion: 'Při obnovování této verze došlo k problému',
publish: 'Publikovat',
publishChanges: 'Publikovat změny',
publishIn: 'Publikovat v {{locale}}',
published: 'Publikováno',
publishIn: 'Publikovat v {{locale}}',
publishing: 'Publikování',
restoreAsDraft: 'Obnovit jako koncept',
restoredSuccessfully: 'Úspěšně obnoveno.',

View File

@@ -424,8 +424,8 @@ export const deTranslations: DefaultTranslationsObject = {
problemRestoringVersion: 'Es gab ein Problem bei der Wiederherstellung dieser Version',
publish: 'Veröffentlichen',
publishChanges: 'Änderungen veröffentlichen',
publishIn: 'Veröffentlichen in {{locale}}',
published: 'Veröffentlicht',
publishIn: 'Veröffentlichen in {{locale}}',
publishing: 'Veröffentlichung',
restoreAsDraft: 'Als Entwurf wiederherstellen',
restoredSuccessfully: 'Erfolgreich wiederhergestellt.',

View File

@@ -422,8 +422,8 @@ export const enTranslations = {
problemRestoringVersion: 'There was a problem restoring this version',
publish: 'Publish',
publishChanges: 'Publish changes',
publishIn: 'Publish in {{locale}}',
published: 'Published',
publishIn: 'Publish in {{locale}}',
publishing: 'Publishing',
restoreAsDraft: 'Restore as draft',
restoredSuccessfully: 'Restored Successfully.',

View File

@@ -424,8 +424,8 @@ export const esTranslations: DefaultTranslationsObject = {
problemRestoringVersion: 'Ocurrió un problema al restaurar esta versión',
publish: 'Publicar',
publishChanges: 'Publicar cambios',
publishIn: 'Publicar en {{locale}}',
published: 'Publicado',
publishIn: 'Publicar en {{locale}}',
publishing: 'Publicación',
restoreAsDraft: 'Restaurar como borrador',
restoredSuccessfully: 'Restaurado éxito.',

View File

@@ -417,8 +417,8 @@ export const faTranslations: DefaultTranslationsObject = {
problemRestoringVersion: 'مشکلی در بازیابی این نگارش وجود دارد',
publish: 'انتشار',
publishChanges: 'انتشار تغییرات',
publishIn: 'منتشر کنید در {{locale}}',
published: 'انتشار یافته',
publishIn: 'منتشر کنید در {{locale}}',
publishing: 'انتشار',
restoreAsDraft: 'بازیابی به عنوان پیش‌نویس',
restoredSuccessfully: 'با موفقیت بازیابی شد.',

View File

@@ -431,8 +431,8 @@ export const frTranslations: DefaultTranslationsObject = {
problemRestoringVersion: 'Un problème est survenu lors de la restauration de cette version',
publish: 'Publier',
publishChanges: 'Publier les modifications',
publishIn: 'Publier en {{locale}}',
published: 'Publié',
publishIn: 'Publier en {{locale}}',
publishing: 'Publication',
restoreAsDraft: 'Restaurer comme brouillon',
restoredSuccessfully: 'Restauré(e) avec succès.',

Some files were not shown because too many files have changed in this diff Show More