Skip to content

Commit

Permalink
feat: add built-in support for TypeScript enums & readonly arrays in …
Browse files Browse the repository at this point in the history
…`DataTypes.ENUM` (#16488)
  • Loading branch information
ephys committed Sep 12, 2023
1 parent b8edd52 commit d2a1c2f
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 21 deletions.
82 changes: 62 additions & 20 deletions packages/core/src/dialects/abstract/data-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { isPlainObject, isString } from '../../utils/check.js';
import { isValidTimeZone } from '../../utils/dayjs.js';
import { doNotUseRealDataType } from '../../utils/deprecations.js';
import { joinSQLFragments } from '../../utils/join-sql-fragments';
import { EMPTY_ARRAY } from '../../utils/object.js';
import { parseBigInt, parseNumber } from '../../utils/parse-number.js';
import { validator as Validator } from '../../utils/validator-extras';
import type { HstoreRecord } from '../postgres/hstore.js';
Expand Down Expand Up @@ -2120,8 +2121,22 @@ export class VIRTUAL<T> extends AbstractDataType<T> {
}
}

/**
* If an array, each element in the array is a possible value for the ENUM.
*
* If a record (plain object, typescript enum),
* it will use the keys as the list of possible values for the ENUM, in the order specified by the Object.
* This is designed to be used with TypeScript enums, but it can be used with plain objects as well.
* Because we don't handle any mapping between the enum keys and values, we require that they be the same.
*/
type EnumValues<Member extends string> = readonly Member[] | Record<Member, Member>;

export interface EnumOptions<Member extends string> {
values: Member[];
values: EnumValues<Member>;
}

export interface NormalizedEnumOptions<Member extends string> {
values: readonly Member[];
}

/**
Expand All @@ -2144,40 +2159,26 @@ export interface EnumOptions<Member extends string> {
export class ENUM<Member extends string> extends AbstractDataType<Member> {
/** @hidden */
static readonly [kDataTypeIdentifier]: string = 'ENUM';
readonly options: EnumOptions<Member>;
readonly options: NormalizedEnumOptions<Member>;

/**
* @param options either array of values or options object with values array. It also supports variadic values.
*/
constructor(options: EnumOptions<Member>);
constructor(members: Member[]);
constructor(members: EnumValues<Member>);
constructor(...members: Member[]);
// we have to define the constructor overloads using tuples due to a TypeScript limitation
// https://github.com/microsoft/TypeScript/issues/29732, to play nice with classToInvokable.
/** @hidden */
constructor(...args:
| [options: EnumOptions<Member>]
| [members: Member[]]
| [members: EnumValues<Member>]
| [...members: Member[]]
);
constructor(...args: [Member[] | Member | EnumOptions<Member>, ...Member[]]) {
constructor(...args: [EnumValues<Member> | Member | EnumOptions<Member>, ...Member[]]) {
super();

let values: Member[];
if (isObject(args[0])) {
if (args.length > 1) {
throw new TypeError('DataTypes.ENUM has been constructed incorrectly: Its first parameter is the option bag or the array of values, but more than one parameter has been provided.');
}

if (Array.isArray(args[0])) {
values = args[0];
} else {
values = args[0].values;
}
} else {
// @ts-expect-error -- we'll assert in the next line whether this is the right type
values = args;
}
const values: readonly Member[] = this.#getEnumValues(args);

if (values.length === 0) {
throw new TypeError(`
Expand Down Expand Up @@ -2213,6 +2214,47 @@ sequelize.define('MyModel', {
};
}

#getEnumValues(args: [EnumValues<Member> | Member | EnumOptions<Member>, ...Member[]]): readonly Member[] {
if (args.length === 0) {
return EMPTY_ARRAY;
}

const [first, ...rest] = args;

if (isString(first)) {
return [first, ...rest];
}

if (rest.length > 0) {
throw new TypeError('DataTypes.ENUM has been constructed incorrectly: Its first parameter is the option bag or the array of values, but more than one parameter has been provided.');
}

let enumOrArray: EnumValues<Member>;
if (!Array.isArray(first) && 'values' in first && typeof first.values !== 'string') {
// This is the option bag
// @ts-expect-error -- Array.isArray does not narrow correctly when the array is readonly
enumOrArray = first.values;
} else {
// @ts-expect-error -- Array.isArray does not narrow correctly when the array is readonly
enumOrArray = first;
}

if (Array.isArray(enumOrArray)) {
return [...enumOrArray];
}

// @ts-expect-error -- Array.isArray does not narrow correctly when the array is readonly
const theEnum: Record<Member, Member> = enumOrArray;
const enumKeys = Object.keys(theEnum) as Member[];
for (const enumKey of enumKeys) {
if (theEnum[enumKey] !== enumKey) {
throw new TypeError(`DataTypes.ENUM has been constructed incorrectly: When specifying values as a TypeScript enum or an object of key-values, the values of the object must be equal to their keys.`);
}
}

return enumKeys;
}

validate(value: any): asserts value is Member {
if (!this.options.values.includes(value)) {
ValidationErrorItem.throwDataTypeValidationError(
Expand Down
44 changes: 43 additions & 1 deletion packages/core/test/unit/data-types/misc-data-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import assert from 'node:assert';
import { expect } from 'chai';
import type { DataTypeInstance } from '@sequelize/core';
import { DataTypes, ValidationErrorItem } from '@sequelize/core';
import { expectsql, sequelize } from '../../support';
import type { ENUM } from '@sequelize/core/_non-semver-use-at-your-own-risk_/dialects/abstract/data-types.js';
import { expectsql, sequelize, typeTest } from '../../support';
import { testDataTypeSql } from './_utils';

const { queryGenerator, dialect } = sequelize;
Expand Down Expand Up @@ -59,6 +60,47 @@ describe('DataTypes.ENUM', () => {
});
});

it('supports TypeScript enums', () => {
enum Test {
A = 'A',
B = 'B',
C = 'C',
}

const User = sequelize.define('User', {
enum1: DataTypes.ENUM({ values: Test }),
enum2: DataTypes.ENUM(Test),
});

const attributes = User.getAttributes();

const enum1: ENUM<any> = attributes.enum1.type as ENUM<any>;
expect(enum1.options.values).to.deep.eq(['A', 'B', 'C']);

const enum2: ENUM<any> = attributes.enum2.type as ENUM<any>;
expect(enum2.options.values).to.deep.eq(['A', 'B', 'C']);
});

it('throws if the TS enum values are not equal to their keys', () => {
enum Test {
A = 'a',
}

expect(() => {
sequelize.define('User', {
anEnum: DataTypes.ENUM({ values: Test }),
});
}).to.throwWithCause(Error, 'DataTypes.ENUM has been constructed incorrectly: When specifying values as a TypeScript enum or an object of key-values, the values of the object must be equal to their keys.');
});

typeTest('accepts readonly arrays', () => {
const values: readonly string[] = ['value 1', 'value 2'];

sequelize.define('User', {
anEnum: DataTypes.ENUM(values),
});
});

it('raises an error if the legacy "values" property is specified', () => {
expect(() => {
sequelize.define('omnomnom', {
Expand Down

0 comments on commit d2a1c2f

Please sign in to comment.