feat: adds plugin-relationship-object-ids package (#6045)

This commit is contained in:
Patrik
2024-05-10 09:31:25 -04:00
committed by GitHub
parent e96ff90029
commit 4aeefc5a1a
15 changed files with 616 additions and 2 deletions

View File

@@ -1,4 +1,8 @@
import Ajv from 'ajv'
import ObjectIdImport from 'bson-objectid'
const ObjectId = (ObjectIdImport.default ||
ObjectIdImport) as unknown as typeof ObjectIdImport.default
import type { RichTextAdapter } from '../admin/types.js'
import type { Where } from '../types/index.js'
@@ -389,8 +393,12 @@ const validateFilterOptions: Validate<
const valueIDs: (number | string)[] = []
values.forEach((val) => {
if (typeof val === 'object' && val?.value) {
valueIDs.push(val.value)
if (typeof val === 'object') {
if (val?.value) {
valueIDs.push(val.value)
} else if (ObjectId.isValid(val)) {
valueIDs.push(new ObjectId(val).toHexString())
}
}
if (typeof val === 'string' || typeof val === 'number') {
@@ -441,6 +449,10 @@ const validateFilterOptions: Validate<
if (typeof val === 'string' || typeof val === 'number') {
requestedID = val
}
if (typeof val === 'object' && ObjectId.isValid(val)) {
requestedID = new ObjectId(val).toHexString()
}
}
if (Array.isArray(relationTo) && typeof val === 'object' && val?.relationTo) {

View File

@@ -0,0 +1,37 @@
/** @type {import('prettier').Config} */
module.exports = {
extends: ['@payloadcms'],
overrides: [
{
extends: ['plugin:@typescript-eslint/disable-type-checked'],
files: ['*.js', '*.cjs', '*.json', '*.md', '*.yml', '*.yaml'],
},
{
files: ['package.json', 'tsconfig.json'],
rules: {
'perfectionist/sort-array-includes': 'off',
'perfectionist/sort-astro-attributes': 'off',
'perfectionist/sort-classes': 'off',
'perfectionist/sort-enums': 'off',
'perfectionist/sort-exports': 'off',
'perfectionist/sort-imports': 'off',
'perfectionist/sort-interfaces': 'off',
'perfectionist/sort-jsx-props': 'off',
'perfectionist/sort-keys': 'off',
'perfectionist/sort-maps': 'off',
'perfectionist/sort-named-exports': 'off',
'perfectionist/sort-named-imports': 'off',
'perfectionist/sort-object-types': 'off',
'perfectionist/sort-objects': 'off',
'perfectionist/sort-svelte-attributes': 'off',
'perfectionist/sort-union-types': 'off',
'perfectionist/sort-vue-attributes': 'off',
},
},
],
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
root: true,
}

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
# 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.
#### Usage
Simply import and install the plugin to make it work:
```ts
import { relationshipsAsObjectID } from '@payloadcms/plugin-relationship-object-ids'
import { buildConfig } from 'payload/config'
export default buildConfig({
// your config here
plugins: [
// Call the plugin within your `plugins` array
relationshipsAsObjectID(),
],
})
```
### Migration
Note - this plugin will only store newly created or resaved documents' relations as `ObjectID`s. It will not modify any of your existing data. If you'd like to convert existing data into an `ObjectID` format, you should write a migration script to loop over all documents in your database and then simply resave each one.
### Support
If you need help with this plugin, [join our Discord](https://t.co/30APlsQUPB) and we'd be happy to give you a hand.

View File

@@ -0,0 +1,56 @@
{
"name": "@payloadcms/plugin-relationship-object-ids",
"version": "3.0.0-beta.18",
"homepage:": "https://payloadcms.com",
"repository": {
"type": "git",
"url": "https://github.com/payloadcms/payload.git",
"directory": "packages/plugin-relationship-object-ids"
},
"description": "A Payload plugin to store all relationship IDs as ObjectIDs",
"main": "./src/index.ts",
"types": "./src/index.ts",
"type": "module",
"scripts": {
"build": "pnpm copyfiles && pnpm build:swc && pnpm build:types",
"build:swc": "swc ./src -d ./dist --config-file .swcrc",
"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/",
"prepublishOnly": "pnpm clean && pnpm turbo build"
},
"author": "dev@payloadcms.com",
"license": "MIT",
"peerDependencies": {
"payload": "workspace:*",
"mongoose": "6.12.3"
},
"files": [
"dist",
"*.js",
"*.d.ts",
"!.prettierrc.js"
],
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"payload": "workspace:*"
},
"exports": {
".": {
"import": "./src/index.ts",
"require": "./src/index.ts",
"types": "./src/index.ts"
}
},
"publishConfig": {
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
}
}

View File

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

View File

@@ -0,0 +1,80 @@
import type { Config } from 'payload/config'
import type { Field } from 'payload/types'
import { getBeforeChangeHook } from './hooks/beforeChange.js'
const traverseFields = ({ config, fields }: { config: Config; fields: Field[] }): Field[] => {
return fields.map((field) => {
if (field.type === 'relationship' || field.type === 'upload') {
return {
...field,
hooks: {
...(field.hooks || {}),
beforeChange: [
...(field.hooks?.beforeChange || []),
getBeforeChangeHook({ config, field }),
],
},
}
}
if ('fields' in field) {
return {
...field,
fields: traverseFields({ config, fields: field.fields }),
}
}
if (field.type === 'tabs') {
return {
...field,
tabs: field.tabs.map((tab) => {
return {
...tab,
fields: traverseFields({ config, fields: tab.fields }),
}
}),
}
}
if (field.type === 'blocks') {
return {
...field,
blocks: field.blocks.map((block) => {
return {
...block,
fields: traverseFields({ config, fields: block.fields }),
}
}),
}
}
return field
})
}
export const relationshipsAsObjectID =
(/** Possible args in the future */) =>
(config: Config): Config => {
return {
...config,
collections: (config.collections || []).map((collection) => {
return {
...collection,
fields: traverseFields({
config,
fields: collection.fields,
}),
}
}),
globals: (config.globals || []).map((global) => {
return {
...global,
fields: traverseFields({
config,
fields: global.fields,
}),
}
}),
}
}

View File

@@ -0,0 +1,24 @@
{
"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",
".eslintrc.cjs",
"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" }]
}

16
pnpm-lock.yaml generated
View File

@@ -1075,6 +1075,19 @@ importers:
specifier: workspace:*
version: link:../payload
packages/plugin-relationship-object-ids:
dependencies:
mongoose:
specifier: 6.12.3
version: 6.12.3
devDependencies:
'@payloadcms/eslint-config':
specifier: workspace:*
version: link:../eslint-config-payload
payload:
specifier: workspace:*
version: link:../payload
packages/plugin-search:
dependencies:
'@payloadcms/ui':
@@ -1619,6 +1632,9 @@ importers:
'@payloadcms/plugin-redirects':
specifier: workspace:*
version: link:../packages/plugin-redirects
'@payloadcms/plugin-relationship-object-ids':
specifier: workspace:*
version: link:../packages/plugin-relationship-object-ids
'@payloadcms/plugin-search':
specifier: workspace:*
version: link:../packages/plugin-search

View File

@@ -26,6 +26,7 @@
"@payloadcms/plugin-form-builder": "workspace:*",
"@payloadcms/plugin-nested-docs": "workspace:*",
"@payloadcms/plugin-redirects": "workspace:*",
"@payloadcms/plugin-relationship-object-ids": "workspace:*",
"@payloadcms/plugin-search": "workspace:*",
"@payloadcms/plugin-sentry": "workspace:*",
"@payloadcms/plugin-seo": "workspace:*",

View File

@@ -0,0 +1 @@
uploads

View File

@@ -0,0 +1,129 @@
import { relationshipsAsObjectID } from '@payloadcms/plugin-relationship-object-ids'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
export default buildConfigWithDefaults({
collections: [
{
slug: 'uploads',
upload: true,
fields: [],
},
{
slug: 'pages',
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
},
{
slug: 'posts',
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
},
{
slug: 'relations',
fields: [
{
name: 'hasOne',
type: 'relationship',
relationTo: 'posts',
filterOptions: ({ id }) => ({ id: { not_equals: id } }),
},
{
name: 'hasOnePoly',
type: 'relationship',
relationTo: ['pages', 'posts'],
},
{
name: 'hasMany',
type: 'relationship',
relationTo: 'posts',
hasMany: true,
},
{
name: 'hasManyPoly',
type: 'relationship',
relationTo: ['pages', 'posts'],
hasMany: true,
},
{
name: 'upload',
type: 'upload',
relationTo: 'uploads',
},
],
},
],
plugins: [relationshipsAsObjectID()],
onInit: async (payload) => {
if (payload.db.name === 'mongoose') {
await payload.create({
collection: 'users',
data: {
email: 'dev@payloadcms.com',
password: 'test',
},
})
const page = await payload.create({
collection: 'pages',
data: {
title: 'page',
},
})
const post1 = await payload.create({
collection: 'posts',
data: {
title: 'post 1',
},
})
const post2 = await payload.create({
collection: 'posts',
data: {
title: 'post 2',
},
})
const upload = await payload.create({
collection: 'uploads',
data: {},
filePath: path.resolve(__dirname, './payload-logo.png'),
})
await payload.create({
collection: 'relations',
depth: 0,
data: {
hasOne: post1.id,
hasOnePoly: { relationTo: 'pages', value: page.id },
hasMany: [post1.id, post2.id],
hasManyPoly: [
{ relationTo: 'posts', value: post1.id },
{ relationTo: 'pages', value: page.id },
],
upload: upload.id,
},
})
await payload.create({
collection: 'relations',
depth: 0,
data: {
hasOnePoly: { relationTo: 'pages', value: page.id },
},
})
}
},
})

View File

@@ -0,0 +1,107 @@
/* eslint-disable jest/no-if */
import type { Payload } from 'payload'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import configPromise from './config.js'
describe('Relationship Object IDs Plugin', () => {
let relations: any
let posts: any
let payload: Payload
beforeAll(async () => {
;({ payload } = await initPayloadInt(configPromise))
})
it('seeds data accordingly', async () => {
if (payload.db.name === 'mongoose') {
const relationsQuery = await payload.find({
collection: 'relations',
sort: 'createdAt',
})
relations = relationsQuery.docs
const postsQuery = await payload.find({
collection: 'posts',
sort: 'createdAt',
})
posts = postsQuery.docs
expect(relationsQuery.totalDocs).toEqual(2)
expect(postsQuery.totalDocs).toEqual(2)
}
})
it('stores relations as object ids', async () => {
// eslint-disable-next-line jest/no-if
if (payload.db.name === 'mongoose') {
const docs = await payload.db.collections.relations.find()
expect(typeof docs[0].hasOne).toBe('object')
expect(typeof docs[0].hasOnePoly.value).toBe('object')
expect(typeof docs[0].hasMany[0]).toBe('object')
expect(typeof docs[0].hasManyPoly[0].value).toBe('object')
expect(typeof docs[0].upload).toBe('object')
}
})
it('can query by relationship id', async () => {
if (payload.db.name === 'mongoose') {
const { totalDocs } = await payload.find({
collection: 'relations',
where: {
hasOne: {
equals: posts[0].id,
},
},
})
expect(totalDocs).toStrictEqual(1)
}
})
it('populates relations', () => {
if (payload.db.name === 'mongoose') {
const populatedPostTitle =
// eslint-disable-next-line jest/no-if
typeof relations[0].hasOne === 'object' ? relations[0].hasOne.title : undefined
expect(populatedPostTitle).toBeDefined()
const populatedUploadFilename =
typeof relations[0].upload === 'object' ? relations[0].upload.filename : undefined
expect(populatedUploadFilename).toBeDefined()
}
})
it('can query by nested property', async () => {
if (payload.db.name === 'mongoose') {
const { totalDocs } = await payload.find({
collection: 'relations',
where: {
'hasOne.title': {
equals: 'post 1',
},
},
})
expect(totalDocs).toStrictEqual(1)
}
})
it('can query using the "in" operator', async () => {
if (payload.db.name === 'mongoose') {
const { totalDocs } = await payload.find({
collection: 'relations',
where: {
hasMany: {
in: [posts[0].id],
},
},
})
expect(totalDocs).toStrictEqual(1)
}
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB