├── .gitignore ├── LICENSE.md ├── README.md ├── babel.config.js ├── demo └── payload-backpop-demo │ ├── .gitignore │ ├── .npmrc │ ├── Dockerfile │ ├── README.md │ ├── docker-compose.yml │ ├── example.env │ ├── nodemon.json │ ├── package.json │ ├── src │ ├── collections │ │ ├── Bar.collection.ts │ │ ├── Baz.collection.ts │ │ ├── Foo.collection.ts │ │ └── Users.ts │ ├── payload.config.ts │ └── server.ts │ └── tsconfig.json ├── jest.config.js ├── package.json ├── src ├── hooks │ ├── backpopulate-cleanup-polymorphic.hook.ts │ ├── backpopulate-cleanup.hook.ts │ ├── backpopulate-polymorphic.hook.ts │ ├── backpopulate.hook.ts │ └── backpopulate.ts ├── index.ts ├── pluginConfig.ts ├── tests │ ├── plugin │ │ └── plugin.spec.ts │ ├── polymorphic │ │ ├── payload-config.ts │ │ └── polymorphic.spec.ts │ ├── setup.ts │ └── simple │ │ ├── payload-config.ts │ │ └── simple.spec.ts └── types.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2022 Tim Hallyburton 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pcms-backpop 2 | === 3 | 4 | A [payloadcms](https://github.com/payloadcms/payload) plugin for backpropagated relationships. 5 | 6 | ## Motivation 7 | 8 | PayloadCMS is one of the fastest-growing, most developer friendly and largely unopinionated headless CMS for Web, Mobile and other applications. 9 | Given that it was built on MongoDB it allows for unstructured data to be connected on document level, much like you would imagine a directed graph. 10 | While this approach is very flexible and works well with their advanced query interfaces it can be expensive to traverse relationships in the opposite 11 | direction. Assume collection `A` holds a many-to-many relationship to collection `B`. 12 | To find out every `B` given an `A` you can simply query the relationship field and the operation should be linear in the number of `B` documents. The 13 | composite query however (every `A` documents, given `B`) will be much slower as for every document `A` each related `B` needs to be compared with the 14 | query element and this complexity worsens with the number of related object types in the query input. Much of this issue is compensated with database-level 15 | optimizations and powerful hardware for now, however, it is to be expected that for large datasets the problem will be noticeable. 16 | 17 | There is another piece of motivation at work too: Developer Experience. If the `Project` collection holds a relationship to `Person`, 18 | indicating the collaborators of a given project, then it is obvious how to get the set of persons involved in a project, but to find all projects a given 19 | person was working on you need to fabricate a find-query with reversed lookup on the collaborators-field. By using this plugin I hope to make the 20 | inverse relationship just as straight forward. 21 | 22 | ## State of the project 23 | 24 | The current state can be best described as ***do not try this at home or at all***, especiall NOT IN PRODUCTION. 25 | While progress is not as fast as I hoped, it is steady and I am pushing for an initial stable release soon. 26 | 27 | As of now you can: 28 | - Check out the project and manually test backpropagation in the payload admin dashboard 29 | - Have simple relationships backpropagated 30 | - Have polymorphic relationships backpropagated 31 | - Clean up simple relationships 32 | - Clean up polymorphic relationships 33 | 34 | ## Roadmap 35 | 36 | ### Pre-Alpha ⬅️ You are here 37 | - [x] Simple relationships 38 | - [x] Clean up simple relationships 39 | - [x] Polymorphic relationships 40 | - [x] Clean up polymorphic relationships 41 | - [ ] Testing with jest 42 | 43 | ### Beta 44 | - [ ] Measure performance gains on large collections 45 | - [ ] Documentation 46 | - [ ] Re-structure project (extract the plugin, help would be appreciated) 47 | - [ ] Allow global configuration 48 | 49 | ### v1.0 50 | - [ ] Polished Demo case 51 | - [ ] Field level configuration 52 | 53 | ## Contributors 54 | A big shoutout to [AlessioGr](https://github.com/AlessioGr) for his overall contributions to payload which indirectly made this plugin 55 | more efficient by introducing the `originalDoc` field in `AfterChange` events 🎉 56 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /demo/payload-backpop-demo/.gitignore: -------------------------------------------------------------------------------- 1 | ### Node ### 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # Snowpack dependency directory (https://snowpack.dev/) 47 | web_modules/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional stylelint cache 59 | .stylelintcache 60 | 61 | # Microbundle cache 62 | .rpt2_cache/ 63 | .rts2_cache_cjs/ 64 | .rts2_cache_es/ 65 | .rts2_cache_umd/ 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variable files 77 | .env 78 | .env.development.local 79 | .env.test.local 80 | .env.production.local 81 | .env.local 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | .parcel-cache 86 | 87 | # Next.js build output 88 | .next 89 | out 90 | 91 | # Nuxt.js build / generate output 92 | .nuxt 93 | dist 94 | 95 | # Gatsby files 96 | .cache/ 97 | # Comment in the public line in if your project uses Gatsby and not Next.js 98 | # https://nextjs.org/blog/next-9-1#public-directory-support 99 | # public 100 | 101 | # vuepress build output 102 | .vuepress/dist 103 | 104 | # vuepress v2.x temp and cache directory 105 | .temp 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | ### Node Patch ### 133 | # Serverless Webpack directories 134 | .webpack/ 135 | 136 | # Optional stylelint cache 137 | 138 | # SvelteKit build / generate output 139 | .svelte-kit 140 | 141 | ### VisualStudioCode ### 142 | .vscode/* 143 | !.vscode/settings.json 144 | !.vscode/tasks.json 145 | !.vscode/launch.json 146 | !.vscode/extensions.json 147 | !.vscode/*.code-snippets 148 | 149 | # Local History for Visual Studio Code 150 | .history/ 151 | 152 | # Built Visual Studio Code Extensions 153 | *.vsix 154 | 155 | ### VisualStudioCode Patch ### 156 | # Ignore all local history of files 157 | .history 158 | .ionide 159 | 160 | # Support for Project snippet scope 161 | .vscode/*.code-snippets 162 | 163 | # Ignore code-workspaces 164 | *.code-workspace 165 | 166 | # End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode 167 | -------------------------------------------------------------------------------- /demo/payload-backpop-demo/.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /demo/payload-backpop-demo/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.8-alpine as base 2 | 3 | FROM base as builder 4 | 5 | WORKDIR /home/node/app 6 | COPY package*.json ./ 7 | 8 | COPY . . 9 | RUN yarn install 10 | RUN yarn build 11 | 12 | FROM base as runtime 13 | 14 | ENV NODE_ENV=production 15 | ENV PAYLOAD_CONFIG_PATH=dist/payload.config.js 16 | 17 | WORKDIR /home/node/app 18 | COPY package*.json ./ 19 | 20 | RUN yarn install --production 21 | COPY --from=builder /home/node/app/dist ./dist 22 | COPY --from=builder /home/node/app/build ./build 23 | 24 | EXPOSE 3000 25 | 26 | CMD ["node", "dist/server.js"] 27 | -------------------------------------------------------------------------------- /demo/payload-backpop-demo/README.md: -------------------------------------------------------------------------------- 1 | # payload-backpop-demo 2 | 3 | This project was created using create-payload-app using the blank template. 4 | 5 | ## How to Use 6 | 7 | `yarn dev` will start up your application and reload on any changes. 8 | 9 | ### Docker 10 | 11 | If you have docker and docker-compose installed, you can run `docker-compose up` 12 | 13 | To build the docker image, run `docker build -t my-tag .` 14 | 15 | Ensure you are passing all needed environment variables when starting up your container via `--env-file` or setting them with your deployment. 16 | 17 | The 3 typical env vars will be `MONGODB_URI`, `PAYLOAD_SECRET`, and `PAYLOAD_CONFIG_PATH` 18 | 19 | `docker run --env-file .env -p 3000:3000 my-tag` 20 | -------------------------------------------------------------------------------- /demo/payload-backpop-demo/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | 5 | payload: 6 | image: node:18-alpine 7 | ports: 8 | - "3000:3000" 9 | volumes: 10 | - .:/home/node/app 11 | - ../../dist:/home/node/app/plugin 12 | working_dir: /home/node/app/ 13 | command: sh -c "rm -rf ./node_modules && yarn install && yarn dev" 14 | depends_on: 15 | - mongo 16 | environment: 17 | MONGODB_URI: mongodb://mongo:27017/payload 18 | PORT: 3000 19 | NODE_ENV: development 20 | PAYLOAD_SECRET: TESTING 21 | 22 | mongo: 23 | image: mongo:latest 24 | ports: 25 | - "27017:27017" 26 | command: 27 | - --storageEngine=wiredTiger 28 | volumes: 29 | - data:/data/db 30 | logging: 31 | driver: none 32 | 33 | volumes: 34 | data: 35 | node_modules: 36 | -------------------------------------------------------------------------------- /demo/payload-backpop-demo/example.env: -------------------------------------------------------------------------------- 1 | MONGODB_URI=mongodb://localhost/payload-backpop-demo 2 | PAYLOAD_SECRET=b6358eb5514018a6e30e129b 3 | 4 | -------------------------------------------------------------------------------- /demo/payload-backpop-demo/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ext": "ts", 3 | "exec": "ts-node src/server.ts" 4 | } 5 | -------------------------------------------------------------------------------- /demo/payload-backpop-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-backpop-demo", 3 | "description": "Payload project created from blank template", 4 | "version": "1.0.0", 5 | "main": "dist/server.js", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon", 9 | "build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build", 10 | "build:server": "tsc", 11 | "build": "yarn copyfiles && yarn build:payload && yarn build:server", 12 | "serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js", 13 | "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/", 14 | "generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types", 15 | "generate:graphQLSchema": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema" 16 | }, 17 | "dependencies": { 18 | "dotenv": "^8.2.0", 19 | "express": "^4.17.1", 20 | "payload": "^1.6.16", 21 | "payload-backpop": "file:./plugin" 22 | }, 23 | "devDependencies": { 24 | "@types/express": "^4.17.17", 25 | "copyfiles": "^2.4.1", 26 | "cross-env": "^7.0.3", 27 | "nodemon": "^2.0.6", 28 | "ts-node": "^9.1.1", 29 | "typescript": "^4.8.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /demo/payload-backpop-demo/src/collections/Bar.collection.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from "payload/types"; 2 | 3 | const Bar: CollectionConfig = { 4 | slug: 'bar', 5 | admin: { 6 | useAsTitle: 'name', 7 | }, 8 | fields: [ 9 | { 10 | name: 'name', 11 | type: 'text', 12 | required: true, 13 | }, 14 | 15 | ] 16 | 17 | } 18 | 19 | export default Bar; -------------------------------------------------------------------------------- /demo/payload-backpop-demo/src/collections/Baz.collection.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from "payload/types"; 2 | 3 | const Baz: CollectionConfig = { 4 | slug: 'baz', 5 | admin: { 6 | useAsTitle: 'name', 7 | }, 8 | fields: [ 9 | { 10 | name: 'name', 11 | type: 'text', 12 | required: true, 13 | }, 14 | 15 | ] 16 | 17 | } 18 | 19 | export default Baz; -------------------------------------------------------------------------------- /demo/payload-backpop-demo/src/collections/Foo.collection.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from "payload/types"; 2 | import backpopulate from "../hooks/backpopulate"; 3 | 4 | 5 | const Foo: CollectionConfig = { 6 | slug: 'foo', 7 | admin: { 8 | useAsTitle: 'name', 9 | }, 10 | fields: [ 11 | { 12 | name: 'name', 13 | type: 'text', 14 | required: true, 15 | }, 16 | { 17 | name: 'bars', 18 | type: 'relationship', 19 | relationTo: 'bar', 20 | hasMany: true, 21 | hooks: { 22 | afterChange: [backpopulate], 23 | } 24 | }, 25 | { 26 | name: 'bars_or_bazzes', 27 | type: 'relationship', 28 | relationTo: ['bar', 'baz'], 29 | hasMany: true, 30 | hooks: { 31 | afterChange: [backpopulate], 32 | } 33 | } 34 | ] 35 | 36 | } 37 | 38 | export default Foo; -------------------------------------------------------------------------------- /demo/payload-backpop-demo/src/collections/Users.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from 'payload/types'; 2 | 3 | const Users: CollectionConfig = { 4 | slug: 'users', 5 | auth: true, 6 | admin: { 7 | useAsTitle: 'email', 8 | }, 9 | access: { 10 | read: () => true, 11 | }, 12 | fields: [ 13 | // Email added by default 14 | // Add more fields as needed 15 | ], 16 | }; 17 | 18 | export default Users; -------------------------------------------------------------------------------- /demo/payload-backpop-demo/src/payload.config.ts: -------------------------------------------------------------------------------- 1 | import { buildConfig } from "payload/config"; 2 | import path from "path"; 3 | import Users from "./collections/Users"; 4 | import Foo from "./collections/Foo.collection"; 5 | import Bar from "./collections/Bar.collection"; 6 | import Baz from "./collections/Baz.collection"; 7 | import BackpopulatedRelationshipsPlugin from "./backpopulated-relationship.plugin"; 8 | 9 | const config = { 10 | serverURL: "http://localhost:3000", 11 | admin: { 12 | user: Users.slug, 13 | }, 14 | collections: [Users, Foo, Bar, Baz], 15 | typescript: { 16 | outputFile: path.resolve(__dirname, "payload-types.ts"), 17 | }, 18 | graphQL: { 19 | schemaOutputFile: path.resolve(__dirname, "generated-schema.graphql"), 20 | }, 21 | plugins: [BackpopulatedRelationshipsPlugin], 22 | 23 | }; 24 | export { config }; 25 | export default buildConfig(config); 26 | -------------------------------------------------------------------------------- /demo/payload-backpop-demo/src/server.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import payload from 'payload'; 3 | 4 | require('dotenv').config(); 5 | const app = express(); 6 | 7 | // Redirect root to Admin panel 8 | app.get('/', (_, res) => { 9 | res.redirect('/admin'); 10 | }); 11 | 12 | console.log(process.env) 13 | // Initialize Payload 14 | payload.init({ 15 | secret: process.env.PAYLOAD_SECRET, 16 | mongoURL: process.env.MONGODB_URI, 17 | express: app, 18 | onInit: () => { 19 | payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`) 20 | }, 21 | }) 22 | 23 | // Add your own express routes here 24 | 25 | app.listen(3000); 26 | -------------------------------------------------------------------------------- /demo/payload-backpop-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "moduleResolution": "nodenext", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "strict": false, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "outDir": "./dist", 15 | "rootDir": "./src", 16 | "jsx": "react", 17 | "paths": { 18 | "payload/generated-types": [ 19 | "./src/payload-types.ts", 20 | ], 21 | } 22 | }, 23 | "include": [ 24 | "src" 25 | ], 26 | "exclude": [ 27 | "node_modules", 28 | "dist", 29 | "build", 30 | ], 31 | "ts-node": { 32 | "transpileOnly": true, 33 | "swc": true, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | testEnvironment: 'node', 4 | globalSetup: '/src/tests/setup.ts', 5 | roots: ['/src/'], 6 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-backpop", 3 | "version": "0.1.0", 4 | "description": "A payloadcms plugin to automatically translate documents.", 5 | "author": "Tim Hallyburton ", 6 | "license": "MIT", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "scripts": { 10 | "build": "tsc && cp package.json dist/", 11 | "test": "cross-env NODE_ENV=test jest --forceExit --detectOpenHandles" 12 | }, 13 | "devDependencies": { 14 | "@babel/core": "^7.21.0", 15 | "@babel/preset-env": "^7.20.2", 16 | "@babel/preset-typescript": "^7.21.0", 17 | "@types/express": "^4.17.17", 18 | "@types/jest": "^29.4.0", 19 | "babel-jest": "^29.5.0", 20 | "isomorphic-fetch": "^3.0.0", 21 | "jest": "^29.5.0", 22 | "payload": "^1.6.19", 23 | "typescript": "^4.9.5", 24 | "uuid": "^9.0.0" 25 | }, 26 | "dependencies": { 27 | "cross-env": "^7.0.3", 28 | "mongodb-memory-server": "^8.12.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/hooks/backpopulate-cleanup-polymorphic.hook.ts: -------------------------------------------------------------------------------- 1 | import payload from "payload"; 2 | import { AfterDeleteHook } from "payload/dist/collections/config/types"; 3 | import { FieldHook } from "payload/types"; 4 | 5 | export interface BackpopulateCleanupHookArgs { 6 | source_field: string; 7 | target_slug: string; 8 | target_field: string; 9 | } 10 | 11 | //When the parent field is deleted 12 | export const backpopulatePolymorphicCleanupHookFactory = ({ 13 | source_field, 14 | target_field, 15 | target_slug, 16 | }: BackpopulateCleanupHookArgs): AfterDeleteHook => { 17 | const cleanupHook = async ({ req, id, doc }) => { 18 | // query all documents which have a relationship to this document 19 | let value = doc[source_field] ?? []; 20 | 21 | const affected_slugs: string[] = Array.from( 22 | new Set(value.map((el) => el.relationTo)) 23 | ); 24 | 25 | for (const slug of affected_slugs) { 26 | for (const affected_document_id of value 27 | .filter((el) => el.relationTo === slug) 28 | .map((el) => el.value)) { 29 | // we hold a reference to these documents, just remove our own id and we're good 30 | // we still need to query the documents in order to retain all other back references 31 | const affected_doc = await payload.findByID({ 32 | collection: slug, 33 | id: affected_document_id, 34 | overrideAccess: true, 35 | depth: 0, 36 | }); 37 | 38 | const prev_references = affected_doc[target_field]; 39 | 40 | await payload.update({ 41 | collection: slug, 42 | id: affected_document_id, 43 | data: { 44 | [target_field]: prev_references.filter((el) => el !== doc.id), 45 | }, 46 | overrideAccess: true, 47 | depth: 0, 48 | }); 49 | } 50 | } 51 | }; 52 | 53 | return cleanupHook; 54 | }; 55 | -------------------------------------------------------------------------------- /src/hooks/backpopulate-cleanup.hook.ts: -------------------------------------------------------------------------------- 1 | import payload from "payload"; 2 | import { AfterDeleteHook } from "payload/dist/collections/config/types"; 3 | import { FieldHook, PayloadRequest } from "payload/types"; 4 | 5 | export interface BackpopulateCleanupHookArgs { 6 | source_field: string; 7 | target_slug: string; 8 | target_field: string; 9 | } 10 | 11 | //When the parent field is deleted 12 | export const backpopulateCleanupHookFactory = ({ 13 | source_field, 14 | target_field, 15 | target_slug, 16 | }: BackpopulateCleanupHookArgs): AfterDeleteHook => { 17 | const cleanupHook = async ({ doc }: { doc: any }) => { 18 | // query all documents which have a relationship to this document 19 | let value = doc[source_field] ? doc[source_field] : []; 20 | 21 | if (!Array.isArray(value)) { 22 | value = [value]; 23 | } 24 | 25 | if (value && value.length >= 1 && value[0].value) { 26 | let newValue = []; 27 | for (const valueEntry of value) { 28 | newValue.push(valueEntry.value); 29 | } 30 | value = newValue; 31 | } 32 | 33 | for (let targetId of value) { 34 | const targetDocument = await payload.findByID({ 35 | collection: target_slug, 36 | id: targetId, 37 | }); 38 | if (!targetDocument) { 39 | continue; 40 | } 41 | // get the current backrefs 42 | const prevReferences = targetDocument[target_field].map( 43 | (ref: any) => ref.id 44 | ); 45 | 46 | // remove self from backrefs 47 | await payload.update({ 48 | collection: target_slug, 49 | id: targetId, 50 | overrideAccess: true, 51 | data: { 52 | [target_field]: prevReferences.filter( 53 | (id: string) => id && id !== doc.id 54 | ), 55 | }, 56 | }); 57 | } 58 | }; 59 | 60 | return cleanupHook; 61 | }; 62 | 63 | //When the backpopulated field is deleted 64 | export const parentCleanupHookFactory = ({ 65 | source_field, 66 | target_field, 67 | target_slug, 68 | }: BackpopulateCleanupHookArgs): AfterDeleteHook => { 69 | const cleanupHook = async ({ 70 | req: id, 71 | doc, 72 | }: { 73 | req: PayloadRequest; 74 | id: string | number; 75 | doc: any; 76 | }) => { 77 | // query all documents which have a relationship to this document 78 | let value = doc[source_field] ? doc[source_field] : []; 79 | if (value && value.length >= 1 && value[0].value) { 80 | let newValue = []; 81 | for (const valueEntry of value) { 82 | newValue.push(valueEntry.value); 83 | } 84 | value = newValue; 85 | } 86 | 87 | for (let targetId of value) { 88 | const targetDocument = await payload.findByID({ 89 | collection: target_slug, 90 | id: targetId, 91 | }); 92 | if (!targetDocument) { 93 | continue; 94 | } 95 | 96 | // get the current backrefs 97 | const prevReferences = targetDocument[target_field].map((ref: any) => 98 | ref.id ? ref.id : ref.value.id ? ref.value.id : ref.value 99 | ); 100 | 101 | let updatedReferenceIds = []; 102 | updatedReferenceIds = prevReferences.filter((ref: any) => { 103 | return (ref.id ? ref.id : ref) !== id; //Sometimes doc is the id, sometimes doc.id is the id 104 | }); 105 | 106 | // remove self from backrefs 107 | await payload.update({ 108 | collection: target_slug, 109 | id: targetId, 110 | overrideAccess: true, 111 | data: { 112 | [target_field]: updatedReferenceIds, //TODO: Doesnt work. Not properly removed from parent relaitonship yet (esp if parents field is array-like relaitonship, non-poly of course) 113 | }, 114 | }); 115 | } 116 | }; 117 | 118 | return cleanupHook; 119 | }; 120 | -------------------------------------------------------------------------------- /src/hooks/backpopulate-polymorphic.hook.ts: -------------------------------------------------------------------------------- 1 | import payload from "payload"; 2 | import { Field, FieldHook } from "payload/types"; 3 | import { PolymorphicHookArgs } from "../types"; 4 | 5 | export const backpopulatePolymorphicHookFactory = ({ 6 | primaryCollection, 7 | targetCollection, 8 | backpopulatedField, 9 | }: PolymorphicHookArgs) => { 10 | const hook: FieldHook = async (args) => { 11 | const { operation, originalDoc, value, previousValue } = args; 12 | 13 | if (operation === "create" || operation === "update") { 14 | if (value === undefined || value === null) { 15 | return; 16 | } 17 | 18 | // comparing JSON representation is the easiest approach here 19 | const str_value = value.map(JSON.stringify); 20 | const str_value_prev = previousValue 21 | ? previousValue.map(JSON.stringify) 22 | : []; 23 | 24 | const removed_targets = [...str_value_prev] 25 | .filter((x) => !str_value.includes(x)) 26 | .map((str) => JSON.parse(str)); 27 | 28 | const added_targets = str_value 29 | .filter((x: string) => !str_value_prev.includes(x)) 30 | .map((str: string) => JSON.parse(str)); 31 | 32 | /** 33 | * At this point we can update the affected collections. 34 | * Thanks to the previousDoc this is much more efficient now. 35 | * 36 | * At first, aggregate all collections by their slugs of affected data, 37 | * later on we streamline the update process for simplicity. 38 | */ 39 | 40 | const affected_slugs = new Set( 41 | [...added_targets, ...removed_targets].map((el) => el.relationTo) 42 | ); 43 | 44 | // using an extra conversion to array here for compatibility 45 | for (const slug of Array.from(affected_slugs)) { 46 | // we can now get all affected documents in one go - this increases performance 47 | const affected_documents = ( 48 | await payload.find({ 49 | collection: slug, 50 | overrideAccess: true, 51 | depth: 0, 52 | limit: 100000, 53 | pagination: false, 54 | where: { 55 | id: { 56 | in: [...added_targets, ...removed_targets] 57 | .filter((el) => el.relationTo === slug) 58 | .map((el) => el.value), 59 | }, 60 | }, 61 | }) 62 | ).docs; 63 | 64 | // reduce the added_items to their ids, then check against those and remove the document from all other affected_documents 65 | // just a minor performance improvement but it saves one extra step 66 | const added_target_ids = added_targets 67 | .filter((el: any) => el.relationTo === slug) 68 | .map((el: any) => el.value); 69 | for (const affected_document of affected_documents) { 70 | affected_document[backpopulatedField["name"]] ??= []; 71 | const references = affected_document[backpopulatedField["name"]]; 72 | let updated_references = []; 73 | if (added_target_ids.includes(affected_document.id)) { 74 | updated_references = Array.from( 75 | new Set([...references, originalDoc.id]) 76 | ); 77 | } else { 78 | updated_references = references.filter( 79 | (el: any) => el !== originalDoc.id 80 | ); 81 | } 82 | 83 | // finally, update the affected document 84 | await payload.update({ 85 | collection: slug, 86 | id: affected_document.id, 87 | overrideAccess: true, 88 | data: { 89 | [backpopulatedField["name"]]: updated_references, 90 | }, 91 | depth: 0, 92 | }); 93 | } 94 | } 95 | } 96 | return; 97 | }; 98 | 99 | return hook; 100 | }; 101 | 102 | export default backpopulatePolymorphicHookFactory; 103 | -------------------------------------------------------------------------------- /src/hooks/backpopulate.hook.ts: -------------------------------------------------------------------------------- 1 | import payload from "payload"; 2 | import { FieldHook } from "payload/types"; 3 | import { SimpleHookArgs } from "../types"; 4 | 5 | export const backpopulateAfterChangeHookFactory = ({ 6 | //If value is added or updated from relationship? 7 | targetCollection, 8 | backpopulatedField, 9 | originalField, 10 | }: SimpleHookArgs) => { 11 | const hook: FieldHook = async (args) => { 12 | const { operation, originalDoc, value, previousValue } = args; 13 | 14 | if (operation === "create" || operation === "update") { 15 | if (value === undefined || value === null) { 16 | // This should never happen, but better safe than sorry. 17 | return; 18 | } 19 | 20 | //If the relationTo "value" is an array with length 1: Usually: Value [ '6307772a5aa9f04ab75df7d4' ] with this: [ { relationTo: 'gear-component', value: '6307772a5aa9f04ab75df7d4' } ] 21 | 22 | const removedTargetIds = previousValue 23 | ? [...previousValue].filter((x) => !value.includes(x)) 24 | : []; 25 | 26 | const addedTargetIds = value.filter( 27 | (x: unknown) => !(previousValue ?? []).includes(x) 28 | ); 29 | 30 | const documentsToRemoveBackPop = 31 | removedTargetIds.length == 0 32 | ? [] 33 | : ( 34 | await payload.find({ 35 | collection: targetCollection.slug, 36 | overrideAccess: true, 37 | depth: 1, 38 | pagination: false, 39 | where: { 40 | id: { 41 | in: removedTargetIds, 42 | }, 43 | }, 44 | }) 45 | ).docs; 46 | 47 | const documentsToAddBackPop = 48 | addedTargetIds.length == 0 49 | ? [] 50 | : ( 51 | await payload.find({ 52 | collection: targetCollection.slug, 53 | overrideAccess: true, 54 | depth: 1, 55 | pagination: false, 56 | where: { 57 | id: { 58 | in: addedTargetIds, 59 | }, 60 | }, 61 | }) 62 | ).docs; 63 | 64 | for (const documentToRemoveBackPop of documentsToRemoveBackPop) { 65 | // this document is not referenced (any more) make sure the originalDoc is not included in the target field 66 | 67 | const prevReferencedIds = documentToRemoveBackPop[ 68 | backpopulatedField["name"] 69 | ].map((doc: any) => doc.id); 70 | 71 | const updatedReferenceIds = prevReferencedIds.filter((doc: any) => { 72 | return (doc.id ? doc.id : doc) !== originalDoc.id; //Sometimes doc is the id, sometimes doc.id is the id 73 | }); 74 | 75 | await payload.update({ 76 | collection: targetCollection.slug, 77 | id: documentToRemoveBackPop.id, 78 | overrideAccess: true, 79 | data: { 80 | [backpopulatedField["name"]]: updatedReferenceIds, 81 | }, 82 | }); 83 | } 84 | 85 | for (const documentToAddBackPop of documentsToAddBackPop) { 86 | const prevReferencedIds = ( 87 | documentToAddBackPop[backpopulatedField["name"]] ?? [] 88 | ).map((doc: any) => doc.id); 89 | const updatedReferenceIds = Array.from( 90 | new Set([...prevReferencedIds, originalDoc.id]) 91 | ); 92 | await payload.update({ 93 | collection: targetCollection.slug, 94 | id: documentToAddBackPop.id, 95 | overrideAccess: true, 96 | data: { 97 | [backpopulatedField["name"]]: updatedReferenceIds, 98 | }, 99 | }); 100 | } 101 | } 102 | 103 | return; //NOT return value; as the new value of that field doesn't change because of this hook anyways!!! Returning value works usually, 104 | // but not when the relationTo field is a simple backpopulate thingy but an ARRAY with the length of 1. Due to the previous value 105 | // conversion there, we cannot just return value again as the format is for non-array relationTO's now and not for array relationTo#s. 106 | // Thus, better to save the pain and just use a simple return; 107 | }; 108 | 109 | return hook; 110 | }; 111 | -------------------------------------------------------------------------------- /src/hooks/backpopulate.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig, Field, FieldHook } from "payload/types"; 2 | 3 | 4 | 5 | const backpopulate: FieldHook = (args) => { 6 | /** 7 | * This is just a marker hook and will be replaced by the plugin. 8 | * Using this marker hook allows to simply use 'backpopulate' as a hook name in the config. 9 | */ 10 | return args.value; 11 | }; 12 | 13 | export default backpopulate; 14 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "payload/config"; 2 | import { Field } from "payload/types"; 3 | import { backpopulateAfterChangeHookFactory } from "./hooks/backpopulate.hook"; 4 | import { 5 | backpopulateCleanupHookFactory, 6 | parentCleanupHookFactory, 7 | } from "./hooks/backpopulate-cleanup.hook"; 8 | import backpopulate from "./hooks/backpopulate"; 9 | import backpopulatePolymorphicHookFactory from "./hooks/backpopulate-polymorphic.hook"; 10 | import { backpopulatePolymorphicCleanupHookFactory } from "./hooks/backpopulate-cleanup-polymorphic.hook"; 11 | import { PolymorphicRelationshipArgs, SimpleRelationshipArgs } from "./types"; 12 | 13 | const BackpopulatedRelationshipsPlugin = (incomingConfig: Config) => { 14 | for (let collection of incomingConfig.collections ?? []) { 15 | for (let field of collection.fields) { 16 | if (field.type === "relationship" && field.relationTo) { 17 | if (field.hasOwnProperty("hooks")) { 18 | const hasMarker = field.hooks?.afterChange?.find( 19 | (hook) => hook === backpopulate 20 | ); 21 | if (hasMarker) { 22 | // get the target collection 23 | // @ts-ignore es-lint-disable-line 24 | 25 | if (Array.isArray(field.relationTo)) { 26 | for (let relationTo of field.relationTo) { 27 | handlePolymorphicRelationship({ 28 | incomingConfig: incomingConfig, 29 | relationTo: relationTo, 30 | collection: collection, 31 | field: field, 32 | }); 33 | } 34 | } else { 35 | handleSimpleRelationship({ 36 | incomingConfig: incomingConfig, 37 | relationTo: field["relationTo"], 38 | collection: collection, 39 | field: field, 40 | }); 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | return incomingConfig; 49 | }; 50 | 51 | const handleSimpleRelationship = ({ 52 | incomingConfig, 53 | relationTo, 54 | collection, 55 | field, 56 | }: SimpleRelationshipArgs) => { 57 | const targetCollection = incomingConfig.collections?.find( 58 | (collection) => collection.slug === relationTo 59 | ); 60 | const targetFieldName = `${collection.slug}_${field.name}_backpopulated`; 61 | // create a readonly hasMany relationship field on the target collection 62 | const backpopulatedField: Field = backpopulateCollectionField({ 63 | targetFieldName: targetFieldName, 64 | sourceCollectionSlug: collection.slug, 65 | }); 66 | // prepare the target (backpopulated) collections by adding relationship fields to marked collections. 67 | targetCollection?.fields.push(backpopulatedField); 68 | 69 | // replace the marker hook with the actual backpopulation hook 70 | // remove the marker 71 | if (!field.hooks) field.hooks = { afterChange: [] }; 72 | 73 | field.hooks.afterChange = 74 | field.hooks.afterChange?.filter((hook) => hook !== backpopulate) ?? []; 75 | // add the backpopulate hook 76 | if (targetCollection) { 77 | field.hooks.afterChange.push( 78 | backpopulateAfterChangeHookFactory({ 79 | targetCollection: targetCollection, 80 | backpopulatedField: backpopulatedField, 81 | originalField: field, 82 | }) 83 | ); 84 | // the source collection also needs an afterDeleteHook to remove itself from the backpopulated fields on the target collection 85 | collection.hooks ??= {}; 86 | collection.hooks.afterDelete ??= []; 87 | 88 | const collectionAfterDeleteHooks = collection.hooks?.afterDelete || []; 89 | 90 | collection.hooks.afterDelete = [ 91 | ...collectionAfterDeleteHooks, 92 | backpopulateCleanupHookFactory({ 93 | source_field: field.name, 94 | target_field: backpopulatedField.name, 95 | target_slug: targetCollection.slug, 96 | }), 97 | ]; 98 | 99 | targetCollection.hooks ??= {}; 100 | targetCollection.hooks.afterDelete ??= []; 101 | 102 | targetCollection.hooks.afterDelete = [ 103 | ...targetCollection.hooks.afterDelete, 104 | parentCleanupHookFactory({ 105 | source_field: targetFieldName, 106 | target_field: field.name, 107 | target_slug: collection.slug, 108 | }), 109 | ]; 110 | } 111 | }; 112 | const handlePolymorphicRelationship = ({ 113 | incomingConfig, 114 | relationTo, 115 | collection, 116 | field, 117 | }: PolymorphicRelationshipArgs) => { 118 | const targetCollection = incomingConfig.collections?.find( 119 | (collection) => collection.slug === relationTo 120 | ); 121 | const targetFieldName = `${collection.slug}_${field.name}_backpopulated`; 122 | // create a readonly hasMany relationship field on the target collection 123 | if (targetCollection) { 124 | const backpopulatedField: Field = backpopulateCollectionField({ 125 | targetFieldName: targetFieldName, 126 | sourceCollectionSlug: collection.slug, 127 | }); 128 | 129 | // prepare the target (backpopulated) collections by adding relationship fields to marked collections. 130 | targetCollection.fields.push(backpopulatedField); 131 | field.hooks ??= {}; 132 | field.hooks.afterChange ??= []; 133 | 134 | // replace the marker hook with the actual backpopulation hook 135 | // remove the marker 136 | field.hooks.afterChange = field.hooks.afterChange.filter( 137 | (hook) => hook !== backpopulate 138 | ); 139 | // add the backpopulate hook 140 | field.hooks.afterChange.push( 141 | backpopulatePolymorphicHookFactory({ 142 | primaryCollection: collection, 143 | targetCollection: targetCollection, 144 | backpopulatedField: backpopulatedField, 145 | }) 146 | ); 147 | 148 | // the source collection also needs an afterDeleteHook to remove itself from the backpopulated fields on the target collection 149 | collection.hooks ??= {}; 150 | collection.hooks.afterDelete ??= []; 151 | 152 | collection.hooks.afterDelete = [ 153 | ...collection.hooks.afterDelete, 154 | backpopulatePolymorphicCleanupHookFactory({ 155 | source_field: field.name, 156 | target_field: backpopulatedField.name, 157 | target_slug: targetCollection.slug, 158 | }), 159 | ]; 160 | } 161 | }; 162 | 163 | const backpopulateCollectionField = ({ 164 | targetFieldName, 165 | sourceCollectionSlug, 166 | }: { 167 | targetFieldName: string; 168 | sourceCollectionSlug: string; 169 | }) => { 170 | /** 171 | * Backpopulate a single relationship field on a collection (not global). 172 | * This method is executed for each (polymorphic) relation. 173 | */ 174 | 175 | // create a readonly hasMany relationship field on the target collection 176 | const backpopulatedField: Field = { 177 | name: targetFieldName, 178 | type: "relationship", 179 | relationTo: sourceCollectionSlug, 180 | hasMany: true, 181 | access: { 182 | create: () => false, 183 | read: () => true, 184 | update: () => false, 185 | }, 186 | }; 187 | // prepare the target (backpopulated) collections by adding relationship fields to marked collections. 188 | return backpopulatedField; 189 | }; 190 | 191 | export default BackpopulatedRelationshipsPlugin; 192 | -------------------------------------------------------------------------------- /src/pluginConfig.ts: -------------------------------------------------------------------------------- 1 | export const defaultPluginConfig = {}; 2 | -------------------------------------------------------------------------------- /src/tests/plugin/plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { Server } from "http"; 3 | import { MongoMemoryServer } from "mongodb-memory-server"; 4 | import payload from "payload"; 5 | let handle: Server; 6 | /** 7 | * Just a placeholder. 8 | * This suite will test the plugin specific config and options. 9 | */ 10 | 11 | describe("AutoI18n Plugin Tests", () => { 12 | // beforeAll(async () => { 13 | // process.env["PAYLOAD_CONFIG_PATH"] = 14 | // "src/tests/configs/simple/payload-config.ts"; 15 | 16 | // const mongod = await MongoMemoryServer.create(); 17 | // const uri = mongod.getUri(); 18 | // const app = express(); 19 | // handle = app.listen(3000); 20 | 21 | // await payload.init({ 22 | // secret: "SECRET", 23 | // express: app, 24 | // mongoURL: uri, 25 | // }); 26 | // }); 27 | 28 | // afterAll(() => { 29 | // handle.close(); 30 | // }); 31 | 32 | it("Noop", async () => { 33 | expect(1).toBe(1); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/tests/polymorphic/payload-config.ts: -------------------------------------------------------------------------------- 1 | import { buildConfig } from "payload/config"; 2 | import * as BackpopulatePlugin from "../../index"; 3 | import backpopulate from "../../hooks/backpopulate"; 4 | 5 | export const fooSlug: string = "foo"; 6 | export const barSlug: string = "bar"; 7 | export const bazSlug: string = "baz"; 8 | 9 | /** 10 | * A simple collection where all translatable fields are top-level. 11 | * For this reason it is considered `simple` (no field unrolling required) 12 | */ 13 | export default buildConfig({ 14 | admin: { 15 | disable: true, 16 | }, 17 | 18 | debug: true, 19 | telemetry: false, 20 | 21 | collections: [ 22 | { 23 | slug: fooSlug, 24 | timestamps: false, 25 | fields: [ 26 | { 27 | name: "name", 28 | type: "text", 29 | }, 30 | { 31 | name: "bars", 32 | type: "relationship", 33 | relationTo: barSlug, 34 | hooks: { 35 | afterChange: [backpopulate], 36 | }, 37 | }, 38 | { 39 | name: "bars_or_bazzes", 40 | type: "relationship", 41 | relationTo: [barSlug, bazSlug], 42 | hooks: { 43 | afterChange: [backpopulate], 44 | }, 45 | }, 46 | ], 47 | }, 48 | { 49 | slug: barSlug, 50 | timestamps: false, 51 | fields: [ 52 | { 53 | name: "name", 54 | type: "text", 55 | }, 56 | ], 57 | }, 58 | { 59 | slug: bazSlug, 60 | timestamps: false, 61 | fields: [ 62 | { 63 | name: "name", 64 | type: "text", 65 | }, 66 | ], 67 | }, 68 | ], 69 | 70 | plugins: [BackpopulatePlugin.default], 71 | }); 72 | -------------------------------------------------------------------------------- /src/tests/polymorphic/polymorphic.spec.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { Server } from "http"; 3 | import { MongoMemoryServer } from "mongodb-memory-server"; 4 | import payload from "payload"; 5 | import { barSlug, fooSlug } from "./payload-config"; 6 | let handle: Server; 7 | 8 | describe("Polymorphic Config Tests", () => { 9 | beforeAll(async () => { 10 | process.env["PAYLOAD_CONFIG_PATH"] = 11 | "src/tests/polymorphic/payload-config.ts"; 12 | 13 | const mongod = await MongoMemoryServer.create(); 14 | const uri = mongod.getUri(); 15 | const app = express(); 16 | handle = app.listen(3000); 17 | 18 | await payload.init({ 19 | secret: "SECRET", 20 | express: app, 21 | mongoURL: uri, 22 | }); 23 | }); 24 | 25 | afterAll(() => { 26 | handle.close(); 27 | }); 28 | 29 | it("Should backpopulate a polymorphic relationship [single add, single remove]", async () => { 30 | // Create basic entities 31 | let foo = await payload.create({ 32 | collection: fooSlug, 33 | data: { 34 | name: "foo", 35 | bars_or_bazzes: [], 36 | }, 37 | }); 38 | 39 | let bar = await payload.create({ 40 | collection: barSlug, 41 | data: { 42 | name: "bar", 43 | }, 44 | }); 45 | 46 | console.log(bar); 47 | 48 | // Now connect foo and bar, bar should backpopulate the relationship 49 | foo = await payload.update({ 50 | collection: fooSlug, 51 | id: foo.id, 52 | data: { 53 | bars_or_bazzes: [{ relationTo: barSlug, value: bar.id }], 54 | }, 55 | }); 56 | 57 | bar = await payload.findByID({ 58 | collection: barSlug, 59 | id: bar.id, 60 | }); 61 | 62 | console.log(bar); 63 | expect(bar).toMatchObject({ 64 | name: bar.name, 65 | foo_bars_or_bazzes_backpopulated: [foo], 66 | }); 67 | 68 | // Remove the bar and check again 69 | foo = await payload.update({ 70 | collection: fooSlug, 71 | id: foo.id, 72 | data: { 73 | bars_or_bazzes: [], 74 | }, 75 | }); 76 | 77 | bar = await payload.findByID({ 78 | collection: barSlug, 79 | id: bar.id, 80 | }); 81 | 82 | expect(bar).toMatchObject({ 83 | name: bar.name, 84 | foo_bars_or_bazzes_backpopulated: [], 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/tests/setup.ts: -------------------------------------------------------------------------------- 1 | export default () => {}; 2 | -------------------------------------------------------------------------------- /src/tests/simple/payload-config.ts: -------------------------------------------------------------------------------- 1 | import { buildConfig } from "payload/config"; 2 | import * as BackpopulatePlugin from "../../index"; 3 | import backpopulate from "../../hooks/backpopulate"; 4 | 5 | export const fooSlug: string = "foo"; 6 | export const barSlug: string = "bar"; 7 | export const bazSlug: string = "baz"; 8 | 9 | /** 10 | * A simple collection where all translatable fields are top-level. 11 | * For this reason it is considered `simple` (no field unrolling required) 12 | */ 13 | export default buildConfig({ 14 | admin: { 15 | disable: true, 16 | }, 17 | 18 | debug: true, 19 | telemetry: false, 20 | 21 | collections: [ 22 | { 23 | slug: fooSlug, 24 | timestamps: false, 25 | fields: [ 26 | { 27 | name: "name", 28 | type: "text", 29 | }, 30 | { 31 | name: "bars", 32 | type: "relationship", 33 | relationTo: barSlug, 34 | hooks: { 35 | afterChange: [backpopulate], 36 | }, 37 | }, 38 | ], 39 | }, 40 | { 41 | slug: barSlug, 42 | timestamps: false, 43 | fields: [ 44 | { 45 | name: "name", 46 | type: "text", 47 | }, 48 | ], 49 | }, 50 | ], 51 | 52 | plugins: [BackpopulatePlugin.default], 53 | }); 54 | -------------------------------------------------------------------------------- /src/tests/simple/simple.spec.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { Server } from "http"; 3 | import { MongoMemoryServer } from "mongodb-memory-server"; 4 | import payload from "payload"; 5 | import { barSlug, fooSlug } from "./payload-config"; 6 | let handle: Server; 7 | 8 | describe("Simple Config Tests", () => { 9 | beforeAll(async () => { 10 | process.env["PAYLOAD_CONFIG_PATH"] = "src/tests/simple/payload-config.ts"; 11 | 12 | const mongod = await MongoMemoryServer.create(); 13 | const uri = mongod.getUri(); 14 | const app = express(); 15 | handle = app.listen(3000); 16 | 17 | await payload.init({ 18 | secret: "SECRET", 19 | express: app, 20 | mongoURL: uri, 21 | }); 22 | }); 23 | 24 | afterAll(() => { 25 | handle.close(); 26 | }); 27 | 28 | it("Should backpopulate a simple relationship [single add, single remove]", async () => { 29 | // Create basic entities 30 | let foo = await payload.create({ 31 | collection: fooSlug, 32 | data: { 33 | name: "foo", 34 | bars: [], 35 | }, 36 | }); 37 | 38 | let bar = await payload.create({ 39 | collection: barSlug, 40 | data: { 41 | name: "bar", 42 | }, 43 | }); 44 | 45 | // Now connect foo and bar, bar should backpopulate the relationship 46 | foo = await payload.update({ 47 | collection: fooSlug, 48 | id: foo.id, 49 | data: { 50 | bars: [bar.id], 51 | }, 52 | }); 53 | 54 | bar = await payload.findByID({ 55 | collection: barSlug, 56 | id: bar.id, 57 | }); 58 | 59 | expect(bar).toMatchObject({ 60 | name: bar.name, 61 | foo_bars_backpopulated: [foo], 62 | }); 63 | 64 | // Remove the bar and check again 65 | foo = await payload.update({ 66 | collection: fooSlug, 67 | id: foo.id, 68 | data: { 69 | bars: [], 70 | }, 71 | }); 72 | 73 | bar = await payload.findByID({ 74 | collection: barSlug, 75 | id: bar.id, 76 | }); 77 | 78 | expect(bar).toMatchObject({ 79 | name: bar.name, 80 | foo_bars_backpopulated: [], 81 | }); 82 | }); 83 | 84 | it("Should handle multiple active relationships [many add, many remove]", async () => { 85 | // Create foo1, foo2, foo3 and bar1, bar2 86 | let bar1 = await payload.create({ 87 | collection: barSlug, 88 | data: { name: "bar1" }, 89 | }); 90 | let bar2 = await payload.create({ 91 | collection: barSlug, 92 | data: { name: "bar2" }, 93 | }); 94 | 95 | let foo1 = await payload.create({ 96 | collection: fooSlug, 97 | data: { 98 | name: "foo1", 99 | }, 100 | }); 101 | let foo2 = await payload.create({ 102 | collection: fooSlug, 103 | data: { 104 | name: "foo2", 105 | bars: [bar1.id, bar2.id], 106 | }, 107 | }); 108 | let foo3 = await payload.create({ 109 | collection: fooSlug, 110 | data: { 111 | name: "foo3", 112 | bars: [], 113 | }, 114 | }); 115 | 116 | // Assert that backpopulation are what we expect 117 | bar1 = await payload.findByID({ collection: barSlug, id: bar1.id }); 118 | bar2 = await payload.findByID({ collection: barSlug, id: bar2.id }); 119 | 120 | expect(bar1.foo_bars_backpopulated).toMatchObject([foo2]); 121 | expect(bar2.foo_bars_backpopulated).toMatchObject([foo2]); 122 | 123 | await payload.update({ 124 | collection: fooSlug, 125 | id: foo2.id, 126 | data: { bars: [] }, 127 | }); 128 | 129 | // Assert that backpopulation are what we expect 130 | bar1 = await payload.findByID({ collection: barSlug, id: bar1.id }); 131 | bar2 = await payload.findByID({ collection: barSlug, id: bar2.id }); 132 | 133 | expect(bar1.foo_bars_backpopulated).toMatchObject([]); 134 | expect(bar2.foo_bars_backpopulated).toMatchObject([]); 135 | 136 | await payload.update({ 137 | collection: fooSlug, 138 | id: foo3.id, 139 | data: { bars: [bar1.id, bar2.id] }, 140 | }); 141 | await payload.update({ 142 | collection: fooSlug, 143 | id: foo1.id, 144 | data: { bars: [bar1.id] }, 145 | }); 146 | 147 | // Assert that backpopulation are what we expect 148 | bar1 = await payload.findByID({ collection: barSlug, id: bar1.id }); 149 | bar2 = await payload.findByID({ collection: barSlug, id: bar2.id }); 150 | foo1 = await payload.findByID({ collection: fooSlug, id: foo1.id }); 151 | foo3 = await payload.findByID({ collection: fooSlug, id: foo3.id }); 152 | 153 | expect(bar1.foo_bars_backpopulated).toMatchObject([foo3, foo1]); 154 | expect(bar2.foo_bars_backpopulated).toMatchObject([foo3]); 155 | }); 156 | 157 | it("Should handle deletion of target elements [single add, single delete]", async () => { 158 | // Create basic entities 159 | let foo = await payload.create({ 160 | collection: fooSlug, 161 | data: { 162 | name: "foo", 163 | bars: [], 164 | }, 165 | }); 166 | 167 | let bar = await payload.create({ 168 | collection: barSlug, 169 | data: { 170 | name: "bar", 171 | }, 172 | }); 173 | 174 | // Now connect foo and bar, bar should backpopulate the relationship 175 | foo = await payload.update({ 176 | collection: fooSlug, 177 | id: foo.id, 178 | data: { 179 | bars: [bar.id], 180 | }, 181 | }); 182 | 183 | bar = await payload.findByID({ 184 | collection: barSlug, 185 | id: bar.id, 186 | }); 187 | 188 | expect(bar).toMatchObject({ 189 | name: bar.name, 190 | foo_bars_backpopulated: [foo], 191 | }); 192 | 193 | // Delete the foo and check again 194 | await payload.delete({ 195 | collection: fooSlug, 196 | id: foo.id, 197 | }); 198 | 199 | bar = await payload.findByID({ 200 | collection: barSlug, 201 | id: bar.id, 202 | }); 203 | 204 | expect(bar).toMatchObject({ 205 | name: bar.name, 206 | foo_bars_backpopulated: [], 207 | }); 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "payload/config"; 2 | import { CollectionConfig, Field, RelationshipField } from "payload/types"; 3 | 4 | // aliasing for clarification 5 | export type locale = string; 6 | 7 | export type BackpopPluginConfig = {}; 8 | 9 | export type SimpleRelationshipArgs = { 10 | incomingConfig: Partial; 11 | relationTo: string; 12 | collection: CollectionConfig; 13 | field: RelationshipField; 14 | }; 15 | 16 | export type PolymorphicRelationshipArgs = { 17 | incomingConfig: Partial; 18 | relationTo: string; 19 | collection: CollectionConfig; 20 | field: RelationshipField; 21 | }; 22 | 23 | export type SimpleHookArgs = { 24 | targetCollection: CollectionConfig; 25 | backpopulatedField: Field & { name: string }; 26 | originalField: Field & { name: string }; 27 | }; 28 | 29 | export type PolymorphicHookArgs = { 30 | // polymorphic hooks need to be aware of their own slug, otherwise 31 | // we can not determine if the document is part of the afterchange value 32 | primaryCollection: CollectionConfig; 33 | targetCollection: CollectionConfig; 34 | backpopulatedField: Field & { name: string }; 35 | }; 36 | 37 | // fields which can be recursively traversed 38 | export const traversableFieldTypes = [ 39 | "group", 40 | "array", 41 | "tabs", 42 | "collapsible", 43 | "row", 44 | ]; 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "outDir": "./dist", 5 | "allowJs": true, 6 | "module": "commonjs", 7 | "sourceMap": true, 8 | "jsx": "react", 9 | "esModuleInterop": true, 10 | "declaration": true, 11 | "declarationDir": "./dist", 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "resolveJsonModule": true, 15 | }, 16 | "include": [ 17 | "src/**/*", 18 | ], 19 | } 20 | --------------------------------------------------------------------------------