Skip to content

Commit

Permalink
feat: add @Unique decorator, support specifying multiple unique ind…
Browse files Browse the repository at this point in the history
…exes on the same attribute (#15342)
  • Loading branch information
ephys committed Nov 27, 2022
1 parent 3228dbd commit 43bca57
Show file tree
Hide file tree
Showing 13 changed files with 281 additions and 109 deletions.
4 changes: 2 additions & 2 deletions src/associations/belongs-to-many.ts
Expand Up @@ -376,8 +376,8 @@ Add your own primary key to the through model, on different attributes than the
uniqueKey = [this.through.model.tableName, ...keys, 'unique'].join('_');
}

this.throughModel.rawAttributes[this.foreignKey].unique = uniqueKey;
this.throughModel.rawAttributes[this.otherKey].unique = uniqueKey;
this.throughModel.rawAttributes[this.foreignKey].unique = [{ name: uniqueKey }];
this.throughModel.rawAttributes[this.otherKey].unique = [{ name: uniqueKey }];
}

this.throughModel.refreshAttributes();
Expand Down
1 change: 1 addition & 0 deletions src/associations/belongs-to.ts
Expand Up @@ -117,6 +117,7 @@ export class BelongsTo<
// for non primary columns.
if (target.sequelize.options.dialect === 'db2' && this.target.getAttributes()[this.targetKey].primaryKey !== true) {
// TODO: throw instead
// @ts-expect-error
this.target.getAttributes()[this.targetKey].unique = true;
}

Expand Down
58 changes: 37 additions & 21 deletions src/decorators/legacy/attribute.ts
Expand Up @@ -4,38 +4,58 @@ import type { ModelAttributeColumnOptions, ModelStatic } from '../../model.js';
import { Model } from '../../model.js';
import { columnToAttribute } from '../../utils/deprecations.js';
import { registerModelAttributeOptions } from '../shared/model.js';
import type { PropertyOrGetterDescriptor } from './decorator-utils.js';
import { makeParameterizedPropertyDecorator } from './decorator-utils.js';

export function Attribute(optionsOrDataType: DataType | ModelAttributeColumnOptions): PropertyDecorator {
return (target: Object, propertyName: string | symbol, propertyDescriptor?: PropertyDescriptor) => {
if (typeof propertyName === 'symbol') {
throw new TypeError('Symbol Model Attributes are not currently supported. We welcome a PR that implements this feature.');
}
type AttributeDecoratorOption = DataType | Partial<ModelAttributeColumnOptions> | undefined;

annotate(
target,
propertyName,
propertyDescriptor ?? Object.getOwnPropertyDescriptor(target, propertyName),
optionsOrDataType,
);
};
}
export const Attribute = makeParameterizedPropertyDecorator<AttributeDecoratorOption>(undefined, (
option: AttributeDecoratorOption,
target: Object,
propertyName: string | symbol,
propertyDescriptor?: PropertyDescriptor,
) => {
if (!option) {
throw new Error('Decorator @Attribute requires an argument');
}

annotate(target, propertyName, propertyDescriptor, option);
});

