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 496390e
Show file tree
Hide file tree
Showing 12 changed files with 1,019 additions and 27 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
170 changes: 170 additions & 0 deletions lib/schema/EntityGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { CodeBlockWriter, Project, QuoteKind, SourceFile } from 'ts-morph';

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

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 knex = this.connection.getKnex();
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();
});

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

async createEntity(tableName: string, schemaName?: string): Promise<void> {
const cols = await this.knex(tableName).columnInfo();
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.getClassName(tableName) + '.ts', writer => {
writer.writeLine(`import { PrimaryKey, Property, ManyToOne, OneToMany, OneToOne, ManyToMany } from 'mikro-orm';`);
writer.blankLine();
writer.write(`export class ${this.getClassName(tableName)}`);
writer.block(() => Object.entries(cols).forEach(([field, def]) => this.createProperty(writer, field, def, pks, fks[field])));
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, field: string, def: any, pks: string[], fk: any): void {
const prop = this.getPropertyName(field, fk);
const type = this.getPropertyType(def.type, fk);
const defaultValue = this.getPropertyDefaultValue(def, type);
const decorator = this.getPropertyDecorator(prop, field, def, defaultValue, pks, 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 getClassName(file: string): string {
const name = file.split('.')[0];
const ret = name.replace(/_(\w)/g, m => m[1].toUpperCase());

return ret.charAt(0).toUpperCase() + ret.slice(1);
}

private getPropertyDecorator(prop: string, field: string, def: any, defaultValue: any, pks: string[], fk: any): string {
const options = {} as any;
const primary = pks.includes(field);
const decorator = this.getDecoratorType(primary, fk);

if (fk) {
options.entity = `() => ${this.getClassName(fk.referencedTableName)}`;

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

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

if (def.nullable) {
options.nullable = true;
}

if (defaultValue && typeof defaultValue === 'string') {
options.default = `'${defaultValue}'`;
}

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

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

private getDecoratorType(primary: boolean, fk: any) {
if (primary) {
return '@PrimaryKey';
}

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

}
58 changes: 54 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,51 @@ 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 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;
}, {});
}

mapForeignKeys(fks: any[]): Record<string, any> {
return fks.reduce((ret, fk: any) => {
ret[fk.column_name] = {
columnName: fk.column_name,
constraintName: fk.constraint_name,
referencedTableName: fk.referenced_table_name,
referencedColumnName: fk.referenced_column_name,
updateRule: fk.update_rule,
deleteRule: fk.delete_rule,
};

return ret;
}, {});
}

}
89 changes: 86 additions & 3 deletions lib/schema/PostgreSqlSchemaHelper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SchemaHelper } from './SchemaHelper';
import { EntityProperty } from '../decorators';
import { AbstractSqlConnection } from '../connections/AbstractSqlConnection';

export class PostgreSqlSchemaHelper extends SchemaHelper {

Expand All @@ -8,15 +9,26 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
float: ['float'],
double: ['double', 'double precision', 'float8'],
string: ['varchar(?)', 'character varying', 'text', 'character', 'char'],
Date: ['datetime(?)', 'timestamp(?)', 'timestamp without time zone', 'timestamptz', 'datetimetz', 'time', 'date', 'timetz', 'datetz'],
date: ['datetime(?)', 'timestamp(?)', 'timestamp without time zone', 'timestamptz', 'datetimetz', 'time', 'date', 'timetz', 'datetz'],
boolean: ['boolean', 'bool'],
text: ['text'],
object: ['json'],
json: ['json'],
};

static readonly DEFAULT_VALUES = {
'now()': ['now()', 'current_timestamp'],
"('now'::text)::timestamp(?) with time zone": ['current_timestamp(?)'],
static readonly TYPES_REVERSE = {
'integer': 'number',
'float': 'number',
'double': 'number',
'double precision': 'number',
'varchar': 'string',
'datetime': 'Date',
'timestamp': 'Date',
'timestamp without time zone': 'Date',
'tinyint': 'boolean',
'text': 'string',
'json': 'object',
};

static readonly DEFAULT_TYPE_LENGTHS = {
Expand All @@ -36,8 +48,79 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
return super.getTypeDefinition(prop, PostgreSqlSchemaHelper.TYPES, PostgreSqlSchemaHelper.DEFAULT_TYPE_LENGTHS);
}

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

indexForeignKeys() {
return false;
}

getListTablesSQL(): string {
return 'select quote_ident(table_name) as table_name, table_schema as schema_name '
+ `from information_schema.tables where table_schema not like 'pg\_%' and table_schema != 'information_schema' `
+ `and table_name != 'geometry_columns' and table_name != 'spatial_ref_sys' and table_type != 'VIEW' order by table_name`;
}

async getIndexes(connection: AbstractSqlConnection, tableName: string, schemaName?: string): Promise<Record<string, any[]>> {
const sql = this.getIndexesSQL(tableName, schemaName);
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.constraint_name,
unique: index.unique,
primary: index.primary,
});

return ret;
}, {});
}

private getIndexesSQL(tableName: string, schemaName = 'public'): string {
return `select i.indexname as constraint_name, k.column_name, c.contype = 'u' as unique, c.contype = 'p' as primary
from pg_catalog.pg_indexes i
join pg_catalog.pg_constraint c on c.conname = i.indexname
join pg_catalog.pg_class rel on rel.oid = c.conrelid
join pg_catalog.pg_namespace nsp on nsp.oid = c.connamespace
join information_schema.key_column_usage k on k.constraint_name = c.conname and k.table_schema = 'public' and k.table_name = '${tableName}'
where nsp.nspname = '${schemaName}' and rel.relname = '${tableName}'`;
}

getForeignKeysSQL(tableName: string, schemaName = 'public'): string {
return `select kcu.table_schema || '.' || kcu.table_name as foreign_table,
rel_kcu.table_schema || '.' || rel_kcu.table_name as primary_table,
kcu.column_name as fk_column, rel_kcu.column_name as pk_column, kcu.constraint_name
from information_schema.table_constraints tco
join information_schema.key_column_usage kcu
on tco.constraint_schema = kcu.constraint_schema
and tco.constraint_name = kcu.constraint_name
join information_schema.referential_constraints rco
on tco.constraint_schema = rco.constraint_schema
and tco.constraint_name = rco.constraint_name
join information_schema.key_column_usage rel_kcu
on rco.unique_constraint_schema = rel_kcu.constraint_schema
and rco.unique_constraint_name = rel_kcu.constraint_name
and kcu.ordinal_position = rel_kcu.ordinal_position
where tco.table_name = '${tableName}' and tco.table_schema = '${schemaName}' and tco.constraint_schema = '${schemaName}' and tco.constraint_type = 'FOREIGN KEY'
order by kcu.table_schema, kcu.table_name, kcu.ordinal_position`;
}

mapForeignKeys(fks: any[]): Record<string, any> {
return fks.reduce((ret, fk: any) => {
ret[fk.column_name] = {
columnName: fk.column_name,
constraintName: fk.constraint_name,
referencedTableName: fk.referenced_table_name,
referencedColumnName: fk.referenced_column_name,
updateRule: fk.update_rule,
deleteRule: fk.delete_rule,
};

return ret;
}, {});
}

}
Loading

0 comments on commit 496390e

Please sign in to comment.