Skip to content

Commit

Permalink
feat(core): add support for enums via @Enum() decorator (#232)
Browse files Browse the repository at this point in the history
Closes #215
  • Loading branch information
B4nan committed Nov 10, 2019
1 parent 9b4f8b6 commit 82ca105
Show file tree
Hide file tree
Showing 22 changed files with 219 additions and 59 deletions.
2 changes: 1 addition & 1 deletion ROADMAP.md
Expand Up @@ -13,7 +13,7 @@ discuss specifics.
- Value transformers (e.g. mapping of `Date` object to formatted string)
- Schema sync (allow automatic synchronization during development)
- Migrations via `umzug`
- Improved support for data types like date, time, enum, timestamp
- Improved support for data types like date, time, timestamp
- Support for RegExp search in SQL drivers
- Collection expressions - support querying parts of collection
- Collection pagination
Expand Down
36 changes: 35 additions & 1 deletion docs/defining-entities.md
Expand Up @@ -124,6 +124,40 @@ as nullable property (mainly for SQL schema generator).

> This auto-detection works only when you omit the `type` attribute.
### Enums

To define enum property, use `@Enum()` decorator. Enums can be either numeric or string valued.

For schema generator to work properly in case of string enums, you need to define the enum
is same file as where it is used, so its values can be automatically discovered. If you want
to define the enum in another file, you should reexport it also in place where you use it.

> You can also set enum items manually via `items: string[]` attribute.
```typescript
@Entity()
export class User implements IdEntity<User> {

@Enum()
role: UserRole; // string enum

@Enum()
status: UserStatus; // numeric enum

}

export enum UserRole {
ADMIN = 'admin',
MODERATOR = 'moderator',
USER = 'user',
}

export const enum UserStatus {
DISABLED,
ACTIVE,
}
```

## Virtual Properties

You can define your properties as virtual, either as a method, or via JavaScript `get/set`.
Expand All @@ -137,7 +171,7 @@ are both hidden from the serialized response, replaced with virtual properties `
```typescript
@Entity()
export class User {
export class User implements IdEntity<User> {

@Property({ hidden: true })
firstName: string;
Expand Down
5 changes: 5 additions & 0 deletions docs/entity-generator.md
Expand Up @@ -40,4 +40,9 @@ Then run this script via `ts-node` (or compile it to plain JS and use `node`):
$ ts-node generate-entities
```

## Current limitations

- many to many relations are not supported, pivot table will be represented as separate entity
- in mysql, tinyint columns will be defined as boolean properties

[&larr; Back to table of contents](index.md#table-of-contents)
4 changes: 2 additions & 2 deletions lib/connections/AbstractSqlConnection.ts
Expand Up @@ -47,7 +47,7 @@ export abstract class AbstractSqlConnection extends Connection {
}

const sql = this.getSql(this.client.raw(queryOrKnex, params));
const res = await this.executeQuery<any>(sql, () => this.client.raw(queryOrKnex, params));
const res = await this.executeQuery<any>(sql, () => this.client.raw(queryOrKnex, params) as unknown as Promise<QueryResult>);
return this.transformRawResult<T>(res, method);
}

Expand Down Expand Up @@ -79,7 +79,7 @@ export abstract class AbstractSqlConnection extends Connection {

protected async executeKnex(qb: QueryBuilder | Raw, method: 'all' | 'get' | 'run'): Promise<QueryResult | any | any[]> {
const sql = this.getSql(qb);
const res = await this.executeQuery(sql, () => qb);
const res = await this.executeQuery(sql, () => qb as unknown as Promise<QueryResult>);

return this.transformKnexResult(res, method);
}
Expand Down
18 changes: 18 additions & 0 deletions lib/decorators/Enum.ts
@@ -0,0 +1,18 @@
import { MetadataStorage } from '../metadata';
import { ReferenceType } from '../entity';
import { PropertyOptions } from '.';
import { EntityProperty, AnyEntity } from '../types';
import { Utils } from '../utils';

export function Enum(options: EnumOptions = {}): Function {
return function (target: AnyEntity, propertyName: string) {
const meta = MetadataStorage.getMetadata(target.constructor.name);
options.name = propertyName;
meta.properties[propertyName] = Object.assign({ reference: ReferenceType.SCALAR, enum: true }, options) as EntityProperty;
Utils.lookupPathFromDecorator(meta);
};
}

export interface EnumOptions extends PropertyOptions {
items?: (number | string)[];
}
1 change: 1 addition & 0 deletions lib/decorators/index.ts
Expand Up @@ -6,5 +6,6 @@ export * from './ManyToOne';
export * from './ManyToMany';
export { OneToMany, OneToManyOptions } from './OneToMany';
export * from './Property';
export * from './Enum';
export * from './Repository';
export * from './hooks';
34 changes: 25 additions & 9 deletions lib/metadata/MetadataDiscovery.ts
Expand Up @@ -3,7 +3,7 @@ import globby from 'globby';
import chalk from 'chalk';

import { EntityClass, EntityClassGroup, EntityMetadata, EntityProperty, AnyEntity } from '../types';
import { Configuration, Logger, Utils, ValidationError } from '../utils';
import { Configuration, Utils, ValidationError } from '../utils';
import { MetadataValidator } from './MetadataValidator';
import { MetadataStorage } from './MetadataStorage';
import { Cascade, ReferenceType } from '../entity';
Expand Down Expand Up @@ -81,7 +81,8 @@ export class MetadataDiscovery {

const name = this.namingStrategy.getClassName(file);
const path = Utils.normalizePath(this.config.get('baseDir'), basePath, file);
const target = this.getEntityPrototype(path, name);
const target = this.getPrototype(path, name);
this.metadata.set(name, MetadataStorage.getMetadata(name));
await this.discoverEntity(target, path);
}
}
Expand Down Expand Up @@ -231,7 +232,7 @@ export class MetadataDiscovery {
Object.values(meta.properties).forEach(prop => {
this.applyNamingStrategy(meta, prop);
this.initVersionProperty(meta, prop);
this.initColumnType(prop);
this.initColumnType(prop, meta.path);
});
meta.serializedPrimaryKey = this.platform.getSerializedPrimaryKeyField(meta.primaryKey);
const ret: EntityMetadata[] = [];
Expand Down Expand Up @@ -376,11 +377,15 @@ export class MetadataDiscovery {
prop.default = this.getDefaultVersionValue(prop);
}

private initColumnType(prop: EntityProperty): void {
private initColumnType(prop: EntityProperty, path?: string): void {
if (prop.columnType || !this.schemaHelper) {
return;
}

if (prop.enum && prop.type && path) {
return this.initEnumValues(prop, path);
}

if (prop.reference === ReferenceType.SCALAR) {
prop.columnType = this.schemaHelper.getTypeDefinition(prop);
return;
Expand All @@ -390,6 +395,19 @@ export class MetadataDiscovery {
prop.columnType = this.schemaHelper.getTypeDefinition(meta.properties[meta.primaryKey]);
}

private initEnumValues(prop: EntityProperty, path: string): void {
path = Utils.normalizePath(this.config.get('baseDir'), path);
const target = this.getPrototype(path, prop.type, false);

if (target) {
const keys = Object.keys(target);
const items = Object.values<string>(target).filter(val => !keys.includes(val));
Utils.defaultValue(prop, 'items', items);
}

prop.columnType = this.schemaHelper!.getTypeDefinition(prop);
}

private initUnsigned(prop: EntityProperty): void {
if (prop.reference === ReferenceType.MANY_TO_ONE || prop.reference === ReferenceType.ONE_TO_ONE) {
const meta2 = this.metadata.get(prop.type);
Expand All @@ -404,15 +422,13 @@ export class MetadataDiscovery {
prop.unsigned = (prop.primary || prop.unsigned) && prop.type === 'number';
}

private getEntityPrototype(path: string, name: string) {
const target = require(path)[name]; // include the file to trigger loading of metadata
private getPrototype(path: string, name: string, validate = true) {
const target = require(path)[name];

if (!target) {
if (!target && validate) {
throw ValidationError.entityNotFound(name, path.replace(this.config.get('baseDir'), '.'));
}

this.metadata.set(name, MetadataStorage.getMetadata(name));

return target;
}

Expand Down
4 changes: 2 additions & 2 deletions lib/schema/EntityGenerator.ts
Expand Up @@ -127,7 +127,7 @@ export class EntityGenerator {
options.fieldName = `'${column.name}'`;
}

if (column.maxLength) {
if (column.maxLength && column.type !== 'enum') {
options.length = column.maxLength;
}
}
Expand Down Expand Up @@ -189,7 +189,7 @@ export class EntityGenerator {
return field.replace(/_(\w)/g, m => m[1].toUpperCase()).replace(/_+/g, '');
}

private getPropertyType(column: Column, defaultType: string = 'string'): string {
private getPropertyType(column: Column, defaultType = 'string'): string {
if (column.fk) {
return this.namingStrategy.getClassName(column.fk.referencedTableName, '_');
}
Expand Down
10 changes: 7 additions & 3 deletions lib/schema/MySqlSchemaHelper.ts
Expand Up @@ -7,16 +7,20 @@ import { Column } from './DatabaseTable';
export class MySqlSchemaHelper extends SchemaHelper {

static readonly TYPES = {
number: ['int(?)', 'int', 'float', 'double'],
boolean: ['tinyint(1)', 'tinyint'],
number: ['int(?)', 'int', 'float', 'double', 'tinyint', 'smallint', 'bigint'],
float: ['float'],
double: ['double'],
string: ['varchar(?)', 'varchar', 'text'],
tinyint: ['tinyint'],
smallint: ['smallint'],
bigint: ['bigint'],
string: ['varchar(?)', 'varchar', 'text', 'enum'],
Date: ['datetime(?)', 'timestamp(?)', 'datetime', 'timestamp'],
date: ['datetime(?)', 'timestamp(?)', 'datetime', 'timestamp'],
boolean: ['tinyint(1)', 'tinyint'],
text: ['text'],
object: ['json'],
json: ['json'],
enum: ['enum'],
};

static readonly DEFAULT_TYPE_LENGTHS = {
Expand Down
10 changes: 7 additions & 3 deletions lib/schema/PostgreSqlSchemaHelper.ts
Expand Up @@ -6,17 +6,21 @@ import { Column } from './DatabaseTable';
export class PostgreSqlSchemaHelper extends SchemaHelper {

static readonly TYPES = {
number: ['int4', 'integer', 'int8', 'int', 'float', 'float8', 'double', 'double precision', 'bigint', 'smallint', 'decimal', 'numeric', 'real'],
boolean: ['bool', 'boolean'],
number: ['int4', 'integer', 'int8', 'int2', 'int', 'float', 'float8', 'double', 'double precision', 'bigint', 'smallint', 'decimal', 'numeric', 'real'],
float: ['float'],
double: ['double', 'double precision', 'float8'],
string: ['varchar(?)', 'character varying', 'text', 'character', 'char', 'uuid'],
tinyint: ['int2'],
smallint: ['int2'],
bigint: ['bigint'],
string: ['varchar(?)', 'character varying', 'text', 'character', 'char', 'uuid', 'enum'],
Date: ['timestamptz(?)', 'timestamp(?)', 'datetime(?)', 'timestamp with time zone', 'timestamp without time zone', 'datetimetz', 'time', 'date', 'timetz', 'datetz'],
date: ['timestamptz(?)', 'timestamp(?)', 'datetime(?)', 'timestamp with time zone', 'timestamp without time zone', 'datetimetz', 'time', 'date', 'timetz', 'datetz'],
boolean: ['bool', 'boolean'],
text: ['text'],
object: ['json'],
json: ['json'],
uuid: ['uuid'],
enum: ['enum'],
};

static readonly DEFAULT_TYPE_LENGTHS = {
Expand Down
11 changes: 8 additions & 3 deletions lib/schema/SchemaGenerator.ts
Expand Up @@ -222,10 +222,13 @@ export class SchemaGenerator {
return table.increments(prop.fieldName);
}

const col = table.specificType(prop.fieldName, prop.columnType);
this.configureColumn(meta, prop, col, alter);
if (prop.enum && prop.items && prop.items.every(item => Utils.isString(item))) {
const col = table.enum(prop.fieldName, prop.items!);
return this.configureColumn(meta, prop, col, alter);
}

return col;
const col = table.specificType(prop.fieldName, prop.columnType);
return this.configureColumn(meta, prop, col, alter);
}

private updateTableColumn(table: TableBuilder, meta: EntityMetadata, prop: EntityProperty, column: Column, diff: IsSame): void {
Expand Down Expand Up @@ -263,6 +266,8 @@ export class SchemaGenerator {
Utils.runIfNotEmpty(() => col.unsigned(), prop.unsigned);
Utils.runIfNotEmpty(() => col.index(), indexed);
Utils.runIfNotEmpty(() => col.defaultTo(this.knex.raw('' + prop.default)), hasDefault);

return col;
}

private createForeignKeys(table: TableBuilder, meta: EntityMetadata): void {
Expand Down
8 changes: 7 additions & 1 deletion lib/schema/SchemaHelper.ts
Expand Up @@ -3,6 +3,7 @@ import { Dictionary, EntityProperty } from '../types';
import { AbstractSqlConnection } from '../connections/AbstractSqlConnection';
import { Column, Index } from './DatabaseTable';
import { ReferenceType } from '../entity';
import { Utils } from '../utils';

export abstract class SchemaHelper {

Expand All @@ -19,7 +20,12 @@ export abstract class SchemaHelper {
}

getTypeDefinition(prop: EntityProperty, types: Record<string, string[]> = {}, lengths: Record<string, number> = {}, allowZero = false): string {
const t = prop.type.toLowerCase();
let t = prop.type.toLowerCase();

if (prop.enum) {
t = prop.items && prop.items.every(item => Utils.isString(item)) ? 'enum' : 'tinyint';
}

let type = (types[t] || types.json || types.text || [t])[0];

if (type.includes('(?)')) {
Expand Down
5 changes: 4 additions & 1 deletion lib/schema/SqliteSchemaHelper.ts
Expand Up @@ -6,7 +6,10 @@ import { Column } from './DatabaseTable';
export class SqliteSchemaHelper extends SchemaHelper {

static readonly TYPES = {
number: ['integer', 'int', 'bigint'],
number: ['integer', 'int', 'tinyint', 'smallint', 'bigint'],
tinyint: ['integer'],
smallint: ['integer'],
bigint: ['integer'],
boolean: ['integer', 'int'],
string: ['varchar', 'text'],
Date: ['datetime', 'text'],
Expand Down
2 changes: 2 additions & 0 deletions lib/types.ts
Expand Up @@ -110,6 +110,8 @@ export interface EntityProperty<T extends AnyEntity<T> = any> {
unsigned: boolean;
persist?: boolean;
hidden?: boolean;
enum?: boolean;
items?: (number | string)[];
version?: boolean;
eager?: boolean;
setter?: boolean;
Expand Down
4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -98,8 +98,8 @@
"fast-deep-equal": "^2.0.0",
"fs-extra": "^8.0.0",
"globby": "^10.0.0",
"knex": "^0.19.5",
"ts-morph": "^4.0.0",
"knex": "^0.20.1",
"ts-morph": "^5.0.0",
"typescript": "^3.6.4",
"umzug": "^2.2.0",
"uuid": "^3.3.2",
Expand Down
1 change: 1 addition & 0 deletions tests/SchemaGenerator.test.ts
Expand Up @@ -214,6 +214,7 @@ describe('SchemaGenerator', () => {
meta.set('NewTable', newTableMeta);
const authorMeta = meta.get('Author2');
authorMeta.properties.termsAccepted.default = false;
const now = Date.now();
await expect(generator.getUpdateSchemaSQL(false)).resolves.toMatchSnapshot('postgres-update-schema-create-table');
await generator.updateSchema();

Expand Down
28 changes: 26 additions & 2 deletions tests/__snapshots__/EntityGenerator.test.ts.snap
Expand Up @@ -178,9 +178,21 @@ export class Publisher2 {
@Property({ length: 255 })
name: string;
@Property({ length: 10 })
@Property({ type: 'enum' })
type: string;
@Property({ nullable: true })
enum1?: boolean;
@Property({ nullable: true })
enum2?: boolean;
@Property({ nullable: true })
enum3?: boolean;
@Property({ type: 'enum', nullable: true })
enum4?: string;
}
",
"import { Cascade, Entity, ManyToOne, PrimaryKey } from 'mikro-orm';
Expand Down Expand Up @@ -422,9 +434,21 @@ export class Publisher2 {
@Property({ length: 255 })
name: string;
@Property({ length: 10 })
@Property({ type: 'text' })
type: string;
@Property({ type: 'int2', nullable: true })
enum1?: number;
@Property({ type: 'int2', nullable: true })
enum2?: number;
@Property({ type: 'int2', nullable: true })
enum3?: number;
@Property({ type: 'text', nullable: true })
enum4?: string;
}
",
"import { Cascade, Entity, ManyToOne, PrimaryKey } from 'mikro-orm';
Expand Down

0 comments on commit 82ca105

Please sign in to comment.