diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index b1aef90d2..93a8dc19b 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -16,6 +16,7 @@ It's often best practice to write your Collections in separate files and then im |--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | **`slug`** * | Unique, URL-friendly string that will act as an identifier for this Collection. | | **`fields`** * | Array of field types that will determine the structure and functionality of the data stored within this Collection. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. | +| **`indexes`** * | Array of database indexes to create, including compound indexes that have multiple fields. | | **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. | | **`admin`** | Admin-specific configuration. See below for [more detail](#admin-options). | | **`hooks`** | Entry points to "tie in" to Collection actions at specific points. [More](/docs/hooks/overview#collection-hooks) | @@ -52,7 +53,7 @@ const Orders: CollectionConfig = { relationTo: 'customers', required: true, } - ] + ], }; ``` diff --git a/src/collections/buildSchema.ts b/src/collections/buildSchema.ts index dd4c11ca8..44152b62f 100644 --- a/src/collections/buildSchema.ts +++ b/src/collections/buildSchema.ts @@ -24,7 +24,11 @@ const buildCollectionSchema = (collection: SanitizedCollectionConfig, config: Sa schema.index({ updatedAt: 1 }); schema.index({ createdAt: 1 }); } - + if (collection.indexes) { + collection.indexes.forEach((index) => { + schema.index(index.fields, index.options); + }); + } schema.plugin(paginate, { useEstimatedCount: true }) .plugin(getBuildQueryPlugin({ collectionSlug: collection.slug })); diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts index de03bba87..7c83ae65d 100644 --- a/src/collections/config/schema.ts +++ b/src/collections/config/schema.ts @@ -72,6 +72,12 @@ const collectionSchema = joi.object().keys({ hideAPIURL: joi.bool(), }), fields: joi.array(), + indexes: joi.array().items( + joi.object().keys({ + fields: joi.object().required(), + options: joi.object(), + }), + ), hooks: joi.object({ beforeOperation: joi.array().items(joi.func()), beforeValidate: joi.array().items(joi.func()), diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index 082b57778..b64b92fc5 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { DeepRequired } from 'ts-essentials'; -import { AggregatePaginateModel, Model, PaginateModel } from 'mongoose'; +import { AggregatePaginateModel, IndexDefinition, IndexOptions, Model, PaginateModel } from 'mongoose'; import { GraphQLInputObjectType, GraphQLNonNull, GraphQLObjectType } from 'graphql'; import { Response } from 'express'; import { Access, Endpoint, EntityDescription, GeneratePreviewURL } from '../../config/types'; @@ -220,8 +220,8 @@ export type CollectionConfig = { plural?: Record | string; }; /** - * Default field to sort by in collection list view - */ + * Default field to sort by in collection list view + */ defaultSort?: string; /** * GraphQL configuration @@ -240,6 +240,10 @@ export type CollectionConfig = { interface?: string } fields: Field[]; + /** + * Array of database indexes to create, including compound indexes that have multiple fields + */ + indexes?: TypeOfIndex[]; /** * Collection admin options */ @@ -352,3 +356,8 @@ export type TypeWithTimestamps = { updatedAt: string [key: string]: unknown } + +export type TypeOfIndex = { + fields: IndexDefinition + options?: IndexOptions +} diff --git a/src/collections/initLocal.ts b/src/collections/initLocal.ts index 8b1eae234..b37ddc31a 100644 --- a/src/collections/initLocal.ts +++ b/src/collections/initLocal.ts @@ -77,6 +77,17 @@ export default function initCollectionsLocal(ctx: Payload): void { }, ); + if (collection.indexes) { + collection.indexes.forEach((index) => { + // prefix 'version.' to each field in the index + const versionIndex = { fields: {}, options: index.options }; + Object.entries(index.fields).forEach(([key, value]) => { + versionIndex.fields[`version.${key}`] = value; + }); + versionSchema.index(versionIndex.fields, versionIndex.options); + }); + } + versionSchema.plugin(paginate, { useEstimatedCount: true }) .plugin(getBuildQueryPlugin({ collectionSlug: collection.slug, versionsFields: versionCollectionFields })); diff --git a/test/fields/collections/Indexed/index.ts b/test/fields/collections/Indexed/index.ts index 453e76b17..2321e8814 100644 --- a/test/fields/collections/Indexed/index.ts +++ b/test/fields/collections/Indexed/index.ts @@ -11,11 +11,15 @@ const beforeDuplicate: BeforeDuplicate = ({ data }) => { }, collapsibleTextUnique: data.collapsibleTextUnique ? `${data.collapsibleTextUnique}-copy` : '', collapsibleLocalizedUnique: data.collapsibleLocalizedUnique ? `${data.collapsibleLocalizedUnique}-copy` : '', + partOne: data.partOne ? `${data.partOne}-copy` : '', + partTwo: data.partTwo ? `${data.partTwo}-copy` : '', }; }; const IndexedFields: CollectionConfig = { slug: 'indexed-fields', + // used to assert that versions also get indexes + versions: true, admin: { hooks: { beforeDuplicate, @@ -71,6 +75,20 @@ const IndexedFields: CollectionConfig = { }, ], }, + { + name: 'partOne', + type: 'text', + }, + { + name: 'partTwo', + type: 'text', + }, + ], + indexes: [ + { + fields: { partOne: 1, partTwo: 1 }, + options: { unique: true, name: 'compound-index', sparse: true }, + }, ], }; diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index a1eaa4efb..fe76ed438 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -4,8 +4,8 @@ import { RESTClient } from '../helpers/rest'; import configPromise from '../uploads/config'; import payload from '../../src'; import { pointDoc } from './collections/Point'; -import { arrayFieldsSlug, arrayDefaultValue, arrayDoc } from './collections/Array'; -import { groupFieldsSlug, groupDefaultChild, groupDefaultValue, groupDoc } from './collections/Group'; +import { arrayDefaultValue, arrayDoc, arrayFieldsSlug } from './collections/Array'; +import { groupDefaultChild, groupDefaultValue, groupDoc, groupFieldsSlug } from './collections/Group'; import { defaultText } from './collections/Text'; import { blocksFieldSeedData } from './collections/Blocks'; import { localizedTextValue, namedTabDefaultValue, namedTabText, tabsDoc, tabsSlug } from './collections/Tabs'; @@ -162,9 +162,6 @@ describe('Fields', () => { const options: Record = {}; beforeAll(() => { - // mongoose model schema indexes do not always create indexes in the actual database - // see: https://github.com/payloadcms/payload/issues/571 - indexes = payload.collections['indexed-fields'].Model.schema.indexes() as [Record, IndexOptions]; indexes.forEach((index) => { @@ -200,7 +197,10 @@ describe('Fields', () => { expect(definitions.collapsibleTextUnique).toEqual(1); expect(options.collapsibleTextUnique).toMatchObject({ unique: true }); }); - + it('should have unique compound indexes', () => { + expect(definitions.partOne).toEqual(1); + expect(options.partOne).toMatchObject({ unique: true, name: 'compound-index', sparse: true }); + }); it('should throw validation error saving on unique fields', async () => { const data = { text: 'a', @@ -218,6 +218,56 @@ describe('Fields', () => { return result.error; }).toBeDefined(); }); + it('should throw validation error saving on unique combined fields', async () => { + await payload.delete({ collection: 'indexed-fields', where: {} }); + const data1 = { + text: 'a', + uniqueText: 'a', + partOne: 'u', + partTwo: 'u', + }; + const data2 = { + text: 'b', + uniqueText: 'b', + partOne: 'u', + partTwo: 'u', + }; + await payload.create({ + collection: 'indexed-fields', + data: data1, + }); + expect(async () => { + const result = await payload.create({ + collection: 'indexed-fields', + data: data2, + }); + return result.error; + }).toBeDefined(); + }); + }); + + describe('version indexes', () => { + let indexes; + const definitions: Record = {}; + const options: Record = {}; + + beforeAll(() => { + indexes = payload.versions['indexed-fields'].schema.indexes() as [Record, IndexOptions]; + indexes.forEach((index) => { + const field = Object.keys(index[0])[0]; + definitions[field] = index[0][field]; + // eslint-disable-next-line prefer-destructuring + options[field] = index[1]; + }); + }); + + it('should have versions indexes', () => { + expect(definitions['version.text']).toEqual(1); + }); + it('should have version indexes from collection indexes', () => { + expect(definitions['version.partOne']).toEqual(1); + expect(options['version.partOne']).toMatchObject({ unique: true, name: 'compound-index', sparse: true }); + }); }); describe('point', () => { diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index ddefa765d..b02d199d0 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -328,6 +328,8 @@ export interface IndexedField { }; collapsibleLocalizedUnique?: string; collapsibleTextUnique?: string; + partOne?: string; + partTwo?: string; createdAt: string; updatedAt: string; }