feat: add find to payloadDataLoader to cache local API queries (#11685)

### What?
Extends our dataloader to add a momiozed payload find function. This way
it will cache the query for the same find request using a cacheKey from
find operation args.

### Why?
This was needed internally for `filterOptions` that exist in an array or
other sitautions where you have the same exact query being made and
awaited many times.

### How?

- Added `find` to payloadDataLoader. Marked `@experimental` in case it
needs to change.
- Created a cache key from the args
- Validate filterOptions changed from `payload.find` to
`payloadDataLoader.find`
- Made `payloadDataLoader` no longer optional on `PayloadRequest`, since
other args are required which are created from createLocalReq (context
for example), I don't see a reason why dataLoader shouldn't be required
also.

Example usage: 
```ts
const result = await req.payloadDataLoader.find({
    collection,
    req,
    where,
  })
```
This commit is contained in:
Dan Ribbens
2025-03-18 17:14:33 -04:00
committed by GitHub
parent 67a7358de1
commit 975bbb756f
8 changed files with 108 additions and 10 deletions

View File

@@ -3,8 +3,10 @@ import type { BatchLoadFn } from 'dataloader'
import DataLoader from 'dataloader'
import type { FindArgs } from '../database/types.js'
import type { PayloadRequest, PopulateType, SelectType } from '../types/index.js'
import type { TypeWithID } from './config/types.js'
import type { Options } from './operations/local/find.js'
import { isValidID } from '../utilities/isValidID.js'
@@ -157,7 +159,65 @@ const batchAndLoadDocs =
return docs
}
export const getDataLoader = (req: PayloadRequest) => new DataLoader(batchAndLoadDocs(req))
export const getDataLoader = (req: PayloadRequest) => {
const findQueries = new Map()
const dataLoader = new DataLoader(batchAndLoadDocs(req)) as PayloadRequest['payloadDataLoader']
dataLoader.find = (args: FindArgs) => {
const key = createFindDataloaderCacheKey(args)
const cached = findQueries.get(key)
if (cached) {
return cached
}
const request = req.payload.find(args)
findQueries.set(key, request)
return request
}
return dataLoader
}
const createFindDataloaderCacheKey = ({
collection,
currentDepth,
depth,
disableErrors,
draft,
includeLockStatus,
joins,
limit,
overrideAccess,
page,
pagination,
populate,
req,
select,
showHiddenFields,
sort,
where,
}: Options<string, SelectType>): string =>
JSON.stringify([
collection,
currentDepth,
depth,
disableErrors,
draft,
includeLockStatus,
joins,
limit,
overrideAccess,
page,
pagination,
populate,
req?.locale,
req?.fallbackLocale,
req?.user?.id,
req?.transactionID,
select,
showHiddenFields,
sort,
where,
])
type CreateCacheKeyArgs = {
collectionSlug: string

View File

@@ -595,7 +595,7 @@ const validateFilterOptions: Validate<
falseCollections.push(collection)
}
const result = await payload.find({
const result = await req.payloadDataLoader.find({
collection,
depth: 0,
limit: 0,

View File

@@ -13,6 +13,7 @@ import type {
CollectionSlug,
DataFromGlobalSlug,
GlobalSlug,
Payload,
RequestContext,
TypedCollectionJoins,
TypedCollectionSelect,
@@ -20,7 +21,7 @@ import type {
TypedUser,
} from '../index.js'
import type { Operator } from './constants.js'
export type { Payload as Payload } from '../index.js'
export type { Payload } from '../index.js'
export type CustomPayloadRequestProperties = {
context: RequestContext
@@ -44,7 +45,19 @@ export type CustomPayloadRequestProperties = {
*/
payloadAPI: 'GraphQL' | 'local' | 'REST'
/** Optimized document loader */
payloadDataLoader?: DataLoader<string, TypeWithID>
payloadDataLoader: {
/**
* Wraps `payload.find` with a cache to deduplicate requests
* @experimental This is may be replaced by a more robust cache strategy in future versions
* By calling this method with the same arguments many times in one request, it will only be handled one time
* const result = await req.payloadDataLoader.find({
* collection,
* req,
* where: findWhere,
* })
*/
find: Payload['find']
} & DataLoader<string, TypeWithID>
/** Resized versions of the image that was uploaded during this request */
payloadUploadSizes?: Record<string, Buffer>
/** Query params on the request */

View File

@@ -1,7 +1,7 @@
import type { FileData, FileSizeImproved, TypeWithID } from 'payload'
import type { SerializedUploadNode } from '../../../../../nodeTypes.js'
import type { UploadDataImproved } from '../../../../upload/server/nodes/UploadNode.jsx'
import type { UploadDataImproved } from '../../../../upload/server/nodes/UploadNode.js'
import type { HTMLConvertersAsync } from '../types.js'
export const UploadHTMLConverterAsync: HTMLConvertersAsync<SerializedUploadNode> = {

View File

@@ -1,7 +1,7 @@
import type { FileData, FileSizeImproved, TypeWithID } from 'payload'
import type { SerializedUploadNode } from '../../../../../nodeTypes.js'
import type { UploadDataImproved } from '../../../../upload/server/nodes/UploadNode.jsx'
import type { UploadDataImproved } from '../../../../upload/server/nodes/UploadNode.js'
import type { HTMLConverters } from '../types.js'
export const UploadHTMLConverter: HTMLConverters<SerializedUploadNode> = {

View File

@@ -12,7 +12,6 @@ export const UploadJSXConverter: JSXConverters<SerializedUploadNode> = {
return null
}
const uploadDoc = uploadNode.value as FileData & TypeWithID
const url = uploadDoc.url

View File

@@ -1,5 +1,6 @@
/* eslint-disable jest/require-top-level-describe */
import { PostgresAdapter } from '@payloadcms/db-postgres/types'
import type { PostgresAdapter } from '@payloadcms/db-postgres/types'
import { cosineDistance, desc, gt, sql } from 'drizzle-orm'
import path from 'path'
import { buildConfig, getPayload } from 'payload'

View File

@@ -1,6 +1,7 @@
import type { Payload } from 'payload'
import type { CollectionSlug, Payload } from 'payload'
import path from 'path'
import { createLocalReq } from 'payload'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
@@ -28,7 +29,9 @@ describe('dataloader', () => {
},
})
if (loginResult.token) token = loginResult.token
if (loginResult.token) {
token = loginResult.token
}
})
afterAll(async () => {
@@ -187,4 +190,26 @@ describe('dataloader', () => {
expect(innerMostRelationship).toStrictEqual(relationB.id)
})
})
describe('find', () => {
it('should call the same query only once in a request', async () => {
const req = await createLocalReq({}, payload)
const spy = jest.spyOn(payload, 'find')
const findArgs = {
collection: 'items' as CollectionSlug,
req,
depth: 0,
where: {
name: { exists: true },
},
}
void req.payloadDataLoader.find(findArgs)
void req.payloadDataLoader.find(findArgs)
await req.payloadDataLoader.find(findArgs)
expect(spy).toHaveBeenCalledTimes(1)
})
})
})