From ab9aabac84a054eb436dc7c3eb7c15ea081e3cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=A9?= Date: Mon, 19 Dec 2022 10:51:40 +0100 Subject: [PATCH] feat: add @Index, createIndexDecorator (#15482) --- src/decorators/legacy/attribute-utils.ts | 12 +- src/decorators/legacy/attribute.ts | 37 ++++++- src/decorators/legacy/index.mjs | 2 + src/decorators/shared/model.ts | 17 +-- src/dialects/abstract/query-interface.d.ts | 1 + src/model-definition.ts | 19 +++- src/model.d.ts | 10 +- test/unit/decorators/attribute.test.ts | 121 ++++++++++++++++++++- test/unit/model/indexes.test.ts | 42 +++++++ 9 files changed, 240 insertions(+), 21 deletions(-) diff --git a/src/decorators/legacy/attribute-utils.ts b/src/decorators/legacy/attribute-utils.ts index e74602d7e7a9..33ac2b95edab 100644 --- a/src/decorators/legacy/attribute-utils.ts +++ b/src/decorators/legacy/attribute-utils.ts @@ -43,7 +43,7 @@ export function createOptionalAttributeOptionsDecorator( callback: ( option: T, target: Object, - propertyName: string | symbol, + propertyName: string, propertyDescriptor: PropertyDescriptor | undefined, ) => Partial, ): OptionalParameterizedPropertyDecorator { @@ -51,6 +51,10 @@ export function createOptionalAttributeOptionsDecorator( decoratorName, defaultValue, (decoratorOption, target, propertyName, propertyDescriptor) => { + if (typeof propertyName === 'symbol') { + throw new TypeError('Symbol Model Attributes are not currently supported. We welcome a PR that implements this feature.'); + } + const attributeOptions = callback(decoratorOption, target, propertyName, propertyDescriptor); annotate(decoratorName, target, propertyName, propertyDescriptor, attributeOptions); @@ -61,14 +65,10 @@ export function createOptionalAttributeOptionsDecorator( function annotate( decoratorName: string, target: Object, - propertyName: string | symbol, + propertyName: string, propertyDescriptor: PropertyDescriptor | undefined, options: Partial, ): void { - if (typeof propertyName === 'symbol') { - throw new TypeError('Symbol Model Attributes are not currently supported. We welcome a PR that implements this feature.'); - } - if (typeof target === 'function') { throwMustBeInstanceProperty(decoratorName, target, propertyName); } diff --git a/src/decorators/legacy/attribute.ts b/src/decorators/legacy/attribute.ts index 33b8424b1fef..dd1bc054ee03 100644 --- a/src/decorators/legacy/attribute.ts +++ b/src/decorators/legacy/attribute.ts @@ -1,7 +1,9 @@ import { isDataType } from '../../dialects/abstract/data-types-utils.js'; import type { DataType } from '../../dialects/abstract/data-types.js'; -import type { AttributeOptions } from '../../model.js'; +import type { AttributeIndexOptions, AttributeOptions } from '../../model.js'; import { columnToAttribute } from '../../utils/deprecations.js'; +import { underscore } from '../../utils/string.js'; +import type { NonUndefined } from '../../utils/types.js'; import { createOptionalAttributeOptionsDecorator, createRequiredAttributeOptionsDecorator } from './attribute-utils.js'; import type { PropertyOrGetterDescriptor } from './decorator-utils.js'; @@ -55,4 +57,35 @@ export const Default = createRequiredAttributeOptionsDecorator('Default /** * Sets the name of the column (in the database) this attribute maps to. */ -export const ColumnName = createRequiredAttributeOptionsDecorator('ColumnName', (columnName: string) => ({ field: columnName })); +export const ColumnName = createRequiredAttributeOptionsDecorator('ColumnName', (columnName: string) => ({ columnName })); + +type IndexAttributeOption = NonUndefined; + +export function createIndexDecorator(decoratorName: string, options: Omit = {}) { + return createOptionalAttributeOptionsDecorator( + decoratorName, + {}, + (indexField: IndexAttributeOption): Partial => { + const index: AttributeIndexOptions = { + ...options, + // TODO: default index name should be generated using https://github.com/sequelize/sequelize/issues/15312 + name: options.name || underscore(decoratorName), + attribute: indexField, + }; + + return { index }; + }, + ); +} + +type IndexDecoratorOptions = NonUndefined; + +export const Index = createOptionalAttributeOptionsDecorator( + 'Index', + {}, + (indexField: IndexDecoratorOptions): Partial => { + return { + index: indexField, + }; + }, +); diff --git a/src/decorators/legacy/index.mjs b/src/decorators/legacy/index.mjs index 3da996bb0d75..88ffede2fda2 100644 --- a/src/decorators/legacy/index.mjs +++ b/src/decorators/legacy/index.mjs @@ -16,6 +16,8 @@ export const Default = Pkg.Default; export const NotNull = Pkg.NotNull; export const PrimaryKey = Pkg.PrimaryKey; export const Unique = Pkg.Unique; +export const Index = Pkg.Index; +export const createIndexDecorator = Pkg.createIndexDecorator; // Validation Decorators diff --git a/src/decorators/shared/model.ts b/src/decorators/shared/model.ts index b9394e2c8188..03506d0f305f 100644 --- a/src/decorators/shared/model.ts +++ b/src/decorators/shared/model.ts @@ -95,18 +95,19 @@ export function registerModelAttributeOptions( continue; } - if (optionName === 'unique') { - if (!existingOptions.unique) { - existingOptions.unique = []; - } else if (!Array.isArray(existingOptions.unique)) { - existingOptions.unique = [existingOptions.unique]; + if (optionName === 'index' || optionName === 'unique') { + if (!existingOptions[optionName]) { + existingOptions[optionName] = []; + } else if (!Array.isArray(existingOptions[optionName])) { + // @ts-expect-error -- runtime type checking is enforced by model + existingOptions[optionName] = [existingOptions[optionName]]; } if (Array.isArray(optionValue)) { - existingOptions.unique = [...existingOptions.unique, ...optionValue]; - } else { // @ts-expect-error -- runtime type checking is enforced by model - existingOptions.unique = [...existingOptions.unique, optionValue]; + existingOptions[optionName] = [...existingOptions[optionName], ...optionValue]; + } else { + existingOptions[optionName] = [...existingOptions[optionName], optionValue]; } continue; diff --git a/src/dialects/abstract/query-interface.d.ts b/src/dialects/abstract/query-interface.d.ts index d9f10e6de0c7..be570e4a27eb 100644 --- a/src/dialects/abstract/query-interface.d.ts +++ b/src/dialects/abstract/query-interface.d.ts @@ -154,6 +154,7 @@ export interface IndexOptions { /** * The fields to index. */ + // TODO: rename to "columns" fields?: Array; /** diff --git a/src/model-definition.ts b/src/model-definition.ts index d0718b309a6e..edc39dd5dffd 100644 --- a/src/model-definition.ts +++ b/src/model-definition.ts @@ -566,6 +566,10 @@ Timestamp attributes are managed automatically by Sequelize, and their nullabili for (const index of indexes) { const jsonbIndexDefaults = rawAttribute.type instanceof DataTypes.JSONB ? { using: 'gin' } : undefined; + if (!index) { + continue; + } + if (index === true || typeof index === 'string') { attributeIndexes.push({ fields: [builtAttribute.columnName], @@ -575,13 +579,22 @@ Timestamp attributes are managed automatically by Sequelize, and their nullabili } else { // @ts-expect-error -- forbidden property if (index.fields) { - throw new Error('"fields" cannot be specified for indexes defined on attributes. Use the "indexes" option on the table definition instead.'); + throw new Error('"fields" cannot be specified for indexes defined on attributes. Use the "indexes" option on the table definition instead. You can also customize how this attribute is part of the index by specifying the "attribute" option on the index.'); } + const { attribute: indexAttributeOptions, ...indexOptions } = index; + attributeIndexes.push({ ...jsonbIndexDefaults, - ...index, - fields: [builtAttribute.columnName], + ...indexOptions, + fields: [ + indexAttributeOptions + ? { + ...indexAttributeOptions, + name: builtAttribute.columnName, + } + : builtAttribute.columnName, + ], }); } } diff --git a/src/model.d.ts b/src/model.d.ts index cc8352117aed..84200062acfa 100644 --- a/src/model.d.ts +++ b/src/model.d.ts @@ -16,6 +16,7 @@ import type { IndexOptions, TableName, TableNameWithSchema, + IndexField, } from './dialects/abstract/query-interface'; import type { IndexHints } from './index-hints'; import type { ValidationOptions } from './instance-validator'; @@ -1746,7 +1747,7 @@ export interface AttributeOptions { * If true, an index will be created for this column. * If a string is provided, the column will be part of a composite index together with the other attributes that specify the same index name. */ - index?: AllowArray; + index?: AllowArray; /** * If true, this attribute will be marked as primary key @@ -1821,6 +1822,13 @@ export interface AttributeOptions { _autoGenerated?: boolean; } +export interface AttributeIndexOptions extends Omit { + /** + * Configures the options for this index attribute. + */ + attribute?: Omit; +} + export interface NormalizedAttributeOptions extends Readonly, 'columnName'>, | 'type' diff --git a/test/unit/decorators/attribute.test.ts b/test/unit/decorators/attribute.test.ts index 1d028e30d383..c6279f0bc79b 100644 --- a/test/unit/decorators/attribute.test.ts +++ b/test/unit/decorators/attribute.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import type { InferAttributes } from '@sequelize/core'; import { Model, DataTypes } from '@sequelize/core'; -import { Table, Attribute, Unique, AutoIncrement, PrimaryKey, NotNull, AllowNull, Comment, Default, ColumnName } from '@sequelize/core/decorators-legacy'; +import { Table, Attribute, Unique, AutoIncrement, PrimaryKey, NotNull, AllowNull, Comment, Default, ColumnName, Index, createIndexDecorator } from '@sequelize/core/decorators-legacy'; import { sequelize } from '../../support'; describe(`@Attribute legacy decorator`, () => { @@ -199,6 +199,125 @@ describe(`@Attribute legacy decorator`, () => { }, ]); }); + + it('merges "index"', () => { + class User extends Model> { + @Attribute(DataTypes.STRING) + @Attribute({ + index: 'firstName-lastName', + }) + @Index({ + name: 'firstName-country', + }) + @Index + @ColumnName('first_name') + declare firstName: string; + + @Attribute(DataTypes.STRING) + @Index({ + name: 'firstName-lastName', + attribute: { + collate: 'en_US', + }, + }) + declare lastName: string; + + @Attribute(DataTypes.STRING) + @Index('firstName-country') + declare country: string; + } + + sequelize.addModels([User]); + + expect(User.getIndexes()).to.deep.equal([ + { + fields: ['first_name'], + column: 'firstName', + name: 'users_first_name', + }, + { + fields: ['first_name', 'country'], + column: 'firstName', + name: 'firstName-country', + }, + { + fields: [ + 'first_name', + { + collate: 'en_US', + name: 'lastName', + }, + ], + column: 'firstName', + name: 'firstName-lastName', + }, + ]); + }); +}); + +describe('createIndexDecorator', () => { + it('makes it possible to create a composite index with options', () => { + const MyIndex = createIndexDecorator('MyIndex', { + name: 'my_custom_index', + type: 'fulltext', + where: { name: null }, + }); + + class User extends Model> { + @Attribute(DataTypes.STRING) + @MyIndex + @ColumnName('first_name') + declare firstName: string; + + @Attribute(DataTypes.STRING) + @MyIndex({ + order: 'DESC', + }) + declare lastName: string; + } + + sequelize.addModels([User]); + + expect(User.getIndexes()).to.deep.equal([ + { + fields: [ + { + name: 'first_name', + }, + { + name: 'lastName', + order: 'DESC', + }, + ], + name: 'my_custom_index', + type: 'fulltext', + where: { name: null }, + }, + ]); + }); + + it('uses a snake-case version of the decorator name as the default index name', () => { + const MyIndex = createIndexDecorator('MyIndex'); + + class User extends Model> { + @Attribute(DataTypes.STRING) + @MyIndex + declare firstName: string; + } + + sequelize.addModels([User]); + + expect(User.getIndexes()).to.deep.equal([ + { + fields: [ + { + name: 'firstName', + }, + ], + name: 'my_index', + }, + ]); + }); }); describe('@AllowNull legacy decorator', () => { diff --git a/test/unit/model/indexes.test.ts b/test/unit/model/indexes.test.ts index 442834266eec..6f55e87b1389 100644 --- a/test/unit/model/indexes.test.ts +++ b/test/unit/model/indexes.test.ts @@ -161,4 +161,46 @@ describe('Model indexes', () => { }, ]); }); + + it('supports configuring the index attribute options', () => { + const User = sequelize.define('User', { + firstName: { + type: DataTypes.STRING, + columnName: 'first_name', + index: { + name: 'first_last_name', + unique: true, + attribute: { + collate: 'en_US', + operator: 'text_pattern_ops', + order: 'DESC', + }, + }, + }, + lastName: { + type: DataTypes.STRING, + columnName: 'last_name', + index: { + name: 'first_last_name', + unique: true, + }, + }, + }); + + expect(User.getIndexes()).to.deep.eq([ + { + fields: [ + { + name: 'first_name', + collate: 'en_US', + operator: 'text_pattern_ops', + order: 'DESC', + }, + 'last_name', + ], + unique: true, + name: 'first_last_name', + }, + ]); + }); });