Skip to content

Commit

Permalink
feat: add @Index, createIndexDecorator (#15482)
Browse files Browse the repository at this point in the history
  • Loading branch information
ephys committed Dec 19, 2022
1 parent bb4d401 commit ab9aaba
Show file tree
Hide file tree
Showing 9 changed files with 240 additions and 21 deletions.
12 changes: 6 additions & 6 deletions src/decorators/legacy/attribute-utils.ts
Expand Up @@ -43,14 +43,18 @@ export function createOptionalAttributeOptionsDecorator<T>(
callback: (
option: T,
target: Object,
propertyName: string | symbol,
propertyName: string,
propertyDescriptor: PropertyDescriptor | undefined,
) => Partial<AttributeOptions>,
): OptionalParameterizedPropertyDecorator<T> {
return createOptionallyParameterizedPropertyDecorator(
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);
Expand All @@ -61,14 +65,10 @@ export function createOptionalAttributeOptionsDecorator<T>(
function annotate(
decoratorName: string,
target: Object,
propertyName: string | symbol,
propertyName: string,
propertyDescriptor: PropertyDescriptor | undefined,
options: Partial<AttributeOptions>,
): 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);
}
Expand Down
37 changes: 35 additions & 2 deletions 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';

Expand Down Expand Up @@ -55,4 +57,35 @@ export const Default = createRequiredAttributeOptionsDecorator<unknown>('Default
/**
* Sets the name of the column (in the database) this attribute maps to.
*/
export const ColumnName = createRequiredAttributeOptionsDecorator<string>('ColumnName', (columnName: string) => ({ field: columnName }));
export const ColumnName = createRequiredAttributeOptionsDecorator<string>('ColumnName', (columnName: string) => ({ columnName }));

type IndexAttributeOption = NonUndefined<AttributeIndexOptions['attribute']>;

export function createIndexDecorator(decoratorName: string, options: Omit<AttributeIndexOptions, 'attribute'> = {}) {
return createOptionalAttributeOptionsDecorator<IndexAttributeOption>(
decoratorName,
{},
(indexField: IndexAttributeOption): Partial<AttributeOptions> => {
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<AttributeOptions['index']>;

export const Index = createOptionalAttributeOptionsDecorator<IndexDecoratorOptions>(
'Index',
{},
(indexField: IndexDecoratorOptions): Partial<AttributeOptions> => {
return {
index: indexField,
};
},
);
2 changes: 2 additions & 0 deletions src/decorators/legacy/index.mjs
Expand Up @@ -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

Expand Down
17 changes: 9 additions & 8 deletions src/decorators/shared/model.ts
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/dialects/abstract/query-interface.d.ts
Expand Up @@ -154,6 +154,7 @@ export interface IndexOptions {
/**
* The fields to index.
*/
// TODO: rename to "columns"
fields?: Array<string | IndexField | Fn | Literal>;

/**
Expand Down
19 changes: 16 additions & 3 deletions src/model-definition.ts
Expand Up @@ -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],
Expand All @@ -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,
],
});
}
}
Expand Down
10 changes: 9 additions & 1 deletion src/model.d.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -1746,7 +1747,7 @@ export interface AttributeOptions<M extends Model = Model> {
* 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<boolean | string | IndexOptions>;
index?: AllowArray<boolean | string | AttributeIndexOptions>;

/**
* If true, this attribute will be marked as primary key
Expand Down Expand Up @@ -1821,6 +1822,13 @@ export interface AttributeOptions<M extends Model = Model> {
_autoGenerated?: boolean;
}

export interface AttributeIndexOptions extends Omit<IndexOptions, 'fields'> {
/**
* Configures the options for this index attribute.
*/
attribute?: Omit<IndexField, 'name'>;
}

export interface NormalizedAttributeOptions<M extends Model = Model> extends Readonly<Omit<
PartlyRequired<AttributeOptions<M>, 'columnName'>,
| 'type'
Expand Down
121 changes: 120 additions & 1 deletion 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`, () => {
Expand Down Expand Up @@ -199,6 +199,125 @@ describe(`@Attribute legacy decorator`, () => {
},
]);
});

it('merges "index"', () => {
class User extends Model<InferAttributes<User>> {
@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<InferAttributes<User>> {
@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<InferAttributes<User>> {
@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', () => {
Expand Down
42 changes: 42 additions & 0 deletions test/unit/model/indexes.test.ts
Expand Up @@ -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',
},
]);
});
});

0 comments on commit ab9aaba

Please sign in to comment.