feat: supports collection compound indexes (#2529)
Co-authored-by: Perry Li <yuanping.li@moblab.com> Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
]
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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, string> | 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
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -11,11 +11,15 @@ const beforeDuplicate: BeforeDuplicate<IndexedField> = ({ 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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, IndexOptions> = {};
|
||||
|
||||
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<string, IndexDirection>, 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<string, IndexDirection> = {};
|
||||
const options: Record<string, IndexOptions> = {};
|
||||
|
||||
beforeAll(() => {
|
||||
indexes = payload.versions['indexed-fields'].schema.indexes() as [Record<string, IndexDirection>, 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', () => {
|
||||
|
||||
@@ -328,6 +328,8 @@ export interface IndexedField {
|
||||
};
|
||||
collapsibleLocalizedUnique?: string;
|
||||
collapsibleTextUnique?: string;
|
||||
partOne?: string;
|
||||
partTwo?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user