feat: compound indexes (#11512)
### What?
This PR adds ability to define indexes on several fields for collections
(compound indexes).
Example:
```ts
{
indexes: [{ unique: true, fields: ['title', 'group.name'] }]
}
```
### Why?
This can be used to either speed up querying/sorting by 2 or more fields
at the same time or to ensure uniqueness between several fields.
### How?
Implements this logic in database adapters. Additionally, adds a utility
`getFieldByPath`.
This commit is contained in:
@@ -57,9 +57,9 @@ export const Posts: CollectionConfig = {
|
||||
|
||||
The following options are available:
|
||||
|
||||
| Option | Description |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). |
|
||||
| Option | Description |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). |
|
||||
| `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). |
|
||||
| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |
|
||||
| `custom` | Extension point for adding custom data (e.g. for plugins) |
|
||||
@@ -67,17 +67,18 @@ The following options are available:
|
||||
| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. |
|
||||
| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. |
|
||||
| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). |
|
||||
| `fields` * | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
|
||||
| `fields` * | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
|
||||
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
|
||||
| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
|
||||
| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
|
||||
| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
|
||||
| `slug` * | Unique, URL-friendly string that will act as an identifier for this Collection. |
|
||||
| `slug` * | Unique, URL-friendly string that will act as an identifier for this Collection. |
|
||||
| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
|
||||
| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
|
||||
| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. |
|
||||
| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). |
|
||||
| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). |
|
||||
| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. |
|
||||
|
||||
_* An asterisk denotes that a property is required._
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { Init, SanitizedCollectionConfig } from 'payload'
|
||||
|
||||
import mongoose from 'mongoose'
|
||||
import paginate from 'mongoose-paginate-v2'
|
||||
import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload'
|
||||
import {
|
||||
buildVersionCollectionFields,
|
||||
buildVersionCompoundIndexes,
|
||||
buildVersionGlobalFields,
|
||||
} from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
import type { CollectionModel, GlobalModel } from './types.js'
|
||||
@@ -36,6 +40,7 @@ export const init: Init = function init(this: MongooseAdapter) {
|
||||
},
|
||||
...schemaOptions,
|
||||
},
|
||||
compoundIndexes: buildVersionCompoundIndexes({ indexes: collection.sanitizedIndexes }),
|
||||
configFields: versionCollectionFields,
|
||||
payload: this.payload,
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ export const buildCollectionSchema = (
|
||||
...schemaOptions,
|
||||
},
|
||||
},
|
||||
compoundIndexes: collection.sanitizedIndexes,
|
||||
configFields: collection.fields,
|
||||
payload,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { IndexOptions, Schema, SchemaOptions, SchemaTypeOptions } from 'mon
|
||||
|
||||
import mongoose from 'mongoose'
|
||||
import {
|
||||
APIError,
|
||||
type ArrayField,
|
||||
type BlocksField,
|
||||
type CheckboxField,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
type RelationshipField,
|
||||
type RichTextField,
|
||||
type RowField,
|
||||
type SanitizedCompoundIndex,
|
||||
type SanitizedLocalizationConfig,
|
||||
type SelectField,
|
||||
type Tab,
|
||||
@@ -128,6 +128,7 @@ const localizeSchema = (
|
||||
|
||||
export const buildSchema = (args: {
|
||||
buildSchemaOptions: BuildSchemaOptions
|
||||
compoundIndexes?: SanitizedCompoundIndex[]
|
||||
configFields: Field[]
|
||||
parentIsLocalized?: boolean
|
||||
payload: Payload
|
||||
@@ -166,6 +167,26 @@ export const buildSchema = (args: {
|
||||
}
|
||||
})
|
||||
|
||||
if (args.compoundIndexes) {
|
||||
for (const index of args.compoundIndexes) {
|
||||
const indexDefinition: Record<string, 1> = {}
|
||||
|
||||
for (const field of index.fields) {
|
||||
if (field.pathHasLocalized && payload.config.localization) {
|
||||
for (const locale of payload.config.localization.locales) {
|
||||
indexDefinition[field.localizedPath.replace('<locale>', locale.code)] = 1
|
||||
}
|
||||
} else {
|
||||
indexDefinition[field.path] = 1
|
||||
}
|
||||
}
|
||||
|
||||
schema.index(indexDefinition, {
|
||||
unique: args.buildSchemaOptions.disableUnique ? false : index.unique,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FlattenedField } from 'payload'
|
||||
import type { FlattenedField, SanitizedCompoundIndex } from 'payload'
|
||||
|
||||
import { InvalidConfiguration } from 'payload'
|
||||
import toSnakeCase from 'to-snake-case'
|
||||
|
||||
import type {
|
||||
@@ -33,6 +34,7 @@ type Args = {
|
||||
baseIndexes?: Record<string, RawIndex>
|
||||
buildNumbers?: boolean
|
||||
buildRelationships?: boolean
|
||||
compoundIndexes?: SanitizedCompoundIndex[]
|
||||
disableNotNull: boolean
|
||||
disableRelsTableUnique?: boolean
|
||||
disableUnique: boolean
|
||||
@@ -68,6 +70,7 @@ export const buildTable = ({
|
||||
baseColumns = {},
|
||||
baseForeignKeys = {},
|
||||
baseIndexes = {},
|
||||
compoundIndexes,
|
||||
disableNotNull,
|
||||
disableRelsTableUnique = false,
|
||||
disableUnique = false,
|
||||
@@ -268,6 +271,61 @@ export const buildTable = ({
|
||||
adapter.rawRelations[localeTableName] = localeRelations
|
||||
}
|
||||
|
||||
if (compoundIndexes) {
|
||||
for (const index of compoundIndexes) {
|
||||
let someLocalized: boolean | null = null
|
||||
const columns: string[] = []
|
||||
|
||||
const getTableToUse = () => {
|
||||
if (someLocalized) {
|
||||
return localesTable
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
|
||||
for (const { path, pathHasLocalized } of index.fields) {
|
||||
if (someLocalized === null) {
|
||||
someLocalized = pathHasLocalized
|
||||
}
|
||||
|
||||
if (someLocalized !== pathHasLocalized) {
|
||||
throw new InvalidConfiguration(
|
||||
`Compound indexes within localized and non localized fields are not supported in SQL. Expected ${path} to be ${someLocalized ? 'non' : ''} localized.`,
|
||||
)
|
||||
}
|
||||
|
||||
const columnPath = path.replaceAll('.', '_')
|
||||
|
||||
if (!getTableToUse().columns[columnPath]) {
|
||||
throw new InvalidConfiguration(
|
||||
`Column ${columnPath} for compound index on ${path} was not found in the ${getTableToUse().name} table.`,
|
||||
)
|
||||
}
|
||||
|
||||
columns.push(columnPath)
|
||||
}
|
||||
|
||||
if (someLocalized) {
|
||||
columns.push('_locale')
|
||||
}
|
||||
|
||||
let name = columns.join('_')
|
||||
// truncate against the limit, buildIndexName will handle collisions
|
||||
if (name.length > 63) {
|
||||
name = 'compound_index'
|
||||
}
|
||||
|
||||
const indexName = buildIndexName({ name, adapter })
|
||||
|
||||
getTableToUse().indexes[indexName] = {
|
||||
name: indexName,
|
||||
on: columns,
|
||||
unique: disableUnique ? false : index.unique,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isRoot) {
|
||||
if (hasManyTextField) {
|
||||
const textsTableName = `${rootTableName}_texts`
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload'
|
||||
import {
|
||||
buildVersionCollectionFields,
|
||||
buildVersionCompoundIndexes,
|
||||
buildVersionGlobalFields,
|
||||
} from 'payload'
|
||||
import toSnakeCase from 'to-snake-case'
|
||||
|
||||
import type { DrizzleAdapter, RawIndex, SetColumnID } from '../types.js'
|
||||
@@ -52,6 +56,7 @@ export const buildRawSchema = ({
|
||||
|
||||
buildTable({
|
||||
adapter,
|
||||
compoundIndexes: collection.sanitizedIndexes,
|
||||
disableNotNull: !!collection?.versions?.drafts,
|
||||
disableUnique: false,
|
||||
fields: collection.flattenedFields,
|
||||
@@ -70,6 +75,7 @@ export const buildRawSchema = ({
|
||||
|
||||
buildTable({
|
||||
adapter,
|
||||
compoundIndexes: buildVersionCompoundIndexes({ indexes: collection.sanitizedIndexes }),
|
||||
disableNotNull: !!collection.versions?.drafts,
|
||||
disableUnique: true,
|
||||
fields: versionFields,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FlattenedField } from 'payload'
|
||||
import type { CompoundIndex, FlattenedField } from 'payload'
|
||||
|
||||
import { InvalidConfiguration } from 'payload'
|
||||
import {
|
||||
|
||||
@@ -17,7 +17,15 @@ import { createClientFields } from '../../fields/config/client.js'
|
||||
|
||||
export type ServerOnlyCollectionProperties = keyof Pick<
|
||||
SanitizedCollectionConfig,
|
||||
'access' | 'custom' | 'endpoints' | 'flattenedFields' | 'hooks' | 'joins' | 'polymorphicJoins'
|
||||
| 'access'
|
||||
| 'custom'
|
||||
| 'endpoints'
|
||||
| 'flattenedFields'
|
||||
| 'hooks'
|
||||
| 'indexes'
|
||||
| 'joins'
|
||||
| 'polymorphicJoins'
|
||||
| 'sanitizedIndexes'
|
||||
>
|
||||
|
||||
export type ServerOnlyCollectionAdminProperties = keyof Pick<
|
||||
@@ -70,6 +78,8 @@ const serverOnlyCollectionProperties: Partial<ServerOnlyCollectionProperties>[]
|
||||
'joins',
|
||||
'polymorphicJoins',
|
||||
'flattenedFields',
|
||||
'indexes',
|
||||
'sanitizedIndexes',
|
||||
// `upload`
|
||||
// `admin`
|
||||
// are all handled separately
|
||||
|
||||
@@ -48,6 +48,7 @@ export const defaults: Partial<CollectionConfig> = {
|
||||
me: [],
|
||||
refresh: [],
|
||||
},
|
||||
indexes: [],
|
||||
timestamps: true,
|
||||
upload: false,
|
||||
versions: false,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { Config, SanitizedConfig } from '../../config/types.js'
|
||||
import type {
|
||||
CollectionConfig,
|
||||
CompoundIndex,
|
||||
SanitizedCollectionConfig,
|
||||
SanitizedJoin,
|
||||
SanitizedJoins,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
addDefaultsToLoginWithUsernameConfig,
|
||||
} from './defaults.js'
|
||||
import { sanitizeAuthFields, sanitizeUploadFields } from './reservedFieldNames.js'
|
||||
import { sanitizeCompoundIndexes } from './sanitizeCompoundIndexes.js'
|
||||
import { validateUseAsTitle } from './useAsTitle.js'
|
||||
|
||||
export const sanitizeCollection = async (
|
||||
@@ -241,5 +243,10 @@ export const sanitizeCollection = async (
|
||||
|
||||
sanitizedConfig.flattenedFields = flattenAllFields({ fields: sanitizedConfig.fields })
|
||||
|
||||
sanitizedConfig.sanitizedIndexes = sanitizeCompoundIndexes({
|
||||
fields: sanitizedConfig.flattenedFields,
|
||||
indexes: sanitizedConfig.indexes,
|
||||
})
|
||||
|
||||
return sanitizedConfig
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FlattenedField } from '../../fields/config/types.js'
|
||||
import type { CompoundIndex, SanitizedCompoundIndex } from './types.js'
|
||||
|
||||
import { InvalidConfiguration } from '../../errors/InvalidConfiguration.js'
|
||||
import { getFieldByPath } from '../../utilities/getFieldByPath.js'
|
||||
|
||||
export const sanitizeCompoundIndexes = ({
|
||||
fields,
|
||||
indexes,
|
||||
}: {
|
||||
fields: FlattenedField[]
|
||||
indexes: CompoundIndex[]
|
||||
}): SanitizedCompoundIndex[] => {
|
||||
const sanitizedCompoundIndexes: SanitizedCompoundIndex[] = []
|
||||
|
||||
for (const index of indexes) {
|
||||
const sanitized: SanitizedCompoundIndex = { fields: [], unique: index.unique ?? false }
|
||||
for (const path of index.fields) {
|
||||
const result = getFieldByPath({ fields, path })
|
||||
|
||||
if (!result) {
|
||||
throw new InvalidConfiguration(`Field ${path} was not found`)
|
||||
}
|
||||
|
||||
const { field, localizedPath, pathHasLocalized } = result
|
||||
|
||||
if (['array', 'blocks', 'group', 'tab'].includes(field.type)) {
|
||||
throw new InvalidConfiguration(
|
||||
`Compound index on ${field.type} cannot be set. Path: ${localizedPath}`,
|
||||
)
|
||||
}
|
||||
|
||||
sanitized.fields.push({ field, localizedPath, path, pathHasLocalized })
|
||||
}
|
||||
|
||||
sanitizedCompoundIndexes.push(sanitized)
|
||||
}
|
||||
|
||||
return sanitizedCompoundIndexes
|
||||
}
|
||||
@@ -464,6 +464,16 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
|
||||
*/
|
||||
refresh?: RefreshHook[]
|
||||
}
|
||||
/**
|
||||
* Define compound indexes for this collection.
|
||||
* This can be used to either speed up querying/sorting by 2 or more fields at the same time or
|
||||
* to ensure uniqueness between several fields.
|
||||
* Specify field paths
|
||||
* @example
|
||||
* [{ unique: true, fields: ['title', 'group.name'] }]
|
||||
* @default []
|
||||
*/
|
||||
indexes?: CompoundIndex[]
|
||||
/**
|
||||
* Label configuration
|
||||
*/
|
||||
@@ -542,7 +552,6 @@ export interface SanitizedCollectionConfig
|
||||
auth: Auth
|
||||
endpoints: Endpoint[] | false
|
||||
fields: Field[]
|
||||
|
||||
/**
|
||||
* Fields in the database schema structure
|
||||
* Rows / collapsible / tabs w/o name `fields` merged to top, UIs are excluded
|
||||
@@ -559,6 +568,8 @@ export interface SanitizedCollectionConfig
|
||||
*/
|
||||
polymorphicJoins: SanitizedJoin[]
|
||||
|
||||
sanitizedIndexes: SanitizedCompoundIndex[]
|
||||
|
||||
slug: CollectionSlug
|
||||
upload: SanitizedUploadConfig
|
||||
versions: SanitizedCollectionVersions
|
||||
@@ -602,3 +613,18 @@ export type TypeWithTimestamps = {
|
||||
id: number | string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type CompoundIndex = {
|
||||
fields: string[]
|
||||
unique?: boolean
|
||||
}
|
||||
|
||||
export type SanitizedCompoundIndex = {
|
||||
fields: {
|
||||
field: FlattenedField
|
||||
localizedPath: string
|
||||
path: string
|
||||
pathHasLocalized: boolean
|
||||
}[]
|
||||
unique: boolean
|
||||
}
|
||||
|
||||
@@ -1058,6 +1058,8 @@ export type {
|
||||
TypeWithID,
|
||||
TypeWithTimestamps,
|
||||
} from './collections/config/types.js'
|
||||
export type { CompoundIndex } from './collections/config/types.js'
|
||||
export type { SanitizedCompoundIndex } from './collections/config/types.js'
|
||||
export { createDataloaderCacheKey, getDataLoader } from './collections/dataloader.js'
|
||||
export { countOperation } from './collections/operations/count.js'
|
||||
export { createOperation } from './collections/operations/create.js'
|
||||
@@ -1072,6 +1074,7 @@ export { findVersionsOperation } from './collections/operations/findVersions.js'
|
||||
export { restoreVersionOperation } from './collections/operations/restoreVersion.js'
|
||||
export { updateOperation } from './collections/operations/update.js'
|
||||
export { updateByIDOperation } from './collections/operations/updateByID.js'
|
||||
|
||||
export { buildConfig } from './config/build.js'
|
||||
export {
|
||||
type ClientConfig,
|
||||
@@ -1080,7 +1083,6 @@ export {
|
||||
serverOnlyConfigProperties,
|
||||
type UnsanitizedClientConfig,
|
||||
} from './config/client.js'
|
||||
|
||||
export { defaults } from './config/defaults.js'
|
||||
export { sanitizeConfig } from './config/sanitize.js'
|
||||
export type * from './config/types.js'
|
||||
@@ -1193,10 +1195,11 @@ export {
|
||||
ValidationError,
|
||||
ValidationErrorName,
|
||||
} from './errors/index.js'
|
||||
|
||||
export type { ValidationFieldError } from './errors/index.js'
|
||||
export { baseBlockFields } from './fields/baseFields/baseBlockFields.js'
|
||||
|
||||
export { baseIDField } from './fields/baseFields/baseIDField.js'
|
||||
|
||||
export {
|
||||
createClientField,
|
||||
createClientFields,
|
||||
@@ -1308,12 +1311,12 @@ export type {
|
||||
ValueWithRelation,
|
||||
} from './fields/config/types.js'
|
||||
export { getDefaultValue } from './fields/getDefaultValue.js'
|
||||
|
||||
export { traverseFields as afterChangeTraverseFields } from './fields/hooks/afterChange/traverseFields.js'
|
||||
export { promise as afterReadPromise } from './fields/hooks/afterRead/promise.js'
|
||||
export { traverseFields as afterReadTraverseFields } from './fields/hooks/afterRead/traverseFields.js'
|
||||
export { traverseFields as beforeChangeTraverseFields } from './fields/hooks/beforeChange/traverseFields.js'
|
||||
export { traverseFields as beforeValidateTraverseFields } from './fields/hooks/beforeValidate/traverseFields.js'
|
||||
|
||||
export { default as sortableFieldTypes } from './fields/sortableFieldTypes.js'
|
||||
export { validations } from './fields/validations.js'
|
||||
|
||||
@@ -1348,6 +1351,7 @@ export type {
|
||||
UploadFieldValidation,
|
||||
UsernameFieldValidation,
|
||||
} from './fields/validations.js'
|
||||
|
||||
export {
|
||||
type ClientGlobalConfig,
|
||||
createClientGlobalConfig,
|
||||
@@ -1367,9 +1371,7 @@ export type {
|
||||
GlobalConfig,
|
||||
SanitizedGlobalConfig,
|
||||
} from './globals/config/types.js'
|
||||
|
||||
export { docAccessOperation as docAccessOperationGlobal } from './globals/operations/docAccess.js'
|
||||
|
||||
export { findOneOperation } from './globals/operations/findOne.js'
|
||||
export { findVersionByIDOperation as findVersionByIDOperationGlobal } from './globals/operations/findVersionByID.js'
|
||||
export { findVersionsOperation as findVersionsOperationGlobal } from './globals/operations/findVersions.js'
|
||||
@@ -1414,9 +1416,9 @@ export { importHandlerPath } from './queues/operations/runJobs/runJob/importHand
|
||||
export { getLocalI18n } from './translations/getLocalI18n.js'
|
||||
export * from './types/index.js'
|
||||
export { getFileByPath } from './uploads/getFileByPath.js'
|
||||
|
||||
export type * from './uploads/types.js'
|
||||
export { addDataAndFileToRequest } from './utilities/addDataAndFileToRequest.js'
|
||||
|
||||
export { addLocalesToRequestFromData, sanitizeLocales } from './utilities/addLocalesToRequest.js'
|
||||
export { commitTransaction } from './utilities/commitTransaction.js'
|
||||
export {
|
||||
@@ -1478,6 +1480,7 @@ export { traverseFields } from './utilities/traverseFields.js'
|
||||
export type { TraverseFieldsCallback } from './utilities/traverseFields.js'
|
||||
export { buildVersionCollectionFields } from './versions/buildCollectionFields.js'
|
||||
export { buildVersionGlobalFields } from './versions/buildGlobalFields.js'
|
||||
export { buildVersionCompoundIndexes } from './versions/buildVersionCompoundIndexes.js'
|
||||
export { versionDefaults } from './versions/defaults.js'
|
||||
export { deleteCollectionVersions } from './versions/deleteCollectionVersions.js'
|
||||
export { enforceMaxVersions } from './versions/enforceMaxVersions.js'
|
||||
|
||||
70
packages/payload/src/utilities/getFieldByPath.ts
Normal file
70
packages/payload/src/utilities/getFieldByPath.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { FlattenedField } from '../fields/config/types.js'
|
||||
|
||||
/**
|
||||
* Get the field from by its path.
|
||||
* Can accept nested paths, e.g: group.title, array.group.title
|
||||
* If there were any localized on the path, pathHasLocalized will be true and localizedPath will look like:
|
||||
* group.<locale>.title // group is localized here
|
||||
*/
|
||||
export const getFieldByPath = ({
|
||||
fields,
|
||||
localizedPath = '',
|
||||
path,
|
||||
}: {
|
||||
fields: FlattenedField[]
|
||||
localizedPath?: string
|
||||
path: string
|
||||
}): {
|
||||
field: FlattenedField
|
||||
localizedPath: string
|
||||
pathHasLocalized: boolean
|
||||
} | null => {
|
||||
let currentFields: FlattenedField[] = fields
|
||||
|
||||
let currentField: FlattenedField | null = null
|
||||
|
||||
const segments = path.split('.')
|
||||
|
||||
let pathHasLocalized = false
|
||||
|
||||
while (segments.length > 0) {
|
||||
const segment = segments.shift()
|
||||
localizedPath = `${localizedPath ? `${localizedPath}.` : ''}${segment}`
|
||||
const field = currentFields.find((each) => each.name === segment)
|
||||
|
||||
if (!field) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (field.localized) {
|
||||
pathHasLocalized = true
|
||||
localizedPath = `${localizedPath}.<locale>`
|
||||
}
|
||||
|
||||
if ('flattenedFields' in field) {
|
||||
currentFields = field.flattenedFields
|
||||
}
|
||||
|
||||
if ('blocks' in field) {
|
||||
for (const block of field.blocks) {
|
||||
const maybeField = getFieldByPath({
|
||||
fields: block.flattenedFields,
|
||||
localizedPath,
|
||||
path: [...segments].join('.'),
|
||||
})
|
||||
|
||||
if (maybeField) {
|
||||
return maybeField
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentField = field
|
||||
}
|
||||
|
||||
if (!currentField) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { field: currentField, localizedPath, pathHasLocalized }
|
||||
}
|
||||
17
packages/payload/src/versions/buildVersionCompoundIndexes.ts
Normal file
17
packages/payload/src/versions/buildVersionCompoundIndexes.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { SanitizedCompoundIndex } from '../collections/config/types.js'
|
||||
|
||||
export const buildVersionCompoundIndexes = ({
|
||||
indexes,
|
||||
}: {
|
||||
indexes: SanitizedCompoundIndex[]
|
||||
}): SanitizedCompoundIndex[] => {
|
||||
return indexes.map((each) => ({
|
||||
fields: each.fields.map(({ field, localizedPath, path, pathHasLocalized }) => ({
|
||||
field,
|
||||
localizedPath: `version.${localizedPath}`,
|
||||
path: `version.${path}`,
|
||||
pathHasLocalized,
|
||||
})),
|
||||
unique: false,
|
||||
}))
|
||||
}
|
||||
@@ -4,10 +4,9 @@ const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
import type { TextField } from 'payload'
|
||||
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
|
||||
import { devUser } from '../credentials.js'
|
||||
import { seed } from './seed.js'
|
||||
import {
|
||||
customIDsSlug,
|
||||
@@ -501,7 +500,7 @@ export default buildConfigWithDefaults({
|
||||
beforeChange: [
|
||||
({ value, operation }) => {
|
||||
if (operation === 'create') {
|
||||
return uuid()
|
||||
return randomUUID()
|
||||
}
|
||||
return value
|
||||
},
|
||||
@@ -564,6 +563,43 @@ export default buildConfigWithDefaults({
|
||||
],
|
||||
versions: true,
|
||||
},
|
||||
{
|
||||
slug: 'compound-indexes',
|
||||
fields: [
|
||||
{
|
||||
name: 'one',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'two',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'three',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'four',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
indexes: [
|
||||
{
|
||||
fields: ['one', 'two'],
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
fields: ['three', 'group.four'],
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
globals: [
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
migrateRelationshipsV2_V3,
|
||||
migrateVersionsV1_V2,
|
||||
} from '@payloadcms/db-mongodb/migration-utils'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type Table } from 'drizzle-orm'
|
||||
import * as drizzlePg from 'drizzle-orm/pg-core'
|
||||
import * as drizzleSqlite from 'drizzle-orm/sqlite-core'
|
||||
@@ -305,6 +306,68 @@ describe('database', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Compound Indexes', () => {
|
||||
beforeEach(async () => {
|
||||
await payload.delete({ collection: 'compound-indexes', where: {} })
|
||||
})
|
||||
|
||||
it('top level: should throw a unique error', async () => {
|
||||
await payload.create({
|
||||
collection: 'compound-indexes',
|
||||
data: { three: randomUUID(), one: '1', two: '2' },
|
||||
})
|
||||
|
||||
// does not fail
|
||||
await payload.create({
|
||||
collection: 'compound-indexes',
|
||||
data: { three: randomUUID(), one: '1', two: '3' },
|
||||
})
|
||||
// does not fail
|
||||
await payload.create({
|
||||
collection: 'compound-indexes',
|
||||
data: { three: randomUUID(), one: '-1', two: '2' },
|
||||
})
|
||||
|
||||
// fails
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'compound-indexes',
|
||||
data: { three: randomUUID(), one: '1', two: '2' },
|
||||
}),
|
||||
).rejects.toBeTruthy()
|
||||
})
|
||||
|
||||
it('combine group and top level: should throw a unique error', async () => {
|
||||
await payload.create({
|
||||
collection: 'compound-indexes',
|
||||
data: {
|
||||
one: randomUUID(),
|
||||
three: '3',
|
||||
group: { four: '4' },
|
||||
},
|
||||
})
|
||||
|
||||
// does not fail
|
||||
await payload.create({
|
||||
collection: 'compound-indexes',
|
||||
data: { one: randomUUID(), three: '3', group: { four: '5' } },
|
||||
})
|
||||
// does not fail
|
||||
await payload.create({
|
||||
collection: 'compound-indexes',
|
||||
data: { one: randomUUID(), three: '4', group: { four: '4' } },
|
||||
})
|
||||
|
||||
// fails
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'compound-indexes',
|
||||
data: { one: randomUUID(), three: '3', group: { four: '4' } },
|
||||
}),
|
||||
).rejects.toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrations', () => {
|
||||
let ranFreshTest = false
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface Config {
|
||||
'custom-ids': CustomId;
|
||||
'fake-custom-ids': FakeCustomId;
|
||||
'relationships-migration': RelationshipsMigration;
|
||||
'compound-indexes': CompoundIndex;
|
||||
users: User;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
@@ -97,6 +98,7 @@ export interface Config {
|
||||
'custom-ids': CustomIdsSelect<false> | CustomIdsSelect<true>;
|
||||
'fake-custom-ids': FakeCustomIdsSelect<false> | FakeCustomIdsSelect<true>;
|
||||
'relationships-migration': RelationshipsMigrationSelect<false> | RelationshipsMigrationSelect<true>;
|
||||
'compound-indexes': CompoundIndexesSelect<false> | CompoundIndexesSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
@@ -400,6 +402,21 @@ export interface RelationshipsMigration {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "compound-indexes".
|
||||
*/
|
||||
export interface CompoundIndex {
|
||||
id: string;
|
||||
one?: string | null;
|
||||
two?: string | null;
|
||||
three?: string | null;
|
||||
group?: {
|
||||
four?: string | null;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
@@ -472,6 +489,10 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'relationships-migration';
|
||||
value: string | RelationshipsMigration;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'compound-indexes';
|
||||
value: string | CompoundIndex;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
@@ -755,6 +776,22 @@ export interface RelationshipsMigrationSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "compound-indexes_select".
|
||||
*/
|
||||
export interface CompoundIndexesSelect<T extends boolean = true> {
|
||||
one?: T;
|
||||
two?: T;
|
||||
three?: T;
|
||||
group?:
|
||||
| T
|
||||
| {
|
||||
four?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
|
||||
Reference in New Issue
Block a user