Skip to content

Commit

Permalink
feat(schema): add basic entity generator
Browse files Browse the repository at this point in the history
New `EntityGenerator` class is present that allows to reverse engineer current database schema and build entity classes based on it. It supports only basic scalar and M:1 properties currently.

Closes #78
  • Loading branch information
B4nan committed Aug 15, 2019
1 parent ccee841 commit 29ebe62
Show file tree
Hide file tree
Showing 20 changed files with 1,151 additions and 76 deletions.
5 changes: 5 additions & 0 deletions lib/MikroORM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AbstractSqlDriver, IDatabaseDriver } from './drivers';
import { MetadataDiscovery, MetadataStorage } from './metadata';
import { Configuration, Logger, Options } from './utils';
import { SchemaGenerator } from './schema';
import { EntityGenerator } from './schema/EntityGenerator';

export class MikroORM {

Expand Down Expand Up @@ -67,4 +68,8 @@ export class MikroORM {
return new SchemaGenerator(this.driver as AbstractSqlDriver, this.metadata);
}

getEntityGenerator(): EntityGenerator {
return new EntityGenerator(this.driver as AbstractSqlDriver, this.config);
}

}
2 changes: 1 addition & 1 deletion lib/entity/EntityValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export class EntityValidator {
}

private fixTypes(expectedType: string, givenType: string, givenValue: any): any {
if (expectedType === 'date' && givenType === 'string') {
if (expectedType === 'date' && ['string', 'number'].includes(givenType)) {
givenValue = this.fixDateType(givenValue);
}

Expand Down
17 changes: 11 additions & 6 deletions lib/metadata/MetadataDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,8 @@ export class MetadataDiscovery {
this.validator.validateEntityDefinition(this.metadata, meta.name);
Object.values(meta.properties).forEach(prop => {
this.applyNamingStrategy(meta, prop);
this.initVersionProperty(meta, prop);
this.initColumnType(prop);

if (prop.version) {
meta.versionProperty = prop.name;
prop.default = this.getDefaultVersionValue(prop);
}
});
meta.serializedPrimaryKey = this.platform.getSerializedPrimaryKeyField(meta.primaryKey);
const ret: EntityMetadata[] = [];
Expand Down Expand Up @@ -298,13 +294,22 @@ export class MetadataDiscovery {
}

if (prop.type.toLowerCase() === 'date') {
prop.length = prop.length || 3;
prop.length = typeof prop.length === 'undefined' ? 3 : prop.length;
return this.platform.getCurrentTimestampSQL(prop.length);
}

return 1;
}

private initVersionProperty(meta: EntityMetadata, prop: EntityProperty): void {
if (!prop.version) {
return;
}

meta.versionProperty = prop.name;
prop.default = this.getDefaultVersionValue(prop);
}

private initColumnType(prop: EntityProperty): void {
if (prop.columnType || !this.schemaHelper) {
return;
Expand Down
4 changes: 2 additions & 2 deletions lib/naming-strategy/AbstractNamingStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { NamingStrategy } from './NamingStrategy';

export abstract class AbstractNamingStrategy implements NamingStrategy {

getClassName(file: string): string {
getClassName(file: string, separator = '-'): string {
const name = file.split('.')[0];
const ret = name.replace(/-(\w)/, m => m[1].toUpperCase());
const ret = name.replace(new RegExp(`${separator}(\\w)`, 'g'), m => m[1].toUpperCase());

return ret.charAt(0).toUpperCase() + ret.slice(1);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/naming-strategy/NamingStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export interface NamingStrategy {
/**
* Return a name of the class based on its file name
*/
getClassName(file: string): string;
getClassName(file: string, separator?: string): string;

/**
* Return a table name for an entity class
Expand Down
5 changes: 5 additions & 0 deletions lib/platforms/PostgreSqlPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,9 @@ export class PostgreSqlPlatform extends Platform {
return true;
}

getCurrentTimestampSQL(length: number): string {
const lengthString = typeof length === 'undefined' ? false : '' + length;
return 'current_timestamp' + (lengthString ? `(${lengthString})` : '');
}

}
209 changes: 209 additions & 0 deletions lib/schema/EntityGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { CodeBlockWriter, Project, QuoteKind, SourceFile } from 'ts-morph';

import { AbstractSqlDriver, Configuration, TableDefinition } from '..';
import { Platform } from '../platforms';
import { EntityProperty } from '../decorators';

export class EntityGenerator {

private readonly platform: Platform = this.driver.getPlatform();
private readonly helper = this.platform.getSchemaHelper()!;
private readonly connection = this.driver.getConnection();
private readonly namingStrategy = this.config.getNamingStrategy();
private readonly project = new Project();
private readonly sources: SourceFile[] = [];

constructor(private readonly driver: AbstractSqlDriver,
private readonly config: Configuration) {
this.project.manipulationSettings.set({ quoteKind: QuoteKind.Single });
}

async generate(): Promise<string[]> {
const tables = await this.connection.execute<TableDefinition[]>(this.helper.getListTablesSQL());

for (const table of tables) {
await this.createEntity(table.table_name, table.schema_name);
}

this.sources.forEach(entity => {
entity.fixMissingImports();
entity.fixUnusedIdentifiers();
entity.organizeImports();
});

return this.sources.map(e => e.getFullText());
}

async createEntity(tableName: string, schemaName?: string): Promise<void> {
const cols = await this.helper.getColumns(this.connection, tableName, schemaName);
const indexes = await this.helper.getIndexes(this.connection, tableName, schemaName);
const pks = await this.helper.getPrimaryKeys(this.connection, indexes, tableName, schemaName);
const fks = await this.getForeignKeys(tableName, schemaName);
const entity = this.project.createSourceFile(this.namingStrategy.getClassName(tableName, '_') + '.ts', writer => {
writer.writeLine(`import { PrimaryKey, Property, ManyToOne, OneToMany, OneToOne, ManyToMany, Cascade } from 'mikro-orm';`);
writer.blankLine();
writer.write(`export class ${this.namingStrategy.getClassName(tableName, '_')}`);
writer.block(() => cols.forEach(def => this.createProperty(writer, def, pks, indexes[def.name], fks[def.name])));
writer.blankLineIfLastNot();
});

this.sources.push(entity);
}

private async getForeignKeys(tableName: string, schemaName?: string): Promise<Record<string, any>> {
const fks = await this.connection.execute<any[]>(this.helper.getForeignKeysSQL(tableName, schemaName));
return this.helper.mapForeignKeys(fks);
}

private createProperty(writer: CodeBlockWriter, def: any, pks: string[], index: any[], fk: any): void {
const prop = this.getPropertyName(def.name, fk);
const type = this.getPropertyType(def.type, fk);
const defaultValue = this.getPropertyDefaultValue(def, type);
const decorator = this.getPropertyDecorator(prop, def, type, defaultValue, pks, index, fk);
const definition = this.getPropertyDefinition(prop, type, defaultValue);

writer.blankLineIfLastNot();
writer.writeLine(decorator);
writer.writeLine(definition);
writer.blankLine();
}

private getPropertyDefinition(prop: string, type: string, defaultValue: any): string {
// string defaults are usually things like SQL functions
if (!defaultValue || typeof defaultValue === 'string') {
return `${prop}: ${type};`;
}

return `${prop}: ${type} = ${defaultValue};`;
}

private getPropertyDecorator(prop: string, def: Record<string, any>, type: string, defaultValue: any, pks: string[], index: any[], fk: any): string {
const options = {} as any;
const decorator = this.getDecoratorType(def, pks, index, fk);

if (fk) {
this.getForeignKeyDecoratorOptions(options, fk, def, prop);
} else {
this.getScalarPropertyDecoratorOptions(type, def, options, prop);
}

this.getCommonDecoratorOptions(def, options, defaultValue);

if (Object.keys(options).length === 0) {
return decorator + '()';
}

return `${decorator}({ ${Object.entries(options).map(([opt, val]) => `${opt}: ${val}`).join(', ')} })`;
}

private getCommonDecoratorOptions(def: any, options: Record<string, any>, defaultValue: any) {
if (def.nullable) {
options.nullable = true;
}

if (defaultValue && typeof defaultValue === 'string') {
options.default = `'${defaultValue.replace(/'/g, '\\\'')}'`;
}
}

private getScalarPropertyDecoratorOptions(type: string, def: any, options: Record<string, any>, prop: string) {
const defaultColumnType = this.helper.getTypeDefinition({
type,
length: def.maxLength,
} as EntityProperty).replace(/\(\d+\)/, '');

if (def.type !== defaultColumnType) {
options.type = `'${def.type}'`;
}

if (def.name !== this.namingStrategy.propertyToColumnName(prop)) {
options.fieldName = `'${def.name}'`;
}

if (def.maxLength) {
options.length = def.maxLength;
}
}

private getForeignKeyDecoratorOptions(options: Record<string, any>, fk: any, def: any, prop: string) {
options.entity = `() => ${this.namingStrategy.getClassName(fk.referencedTableName, '_')}`;

if (def.name !== this.namingStrategy.joinKeyColumnName(prop, fk.referencedColumnName)) {
options.fieldName = `'${def.name}'`;
}

const cascade = ['Cascade.MERGE'];

if (fk.updateRule.toLowerCase() === 'cascade') {
cascade.push('Cascade.PERSIST');
}

if (fk.deleteRule.toLowerCase() === 'cascade') {
cascade.push('Cascade.REMOVE');
}

if (cascade.length === 3) {
cascade.length = 0;
cascade.push('Cascade.ALL');
}

if (!(cascade.length === 2 && cascade.includes('Cascade.PERSIST') && cascade.includes('Cascade.MERGE'))) {
options.cascade = `[${cascade.sort().join(', ')}]`;
}
}

private getDecoratorType(def: any, pks: string[], index: any[], fk: any): string {
const primary = pks.includes(def.name);

if (primary) {
return '@PrimaryKey';
}

if (fk && index && index.some(i => i.unique)) {
return '@OneToOne';
}

if (fk) {
return '@ManyToOne';
}

return '@Property';
}

private getPropertyName(field: string, fk: any): string {
if (fk) {
field = field.replace(new RegExp(`_${fk.referencedColumnName}$`), '');
}

return field.replace(/_(\w)/g, m => m[1].toUpperCase());
}

private getPropertyType(type: string, fk: any): string {
if (fk) {
return this.namingStrategy.getClassName(fk.referencedTableName, '_');
}

return this.helper.getTypeFromDefinition(type);
}

private getPropertyDefaultValue(def: any, propType: string): any {
if (!def.nullable && def.defaultValue === null) {
return;
}

if (!def.defaultValue) {
return;
}

if (propType === 'boolean') {
return !!def.defaultValue;
}

if (propType === 'number') {
return +def.defaultValue;
}

return '' + def.defaultValue;
}

}
59 changes: 55 additions & 4 deletions lib/schema/MySqlSchemaHelper.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { MySqlTableBuilder } from 'knex';
import { SchemaHelper } from './SchemaHelper';
import { EntityProperty } from '../decorators';
import { AbstractSqlConnection } from '../connections/AbstractSqlConnection';

export class MySqlSchemaHelper extends SchemaHelper {

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

Expand All @@ -38,4 +41,52 @@ export class MySqlSchemaHelper extends SchemaHelper {
return super.getTypeDefinition(prop, MySqlSchemaHelper.TYPES, MySqlSchemaHelper.DEFAULT_TYPE_LENGTHS);
}

getTypeFromDefinition(type: string): string {
return super.getTypeFromDefinition(type, MySqlSchemaHelper.TYPES);
}

getListTablesSQL(): string {
return `select table_name from information_schema.tables where table_type = 'BASE TABLE' and table_schema = schema()`;
}

getForeignKeysSQL(tableName: string, schemaName?: string): string {
return `select distinct k.constraint_name, k.column_name, k.referenced_table_name, k.referenced_column_name, c.update_rule, c.delete_rule `
+ `from information_schema.key_column_usage k `
+ `inner join information_schema.referential_constraints c on c.constraint_name = k.constraint_name and c.table_name = '${tableName}' `
+ `where k.table_name = '${tableName}' and k.table_schema = database() and c.constraint_schema = database() and k.referenced_column_name is not null`;
}

async getColumns(connection: AbstractSqlConnection, tableName: string, schemaName?: string): Promise<any[]> {
const sql = `select column_name, column_default, is_nullable, data_type, column_key, ifnull(datetime_precision, character_maximum_length) length
from information_schema.columns where table_schema = database() and table_name = '${tableName}'`;
const columns = await connection.execute<any[]>(sql);

return columns.map(col => ({
name: col.column_name,
type: col.data_type,
maxLength: col.length,
defaultValue: col.column_default,
nullable: col.is_nullable === 'YES',
primary: col.column_key === 'PRI',
unique: col.column_key === 'UNI',
}));
}

async getIndexes(connection: AbstractSqlConnection, tableName: string, schemaName?: string): Promise<Record<string, any[]>> {
const sql = `show index from \`${tableName}\``;
const indexes = await connection.execute<any[]>(sql);

return indexes.reduce((ret, index: any) => {
ret[index.Column_name] = ret[index.Column_name] || [];
ret[index.Column_name].push({
columnName: index.Column_name,
keyName: index.Key_name,
unique: !index.Non_unique,
primary: index.Key_name === 'PRIMARY',
});

return ret;
}, {});
}

}
Loading

0 comments on commit 29ebe62

Please sign in to comment.