diff --git a/wiki/_master-index.md b/wiki/_master-index.md index a8ad4f3..348392c 100644 --- a/wiki/_master-index.md +++ b/wiki/_master-index.md @@ -35,7 +35,7 @@ This 3-hop pattern works for hundreds of articles without vector search. | [[wiki/reports/_index\|reports/]] | Weekly and monthly summaries — generate: `uv run python scripts/report-generator.py --weekly` | 1 | | [[wiki/infrastructure/_index\|infrastructure/]] | Server inventory: all 10 SSH hosts — optical, optical-dev, optical-prod, baic, librechat, modocmms, box-cli, aimpress, pve | 12 | | [[wiki/testing/_index\|testing/]] | Web app testing: functional, performance, security, UI types; TDD/BDD/Agile methodologies; Selenium/Cypress/Playwright/JMeter/OWASP ZAP tools | 1 | -| [[wiki/payloadcms/_index\|payloadcms/]] | Full Payload CMS reference — getting started, config, database (Postgres/MongoDB/SQLite), all 22 field types, access control, hooks, authentication (cookies, JWT, API keys, custom strategies, token data), admin UI, custom components, Lexical rich text, live preview, versions/drafts, Local/REST/GraphQL APIs, queries, plugins, jobs queue, upload, ecommerce, production deploy, TypeScript, migration guides, i18n, localization | 70 | +| [[wiki/payloadcms/_index\|payloadcms/]] | Full Payload CMS reference — getting started, config, database (Postgres/MongoDB/SQLite), all 22 field types, access control, hooks, authentication (cookies, JWT, API keys, custom strategies, token data), admin UI, custom components, Lexical rich text, live preview, versions/drafts, Local/REST/GraphQL APIs, queries, plugins, jobs queue, upload, ecommerce, production deploy, TypeScript, migration guides, i18n, localization | 71 | | [[wiki/shared-patterns/_index\|shared-patterns/]] | Oliver Agency standard library patterns: httpx, structlog, pydantic-settings, alembic — reuse before writing from scratch | 4 | | [[wiki/mistakes/_index\|mistakes/]] | Anti-patterns extracted from sessions — per-stack running lists (fastapi, react, docker, postgres, general) — injected at session start | 5 | diff --git a/wiki/payloadcms/_index.md b/wiki/payloadcms/_index.md index d09dd6c..fdda775 100644 --- a/wiki/payloadcms/_index.md +++ b/wiki/payloadcms/_index.md @@ -71,3 +71,4 @@ | [[wiki/payloadcms/fields-date\|Date Field]] | Date picker field — ISO 8601 storage, pickerAppearance modes, timezone support with separate `_tz` column, custom UTC offsets, GraphQL enum names | raw/fields__date.md | 2026-05-15 | | [[wiki/payloadcms/fields-email\|Email Field]] | Email field with built-in format validation — unique index, saveToJWT, index, placeholder, autoComplete, custom components | raw/fields__email.md | 2026-05-15 | | [[wiki/payloadcms/fields-group\|Group Field]] | Nest fields under a shared property (named) or visually group without nesting (presentational); `localized` covers all nested fields at once | raw/fields__group.md | 2026-05-15 | +| [[wiki/payloadcms/fields-join\|Join Field]] | Virtual bi-directional relationship field — zero-overhead reverse lookup, custom junction tables, polymorphic, query options across all APIs | raw/fields__join.md | 2026-05-15 | diff --git a/wiki/payloadcms/fields-join.md b/wiki/payloadcms/fields-join.md new file mode 100644 index 0000000..601f6d6 --- /dev/null +++ b/wiki/payloadcms/fields-join.md @@ -0,0 +1,162 @@ +--- +title: "Join Field" +aliases: [join-field, bi-directional-relationship, virtual-relationship-field] +tags: [payloadcms, fields, relationships, database, schema] +sources: [raw/fields__join.md] +created: 2026-05-15 +updated: 2026-05-15 +--- + +## Overview + +The Join field exposes [[wiki/payloadcms/fields-complex|Relationship and Upload fields]] in the **reverse direction** — it is a **virtual field** (stores no data) that surfaces related documents in the Admin UI and APIs. The foreign key lives on the related collection only; Join reads it on the fly. + +```ts +// On the "categories" collection: +export const relatedPostsField: Field = { + name: 'relatedPosts', + type: 'join', + collection: 'posts', // slug of the collection to join from + on: 'category', // name of the relationship field in that collection +} + +// On the "posts" collection (the actual storage): +export const categoryField: Field = { + name: 'category', + type: 'relationship', + relationTo: 'categories', +} +``` + +Navigating to a Category now shows all Posts that reference it — without storing post IDs on the category. + +## How It Works Internally + +- **MongoDB** — uses aggregation pipelines to join related documents. +- **Postgres / SQLite** — uses SQL `JOIN` statements. +- **No extra query cost** until `depth >= 1`. At `depth: 0` only IDs are returned. +- **Not supported** on DocumentDB and Azure Cosmos DB (aggregation limitations). + +## Schema Design Advice + +Store the relationship on **one side only** (single source of truth): + +| Do | Don't | +|----|-------| +| `post.category_id` (FK on post) | `post.category_id` + `category.post_ids[]` | + +Join makes the single-source approach ergonomic — you get bi-directional UI/API without duplicating data. + +## Custom Junction Tables + +By default, polymorphic relationships create a `_rels` table in Postgres/SQLite. Use Join to **bypass** this: + +1. Create a `categories_posts` collection with `category` and `post` relationship fields. +2. Add Join fields on both `Categories` and `Posts` pointing `on` the respective field in `categories_posts`. +3. Optionally set `admin.hidden: true` on `categories_posts` to hide it from the nav. +4. Add extra "context" fields (e.g. `featured`, `spotlight`) on the junction collection. + +## Config Options + +| Option | Required | Description | +|--------|----------|-------------| +| `name` | ✅ | Property name in API responses | +| `collection` | ✅ | Slug or array of slugs to join from | +| `on` | ✅ | Relationship/Upload field name in that collection. Dot-notation for nested: `'myGroup.relationName'` | +| `orderable` | | Drag-and-drop reordering via fractional indexing | +| `where` | | Static `Where` filter merged with request `where` | +| `maxDepth` | | Max population depth (default 1) | +| `defaultLimit` | | Docs returned per page; `0` = all | +| `defaultSort` | | Default sort field | +| `hooks` | | Field-level hooks | +| `access` | | Field-level access control | + +### Admin Options (`admin.*`) + +| Option | Description | +|--------|-------------| +| `defaultColumns` | Columns shown in the relationship table | +| `allowCreate` | Set `false` to hide "New" button | +| `components.Label` | Custom Label component | +| `disableRowTypes` | Hide row type badges (default `false`) | + +## API Response Shape + +```json +{ + "id": "66e3431a3f23e684075aae9c", + "relatedPosts": { + "docs": [{ "id": "...", "category": "66e3431a3f23e684075aae9c" }], + "hasNextPage": false, + "totalDocs": 10 + } +} +``` + +Polymorphic (array `collection`): each `docs` entry is `{ relationTo: "posts", value: { ... } }`. + +## Query Options + +Pass `joins: false` to suppress all join fields (performance). + +| Param | Description | +|-------|-------------| +| `limit` | Max related docs (default 10) | +| `where` | Filter joined docs | +| `sort` | Sort field | +| `count` | Include `totalDocs` in response | + +### Local API + +```js +await payload.find({ + collection: 'categories', + joins: { + relatedPosts: { limit: 5, sort: 'title', where: { title: { equals: 'My Post' } } }, + }, +}) +``` + +### REST API + +``` +/api/categories/{id}?joins[relatedPosts][limit]=5&joins[relatedPosts][sort]=title +``` + +### GraphQL + +```graphql +query { + Categories { + docs { + relatedPosts(sort: "createdAt", limit: 5, count: true) { + docs { title } + hasNextPage + totalDocs + } + } + } +} +``` + +## Key Takeaways + +- **Virtual field** — no column added to the collection that defines it; data stays on the related side. +- **Zero overhead at `depth: 0`** — only IDs returned; full population kicks in at `depth >= 1`. +- **Single source of truth** — avoids dual-sided storage; Join makes bi-directional access free. +- **Custom junction tables** — use a dedicated junction collection + Join fields to escape Payload's auto `_rels` table and add context fields (e.g. `featured`). +- **Polymorphic** — `collection` can be an array of slugs; response wraps each doc as `{ relationTo, value }`. +- **Not supported** on DocumentDB / Azure Cosmos DB. +- **`where` + array collections** — filtering inside arrays/blocks is not yet supported. + +## Related + +- [[wiki/payloadcms/fields-complex|Fields: Complex (Relationship, Upload, Array, Blocks)]] +- [[wiki/payloadcms/queries|Queries — Where, depth, pagination]] +- [[wiki/payloadcms/database-postgres|Database — Postgres (junction tables, _rels)]] +- [[wiki/payloadcms/collection-config|Collection Config (fractional indexing, orderable)]] +- [[wiki/payloadcms/local-api|Local API]] + +## Sources + +- `raw/fields__join.md` — official Payload CMS Join field docs