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:
@@ -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 = {
|
||||
globalType: slug,
|
||||
...data,
|
||||
}
|
||||
|
||||
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,22 +27,25 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
|
||||
const VersionModel = this.versions[globalSlug]
|
||||
const options = await withSession(this, req)
|
||||
|
||||
const [doc] = await VersionModel.create(
|
||||
[
|
||||
{
|
||||
autosave,
|
||||
createdAt,
|
||||
latest: true,
|
||||
parent,
|
||||
publishedLocale,
|
||||
snapshot,
|
||||
updatedAt,
|
||||
version: versionData,
|
||||
},
|
||||
],
|
||||
options,
|
||||
req,
|
||||
)
|
||||
const data = sanitizeRelationshipIDs({
|
||||
config: this.payload.config,
|
||||
data: {
|
||||
autosave,
|
||||
createdAt,
|
||||
latest: true,
|
||||
parent,
|
||||
publishedLocale,
|
||||
snapshot,
|
||||
updatedAt,
|
||||
version: versionData,
|
||||
},
|
||||
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,22 +27,25 @@ export const createVersion: CreateVersion = async function createVersion(
|
||||
const VersionModel = this.versions[collectionSlug]
|
||||
const options = await withSession(this, req)
|
||||
|
||||
const [doc] = await VersionModel.create(
|
||||
[
|
||||
{
|
||||
autosave,
|
||||
createdAt,
|
||||
latest: true,
|
||||
parent,
|
||||
publishedLocale,
|
||||
snapshot,
|
||||
updatedAt,
|
||||
version: versionData,
|
||||
},
|
||||
],
|
||||
options,
|
||||
req,
|
||||
)
|
||||
const data = sanitizeRelationshipIDs({
|
||||
config: this.payload.config,
|
||||
data: {
|
||||
autosave,
|
||||
createdAt,
|
||||
latest: true,
|
||||
parent,
|
||||
publishedLocale,
|
||||
snapshot,
|
||||
updatedAt,
|
||||
version: versionData,
|
||||
},
|
||||
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,21 +29,49 @@ export const sanitizeQueryValue = ({
|
||||
let formattedOperator = operator
|
||||
|
||||
// Disregard invalid _ids
|
||||
if (path === '_id' && typeof val === 'string' && val.split(',').length === 1) {
|
||||
if (!hasCustomID) {
|
||||
const isValid = mongoose.Types.ObjectId.isValid(val)
|
||||
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 }
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
const parsedNumber = parseFloat(val)
|
||||
if (field.type === 'number') {
|
||||
const parsedNumber = parseFloat(val)
|
||||
|
||||
if (Number.isNaN(parsedNumber)) {
|
||||
return { operator: formattedOperator, val: undefined }
|
||||
if (Number.isNaN(parsedNumber)) {
|
||||
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]
|
||||
}, [])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user