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:
1
.github/workflows/pr-title.yml
vendored
1
.github/workflows/pr-title.yml
vendored
@@ -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
235
docs/fields/join.mdx
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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\"",
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, '\\$&'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
174
packages/db-mongodb/src/utilities/buildJoinAggregation.ts
Normal file
174
packages/db-mongodb/src/utilities/buildJoinAggregation.ts
Normal 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
|
||||
}
|
||||
140
packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.ts
Normal file
140
packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.ts
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
82
packages/drizzle/src/queries/buildOrderBy.ts
Normal file
82
packages/drizzle/src/queries/buildOrderBy.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: '',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
39
packages/payload/src/admin/fields/Join.ts
Normal file
39
packages/payload/src/admin/fields/Join.ts
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
11
packages/payload/src/errors/InvalidFieldJoin.ts
Normal file
11
packages/payload/src/errors/InvalidFieldJoin.ts
Normal 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}'.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
85
packages/payload/src/fields/config/sanitizeJoinField.ts
Normal file
85
packages/payload/src/fields/config/sanitizeJoinField.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
91
packages/payload/src/utilities/traverseFields.ts
Normal file
91
packages/payload/src/utilities/traverseFields.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
demo/uploads
|
||||
build
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: 'فهرست نتایج ممکن است بر اساس محتوا و متناسب با کلمه کلیدی جستجو شده باشند',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -413,8 +413,8 @@ export const arTranslations: DefaultTranslationsObject = {
|
||||
problemRestoringVersion: 'حدث خطأ في استعادة هذه النّسخة',
|
||||
publish: 'نشر',
|
||||
publishChanges: 'نشر التّغييرات',
|
||||
publishIn: 'نشر في {{locale}}',
|
||||
published: 'تمّ النّشر',
|
||||
publishIn: 'نشر في {{locale}}',
|
||||
publishing: 'نشر',
|
||||
restoreAsDraft: 'استعادة كمسودة',
|
||||
restoredSuccessfully: 'تمّت الاستعادة بنحاح.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -419,8 +419,8 @@ export const bgTranslations: DefaultTranslationsObject = {
|
||||
problemRestoringVersion: 'Имаше проблем при възстановяването на тази версия',
|
||||
publish: 'Публикувай',
|
||||
publishChanges: 'Публикувай промените',
|
||||
publishIn: 'Публикувайте в {{locale}}',
|
||||
published: 'Публикувано',
|
||||
publishIn: 'Публикувайте в {{locale}}',
|
||||
publishing: 'Публикуване',
|
||||
restoreAsDraft: 'Възстанови като чернова',
|
||||
restoredSuccessfully: 'Успешно възстановяване.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -417,8 +417,8 @@ export const faTranslations: DefaultTranslationsObject = {
|
||||
problemRestoringVersion: 'مشکلی در بازیابی این نگارش وجود دارد',
|
||||
publish: 'انتشار',
|
||||
publishChanges: 'انتشار تغییرات',
|
||||
publishIn: 'منتشر کنید در {{locale}}',
|
||||
published: 'انتشار یافته',
|
||||
publishIn: 'منتشر کنید در {{locale}}',
|
||||
publishing: 'انتشار',
|
||||
restoreAsDraft: 'بازیابی به عنوان پیشنویس',
|
||||
restoredSuccessfully: 'با موفقیت بازیابی شد.',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user