/**
* @param optionsOrDataType
* @deprecated use {@link Attribute} instead.
*/
export function Column(optionsOrDataType: DataType | ModelAttributeColumnOptions): PropertyDecorator {
export function Column(optionsOrDataType: DataType | ModelAttributeColumnOptions): PropertyOrGetterDescriptor {
columnToAttribute();

return Attribute(optionsOrDataType);
}

type UniqueOptions = NonNullable<ModelAttributeColumnOptions['unique']>;

/**
* Sets the unique option true for annotated property
*/
export const Unique = makeParameterizedPropertyDecorator<UniqueOptions>(true, (
option: UniqueOptions,
target: Object,
propertyName: string | symbol,
propertyDescriptor?: PropertyDescriptor,
) => {
annotate(target, propertyName, propertyDescriptor, { unique: option });
});

function annotate(
target: Object,
propertyName: string,
propertyName: string | symbol,
propertyDescriptor: PropertyDescriptor | undefined,
optionsOrDataType: ModelAttributeColumnOptions | DataType,
optionsOrDataType: Partial<ModelAttributeColumnOptions> | DataType,
): 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') {
throw new TypeError(
`Decorator @Attribute has been used on "${target.name}.${String(propertyName)}", which is static. This decorator can only be used on instance properties, setters and getters.`,
Expand All @@ -48,7 +68,7 @@ function annotate(
);
}

let options: ModelAttributeColumnOptions;
let options: Partial<ModelAttributeColumnOptions>;

if (isDataType(optionsOrDataType)) {
options = {
Expand All @@ -58,10 +78,6 @@ function annotate(
options = { ...optionsOrDataType };
}

if (!options.type) {
throw new Error(`Decorator @Attribute has been used on "${target.constructor.name}.${String(propertyName)}" but does not specify the data type of the attribute. Please specify a data type.`);
}

if (propertyDescriptor) {
if (propertyDescriptor.get) {
options.get = propertyDescriptor.get;
Expand Down
44 changes: 44 additions & 0 deletions src/decorators/legacy/decorator-utils.ts
@@ -0,0 +1,44 @@
export type PropertyOrGetterDescriptor = (
target: Object,
propertyName: string | symbol,
propertyDescriptor?: PropertyDescriptor,
) => void;

export interface ParameterizedPropertyDecorator<T> {
(options: T): PropertyOrGetterDescriptor;

(target: Object, propertyName: string | symbol, propertyDescriptor?: PropertyDescriptor): void;
}

/**
* Makes a decorator that can optionally receive a parameter
*
* @param defaultValue The value to use if no parameter is provided.
* @param callback The callback that will be executed once the decorator is applied.
*/
export function makeParameterizedPropertyDecorator<T>(
defaultValue: T,
callback: (
option: T,
target: Object,
propertyName: string | symbol,
propertyDescriptor: PropertyDescriptor | undefined,
) => void,
): ParameterizedPropertyDecorator<T> {
return function decorator(...args: [options: T] | Parameters<PropertyOrGetterDescriptor>) {
if (args.length === 1) {
return function parameterizedDecorator(
target: Object,
propertyName: string | symbol,
propertyDescriptor?: PropertyDescriptor | undefined,
) {
callback(args[0], target, propertyName, propertyDescriptor ?? Object.getOwnPropertyDescriptor(target, propertyName));
};
}

callback(defaultValue, args[0], args[1], args[2] ?? Object.getOwnPropertyDescriptor(args[0], args[1]));

// this method only returns something if args.length === 1, but typescript doesn't understand it
return undefined as unknown as PropertyOrGetterDescriptor;
};
}
17 changes: 17 additions & 0 deletions src/decorators/shared/model.ts
Expand Up @@ -121,6 +121,23 @@ export function registerModelAttributeOptions(
continue;
}

if (optionName === 'unique') {
if (!existingOptions.unique) {
existingOptions.unique = [];
} else if (!Array.isArray(existingOptions.unique)) {
existingOptions.unique = [existingOptions.unique];
}

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];
}

continue;
}

// @ts-expect-error
if (optionValue === existingOptions[optionName]) {
continue;
Expand Down
8 changes: 4 additions & 4 deletions src/model.d.ts
Expand Up @@ -1696,9 +1696,7 @@ export interface ModelAttributeColumnOptions<M extends Model = Model> {
* composite unique index. If multiple columns have the same string, they will be part of the same unique
* index
*/
// TODO: unique should accept an array so multiple uniques can be registered
// https://github.com/sequelize/sequelize/issues/15334
unique?: boolean | string | { name: string, msg: string };
unique?: AllowArray<boolean | string | { name: string, msg?: string }>;

/**
* If true, this attribute will be marked as primary key
Expand Down Expand Up @@ -1764,7 +1762,7 @@ export interface ModelAttributeColumnOptions<M extends Model = Model> {
set?(this: M, val: unknown): void;
}

export interface BuiltModelAttributeColumnOptions<M extends Model = Model> extends Omit<ModelAttributeColumnOptions<M>, 'type'> {
export interface BuiltModelAttributeColumnOptions<M extends Model = Model> extends Omit<ModelAttributeColumnOptions<M>, 'type' | 'unique'> {
/**
* The name of the attribute (JS side).
*/
Expand All @@ -1776,6 +1774,8 @@ export interface BuiltModelAttributeColumnOptions<M extends Model = Model> exten
type: string | AbstractDataType<any>;
references?: ModelAttributeColumnReferencesOptions;

unique?: Array<{ name: string, msg?: string }>;

/**
* This attribute was added by sequelize. Do not use!
*
Expand Down
56 changes: 33 additions & 23 deletions src/model.js
Expand Up @@ -988,7 +988,7 @@ Specify a different name for either index to resolve this issue.`);
}

if (attribute.type === undefined) {
throw new Error(`Unrecognized datatype for attribute "${this.name}.${name}"`);
throw new Error(`Attribute "${this.name}.${name}" does not specify its DataType.`);
}

if (attribute.allowNull !== false && _.get(attribute, 'validate.notNull')) {
Expand Down Expand Up @@ -1172,36 +1172,46 @@ Specify a different name for either index to resolve this issue.`);
}

if (Object.prototype.hasOwnProperty.call(definition, 'unique') && definition.unique) {
if (typeof definition.unique === 'string') {
definition.unique = {
name: definition.unique,
};
} else if (definition.unique === true) {
definition.unique = {};
if (!Array.isArray(definition.unique)) {
definition.unique = [definition.unique];
}

const index = definition.unique.name && this.uniqueKeys[definition.unique.name]
? this.uniqueKeys[definition.unique.name]
: { fields: [] };
for (let i = 0; i < definition.unique.length; i++) {
let unique = definition.unique[i];

index.fields.push(definition.field);
index.msg = index.msg || definition.unique.msg || null;
if (typeof unique === 'string') {
unique = {
name: unique,
};
} else if (unique === true) {
unique = {};
}

// TODO: remove this 'column'? It does not work with composite indexes, and is only used by db2 which should use fields instead.
index.column = name;
definition.unique[i] = unique;

index.customIndex = definition.unique !== true;
index.unique = true;
const index = unique.name && this.uniqueKeys[unique.name]
? this.uniqueKeys[unique.name]
: { fields: [] };

if (definition.unique.name) {
index.name = definition.unique.name;
} else {
this._nameIndex(index);
}
index.fields.push(definition.field);
index.msg = index.msg || unique.msg || null;

// TODO: remove this 'column'? It does not work with composite indexes, and is only used by db2 which should use fields instead.
index.column = name;

definition.unique.name ??= index.name;
index.customIndex = unique !== true;
index.unique = true;

this.uniqueKeys[index.name] = index;
if (unique.name) {
index.name = unique.name;
} else {
this._nameIndex(index);
}

unique.name ??= index.name;

this.uniqueKeys[index.name] = index;
}
}

if (Object.prototype.hasOwnProperty.call(definition, 'validate')) {
Expand Down
3 changes: 1 addition & 2 deletions src/utils/format.ts
Expand Up @@ -5,7 +5,6 @@ import type {
Attributes,
BuiltModelAttributeColumnOptions,
Model,
ModelAttributeColumnOptions,
ModelStatic,
WhereOptions,
} from '..';
Expand Down Expand Up @@ -103,7 +102,7 @@ export function mapWhereFieldNames(where: Record<PropertyKey, any>, Model: Model
const newWhere: Record<PropertyKey, any> = Object.create(null);
// TODO: note on 'as any[]'; removing the cast causes the following error on attributeNameOrOperator "TS2538: Type 'symbol' cannot be used as an index type."
for (const attributeNameOrOperator of getComplexKeys(where) as any[]) {
const rawAttribute: ModelAttributeColumnOptions | undefined = Model.rawAttributes[attributeNameOrOperator];
const rawAttribute: BuiltModelAttributeColumnOptions | undefined = Model.rawAttributes[attributeNameOrOperator];

const columnNameOrOperator: PropertyKey = rawAttribute?.field ?? attributeNameOrOperator;

Expand Down
36 changes: 0 additions & 36 deletions test/integration/associations/belongs-to-many.test.js
Expand Up @@ -3320,42 +3320,6 @@ describe(Support.getTestDialectTeaser('BelongsToMany'), () => {
expect(ut1).to.have.length(1);
expect(ut2).to.have.length(1);
});

it('create custom unique identifier', async function () {
const UserTasksLong = this.sequelize.define('table_user_task_with_very_long_name', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
id_user_very_long_field: {
type: DataTypes.INTEGER(1),
},
id_task_very_long_field: {
type: DataTypes.INTEGER(1),
},
}, {
tableName: 'table_user_task_with_very_long_name',
});

this.User.belongsToMany(this.Task, {
as: 'MyTasks',
through: {
model: UserTasksLong,
unique: 'custom_user_group_unique',
},
foreignKey: 'id_user_very_long_field',
otherKey: 'id_task_very_long_field',
inverse: {
as: 'MyUsers',
},
});

await this.sequelize.sync({ force: true });

expect(UserTasksLong.rawAttributes.id_user_very_long_field.unique).to.deep.equal({ name: 'custom_user_group_unique' });
expect(UserTasksLong.rawAttributes.id_task_very_long_field.unique).to.deep.equal({ name: 'custom_user_group_unique' });
});
});

describe('Association options', () => {
Expand Down
14 changes: 0 additions & 14 deletions test/integration/model/create.test.js
Expand Up @@ -1175,20 +1175,6 @@ describe(Support.getTestDialectTeaser('Model'), () => {
}
});

it('raises an error if you mess up the datatype', function () {
expect(() => {
this.sequelize.define('UserBadDataType', {
activity_date: DataTypes.DATe,
});
}).to.throw(Error, 'Unrecognized datatype for attribute "UserBadDataType.activity_date"');

expect(() => {
this.sequelize.define('UserBadDataType', {
activity_date: { type: DataTypes.DATe },
});
}).to.throw(Error, 'Unrecognized datatype for attribute "UserBadDataType.activity_date"');
});

it('sets a 64 bit int in bigint', async function () {
const User = this.sequelize.define('UserWithBigIntFields', {
big: DataTypes.BIGINT,
Expand Down
8 changes: 4 additions & 4 deletions test/unit/associations/belongs-to-many.test.ts
Expand Up @@ -968,8 +968,8 @@ describe(getTestDialectTeaser('belongsToMany'), () => {
expect(Through === MyGroups.through.model);

expect(Object.keys(Through.rawAttributes).sort()).to.deep.equal(['id', 'createdAt', 'updatedAt', 'id_user_very_long_field', 'id_group_very_long_field'].sort());
expect(Through.rawAttributes.id_user_very_long_field.unique).to.deep.equal({ name: 'table_user_group_with_very_long_name_id_group_very_long_field_id_user_very_long_field_unique' });
expect(Through.rawAttributes.id_group_very_long_field.unique).to.deep.equal({ name: 'table_user_group_with_very_long_name_id_group_very_long_field_id_user_very_long_field_unique' });
expect(Through.rawAttributes.id_user_very_long_field.unique).to.deep.equal([{ name: 'table_user_group_with_very_long_name_id_group_very_long_field_id_user_very_long_field_unique' }]);
expect(Through.rawAttributes.id_group_very_long_field.unique).to.deep.equal([{ name: 'table_user_group_with_very_long_name_id_group_very_long_field_id_user_very_long_field_unique' }]);
});

it('generates unique identifier with custom name', () => {
Expand Down Expand Up @@ -1010,8 +1010,8 @@ describe(getTestDialectTeaser('belongsToMany'), () => {

expect(MyUsers.through.model === UserGroup);
expect(MyGroups.through.model === UserGroup);
expect(UserGroup.rawAttributes.id_user_very_long_field.unique).to.deep.equal({ name: 'custom_user_group_unique' });
expect(UserGroup.rawAttributes.id_group_very_long_field.unique).to.deep.equal({ name: 'custom_user_group_unique' });
expect(UserGroup.rawAttributes.id_user_very_long_field.unique).to.deep.equal([{ name: 'custom_user_group_unique' }]);
expect(UserGroup.rawAttributes.id_group_very_long_field.unique).to.deep.equal([{ name: 'custom_user_group_unique' }]);
});
});

Expand Down

0 comments on commit 43bca57

Please sign in to comment.