diff --git a/packages/entity-generator/src/SourceFile.ts b/packages/entity-generator/src/SourceFile.ts index 38c001f3bc64..9efef6e9590d 100644 --- a/packages/entity-generator/src/SourceFile.ts +++ b/packages/entity-generator/src/SourceFile.ts @@ -505,6 +505,10 @@ export class SourceFile { options.autoincrement = false; } } + + if (prop.generated) { + options.generated = typeof prop.generated === 'string' ? this.quote(prop.generated) : `${prop.generated}`; + } } protected getManyToManyDecoratorOptions(options: Dictionary, prop: EntityProperty) { @@ -609,6 +613,10 @@ export class SourceFile { if (prop.primary) { options.primary = true; } + + if (prop.generated) { + options.generated = typeof prop.generated === 'string' ? this.quote(prop.generated) : `${prop.generated}`; + } } protected getDecoratorType(prop: EntityProperty): string { diff --git a/packages/knex/src/schema/DatabaseTable.ts b/packages/knex/src/schema/DatabaseTable.ts index aebc2d668b4b..b08a27d65b25 100644 --- a/packages/knex/src/schema/DatabaseTable.ts +++ b/packages/knex/src/schema/DatabaseTable.ts @@ -606,6 +606,7 @@ export class DatabaseTable { const column = this.getColumn(fk.columnNames[0])!; columnOptions.default = this.getPropertyDefaultValue(schemaHelper, column, type); columnOptions.defaultRaw = this.getPropertyDefaultValue(schemaHelper, column, type, true); + columnOptions.generated = column.generated; columnOptions.nullable = column.nullable; columnOptions.primary = column.primary; columnOptions.length = column.length; @@ -658,6 +659,7 @@ export class DatabaseTable { name: prop, type, kind, + generated: column.generated, columnType: column.type, default: this.getPropertyDefaultValue(schemaHelper, column, type), defaultRaw: this.getPropertyDefaultValue(schemaHelper, column, type, true), diff --git a/tests/features/entity-generator/GeneratedColumns.mysql.test.ts b/tests/features/entity-generator/GeneratedColumns.mysql.test.ts new file mode 100644 index 000000000000..a8e6cc73c492 --- /dev/null +++ b/tests/features/entity-generator/GeneratedColumns.mysql.test.ts @@ -0,0 +1,90 @@ +import { MikroORM, Utils } from '@mikro-orm/mysql'; +import { EntityGenerator } from '@mikro-orm/entity-generator'; + +let orm: MikroORM; + +const schemaName = 'generated_columns_example'; +const schema = ` +CREATE TABLE IF NOT EXISTS \`allowed_ages_at_creation\` +( + \`age\` TINYINT UNSIGNED NOT NULL, + PRIMARY KEY (\`age\`) +) + ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS \`users\` +( + \`id\` INT UNSIGNED NOT NULL AUTO_INCREMENT, + \`first_name\` VARCHAR(100) NOT NULL, + \`last_name\` VARCHAR(100) NOT NULL, + \`full_name\` VARCHAR(200) GENERATED ALWAYS AS (CONCAT(\`first_name\`, ' ', \`last_name\`)) VIRTUAL NOT NULL, + \`created_at\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`date_of_birth\` DATE NOT NULL, + \`age_at_creation\` TINYINT UNSIGNED GENERATED ALWAYS AS (TIMESTAMPDIFF(YEAR, \`date_of_birth\`, \`created_at\`)) STORED NULL, + PRIMARY KEY (\`id\`), + INDEX \`fk_users_allowed_ages_at_creation_idx\` (\`age_at_creation\` ASC) VISIBLE, + CONSTRAINT \`fk_users_allowed_ages_at_creation\` + FOREIGN KEY (\`age_at_creation\`) + REFERENCES \`allowed_ages_at_creation\` (\`age\`) + ON DELETE CASCADE + ON UPDATE RESTRICT +) + ENGINE = InnoDB; +`; + +beforeAll(async () => { + orm = await MikroORM.init({ + dbName: schemaName, + port: 3308, + discovery: { warnWhenNoEntities: false }, + extensions: [EntityGenerator], + multipleStatements: true, + ensureDatabase: false, + }); + + if (await orm.schema.ensureDatabase({ create: true })) { + await orm.schema.execute(schema); + } + + await orm.close(true); +}); + +beforeEach(async () => { + orm = await MikroORM.init({ + dbName: schemaName, + port: 3308, + discovery: { warnWhenNoEntities: false }, + extensions: [EntityGenerator], + multipleStatements: true, + }); +}); + +afterEach(async () => { + await orm.close(true); +}); + +describe(schemaName, () => { + describe.each([true, false])('entitySchema=%s', entitySchema => { + beforeEach(() => { + orm.config.get('entityGenerator').entitySchema = entitySchema; + }); + + test('generates from db', async () => { + const dump = await orm.entityGenerator.generate(); + expect(dump).toMatchSnapshot('dump'); + }); + + test('as functions from extensions', async () => { + orm.config.get('entityGenerator').onInitialMetadata = metadata => { + const usersMeta = metadata.find(meta => meta.className === 'Users')!; + Object.entries(usersMeta.properties).forEach(([propName, propOptions]) => { + if (typeof propOptions.generated === 'string') { + propOptions.generated = Utils.createFunction(new Map(), `return () => ${JSON.stringify(propOptions.generated)}`); + } + }); + }; + const dump = await orm.entityGenerator.generate(); + expect(dump).toMatchSnapshot('dump'); + }); + }); +}); diff --git a/tests/features/entity-generator/GeneratedColumns.postgresql.test.ts b/tests/features/entity-generator/GeneratedColumns.postgresql.test.ts new file mode 100644 index 000000000000..887831584b16 --- /dev/null +++ b/tests/features/entity-generator/GeneratedColumns.postgresql.test.ts @@ -0,0 +1,84 @@ +import { MikroORM, Utils } from '@mikro-orm/postgresql'; +import { EntityGenerator } from '@mikro-orm/entity-generator'; + +let orm: MikroORM; + +const schemaName = 'generated_columns_example'; +const schema = ` +CREATE TABLE IF NOT EXISTS "allowed_ages_at_creation" +( + "age" SMALLINT, + PRIMARY KEY ("age") +); + +CREATE TABLE IF NOT EXISTS "users" +( + "id" SERIAL PRIMARY KEY, + "first_name" VARCHAR(100) NOT NULL, + "last_name" VARCHAR(100) NOT NULL, + "full_name" VARCHAR(200) GENERATED ALWAYS AS ("first_name" || ' ' || "last_name") STORED NOT NULL, + "created_at" TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + "date_of_birth" DATE NOT NULL, + "age_at_creation" SMALLINT GENERATED ALWAYS AS (EXTRACT(YEAR FROM "created_at") - EXTRACT(YEAR FROM "date_of_birth")) STORED NULL, + CONSTRAINT "fk_users_allowed_ages_at_creation" + FOREIGN KEY ("age_at_creation") + REFERENCES "allowed_ages_at_creation" ("age") + ON DELETE CASCADE + ON UPDATE RESTRICT +); +`; + +beforeAll(async () => { + orm = await MikroORM.init({ + dbName: schemaName, + discovery: { warnWhenNoEntities: false }, + extensions: [EntityGenerator], + multipleStatements: true, + ensureDatabase: false, + }); + + if (await orm.schema.ensureDatabase({ create: true })) { + await orm.schema.execute(schema); + } + + await orm.close(true); +}); + +beforeEach(async () => { + orm = await MikroORM.init({ + dbName: schemaName, + discovery: { warnWhenNoEntities: false }, + extensions: [EntityGenerator], + multipleStatements: true, + }); +}); + +afterEach(async () => { + await orm.close(true); +}); + +describe(schemaName, () => { + describe.each([true, false])('entitySchema=%s', entitySchema => { + beforeEach(() => { + orm.config.get('entityGenerator').entitySchema = entitySchema; + }); + + test('generates from db', async () => { + const dump = await orm.entityGenerator.generate(); + expect(dump).toMatchSnapshot('dump'); + }); + + test('as functions from extensions', async () => { + orm.config.get('entityGenerator').onInitialMetadata = metadata => { + const usersMeta = metadata.find(meta => meta.className === 'Users')!; + Object.entries(usersMeta.properties).forEach(([propName, propOptions]) => { + if (typeof propOptions.generated === 'string') { + propOptions.generated = Utils.createFunction(new Map(), `return () => ${JSON.stringify(propOptions.generated)}`); + } + }); + }; + const dump = await orm.entityGenerator.generate(); + expect(dump).toMatchSnapshot('dump'); + }); + }); +}); diff --git a/tests/features/entity-generator/__snapshots__/GeneratedColumns.mysql.test.ts.snap b/tests/features/entity-generator/__snapshots__/GeneratedColumns.mysql.test.ts.snap new file mode 100644 index 000000000000..47cd15951897 --- /dev/null +++ b/tests/features/entity-generator/__snapshots__/GeneratedColumns.mysql.test.ts.snap @@ -0,0 +1,205 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generated_columns_example entitySchema=false as functions from extensions: dump 1`] = ` +[ + "import { Entity, PrimaryKey, PrimaryKeyProp } from '@mikro-orm/core'; + +@Entity() +export class AllowedAgesAtCreation { + + [PrimaryKeyProp]?: 'age'; + + @PrimaryKey({ columnType: 'tinyint', autoincrement: false }) + age!: number; + +} +", + "import { Entity, ManyToOne, Opt, PrimaryKey, Property } from '@mikro-orm/core'; +import { AllowedAgesAtCreation } from './AllowedAgesAtCreation'; + +@Entity() +export class Users { + + @PrimaryKey() + id!: number; + + @Property({ length: 100 }) + firstName!: string; + + @Property({ length: 100 }) + lastName!: string; + + @Property({ length: 200, generated: () => "concat(\`first_name\`,_utf8mb4\\\\' \\\\',\`last_name\`) virtual" }) + fullName!: string; + + @Property({ length: 0, defaultRaw: \`CURRENT_TIMESTAMP\` }) + createdAt!: Date & Opt; + + @Property({ columnType: 'date' }) + dateOfBirth!: string; + + @ManyToOne({ entity: () => AllowedAgesAtCreation, fieldName: 'age_at_creation', deleteRule: 'cascade', generated: () => "timestampdiff(YEAR,\`date_of_birth\`,\`created_at\`) stored", nullable: true, index: 'fk_users_allowed_ages_at_creation_idx' }) + ageAtCreation?: AllowedAgesAtCreation; + +} +", +] +`; + +exports[`generated_columns_example entitySchema=false generates from db: dump 1`] = ` +[ + "import { Entity, PrimaryKey, PrimaryKeyProp } from '@mikro-orm/core'; + +@Entity() +export class AllowedAgesAtCreation { + + [PrimaryKeyProp]?: 'age'; + + @PrimaryKey({ columnType: 'tinyint', autoincrement: false }) + age!: number; + +} +", + "import { Entity, ManyToOne, Opt, PrimaryKey, Property } from '@mikro-orm/core'; +import { AllowedAgesAtCreation } from './AllowedAgesAtCreation'; + +@Entity() +export class Users { + + @PrimaryKey() + id!: number; + + @Property({ length: 100 }) + firstName!: string; + + @Property({ length: 100 }) + lastName!: string; + + @Property({ length: 200, generated: 'concat(\`first_name\`,_utf8mb4\\' \\',\`last_name\`) virtual' }) + fullName!: string; + + @Property({ length: 0, defaultRaw: \`CURRENT_TIMESTAMP\` }) + createdAt!: Date & Opt; + + @Property({ columnType: 'date' }) + dateOfBirth!: string; + + @ManyToOne({ entity: () => AllowedAgesAtCreation, fieldName: 'age_at_creation', deleteRule: 'cascade', generated: 'timestampdiff(YEAR,\`date_of_birth\`,\`created_at\`) stored', nullable: true, index: 'fk_users_allowed_ages_at_creation_idx' }) + ageAtCreation?: AllowedAgesAtCreation; + +} +", +] +`; + +exports[`generated_columns_example entitySchema=true as functions from extensions: dump 1`] = ` +[ + "import { EntitySchema, PrimaryKeyProp } from '@mikro-orm/core'; + +export class AllowedAgesAtCreation { + [PrimaryKeyProp]?: 'age'; + age!: number; +} + +export const AllowedAgesAtCreationSchema = new EntitySchema({ + class: AllowedAgesAtCreation, + properties: { + age: { primary: true, type: 'number', columnType: 'tinyint', autoincrement: false }, + }, +}); +", + "import { EntitySchema, Opt } from '@mikro-orm/core'; + +export class Users { + id!: number; + firstName!: string; + lastName!: string; + fullName!: string; + createdAt!: Date & Opt; + dateOfBirth!: string; + ageAtCreation?: AllowedAgesAtCreation; +} + +export const UsersSchema = new EntitySchema({ + class: Users, + properties: { + id: { primary: true, type: 'number' }, + firstName: { type: 'string', length: 100 }, + lastName: { type: 'string', length: 100 }, + fullName: { + type: 'string', + length: 200, + generated: () => "concat(\`first_name\`,_utf8mb4\\\\' \\\\',\`last_name\`) virtual", + }, + createdAt: { type: 'Date', length: 0, defaultRaw: \`CURRENT_TIMESTAMP\` }, + dateOfBirth: { type: 'string', columnType: 'date' }, + ageAtCreation: { + kind: 'm:1', + entity: () => AllowedAgesAtCreation, + fieldName: 'age_at_creation', + deleteRule: 'cascade', + generated: () => "timestampdiff(YEAR,\`date_of_birth\`,\`created_at\`) stored", + nullable: true, + index: 'fk_users_allowed_ages_at_creation_idx', + }, + }, +}); +", +] +`; + +exports[`generated_columns_example entitySchema=true generates from db: dump 1`] = ` +[ + "import { EntitySchema, PrimaryKeyProp } from '@mikro-orm/core'; + +export class AllowedAgesAtCreation { + [PrimaryKeyProp]?: 'age'; + age!: number; +} + +export const AllowedAgesAtCreationSchema = new EntitySchema({ + class: AllowedAgesAtCreation, + properties: { + age: { primary: true, type: 'number', columnType: 'tinyint', autoincrement: false }, + }, +}); +", + "import { EntitySchema, Opt } from '@mikro-orm/core'; + +export class Users { + id!: number; + firstName!: string; + lastName!: string; + fullName!: string; + createdAt!: Date & Opt; + dateOfBirth!: string; + ageAtCreation?: AllowedAgesAtCreation; +} + +export const UsersSchema = new EntitySchema({ + class: Users, + properties: { + id: { primary: true, type: 'number' }, + firstName: { type: 'string', length: 100 }, + lastName: { type: 'string', length: 100 }, + fullName: { + type: 'string', + length: 200, + generated: 'concat(\`first_name\`,_utf8mb4\\' \\',\`last_name\`) virtual', + }, + createdAt: { type: 'Date', length: 0, defaultRaw: \`CURRENT_TIMESTAMP\` }, + dateOfBirth: { type: 'string', columnType: 'date' }, + ageAtCreation: { + kind: 'm:1', + entity: () => AllowedAgesAtCreation, + fieldName: 'age_at_creation', + deleteRule: 'cascade', + generated: 'timestampdiff(YEAR,\`date_of_birth\`,\`created_at\`) stored', + nullable: true, + index: 'fk_users_allowed_ages_at_creation_idx', + }, + }, +}); +", +] +`; diff --git a/tests/features/entity-generator/__snapshots__/GeneratedColumns.postgresql.test.ts.snap b/tests/features/entity-generator/__snapshots__/GeneratedColumns.postgresql.test.ts.snap new file mode 100644 index 000000000000..b48db720ec52 --- /dev/null +++ b/tests/features/entity-generator/__snapshots__/GeneratedColumns.postgresql.test.ts.snap @@ -0,0 +1,203 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generated_columns_example entitySchema=false as functions from extensions: dump 1`] = ` +[ + "import { Entity, PrimaryKey, PrimaryKeyProp } from '@mikro-orm/core'; + +@Entity() +export class AllowedAgesAtCreation { + + [PrimaryKeyProp]?: 'age'; + + @PrimaryKey({ columnType: 'smallint', autoincrement: false }) + age!: number; + +} +", + "import { Entity, ManyToOne, Opt, PrimaryKey, Property } from '@mikro-orm/core'; +import { AllowedAgesAtCreation } from './AllowedAgesAtCreation'; + +@Entity() +export class Users { + + @PrimaryKey() + id!: number; + + @Property({ length: 100 }) + firstName!: string; + + @Property({ length: 100 }) + lastName!: string; + + @Property({ length: 200, generated: () => "(((first_name)::text || ' '::text) || (last_name)::text) stored" }) + fullName!: string; + + @Property({ length: 6, defaultRaw: \`CURRENT_TIMESTAMP\` }) + createdAt!: Date & Opt; + + @Property({ columnType: 'date' }) + dateOfBirth!: string; + + @ManyToOne({ entity: () => AllowedAgesAtCreation, fieldName: 'age_at_creation', deleteRule: 'cascade', generated: () => "(EXTRACT(year FROM created_at) - EXTRACT(year FROM date_of_birth)) stored", nullable: true }) + ageAtCreation?: AllowedAgesAtCreation; + +} +", +] +`; + +exports[`generated_columns_example entitySchema=false generates from db: dump 1`] = ` +[ + "import { Entity, PrimaryKey, PrimaryKeyProp } from '@mikro-orm/core'; + +@Entity() +export class AllowedAgesAtCreation { + + [PrimaryKeyProp]?: 'age'; + + @PrimaryKey({ columnType: 'smallint', autoincrement: false }) + age!: number; + +} +", + "import { Entity, ManyToOne, Opt, PrimaryKey, Property } from '@mikro-orm/core'; +import { AllowedAgesAtCreation } from './AllowedAgesAtCreation'; + +@Entity() +export class Users { + + @PrimaryKey() + id!: number; + + @Property({ length: 100 }) + firstName!: string; + + @Property({ length: 100 }) + lastName!: string; + + @Property({ length: 200, generated: '(((first_name)::text || ' '::text) || (last_name)::text) stored' }) + fullName!: string; + + @Property({ length: 6, defaultRaw: \`CURRENT_TIMESTAMP\` }) + createdAt!: Date & Opt; + + @Property({ columnType: 'date' }) + dateOfBirth!: string; + + @ManyToOne({ entity: () => AllowedAgesAtCreation, fieldName: 'age_at_creation', deleteRule: 'cascade', generated: '(EXTRACT(year FROM created_at) - EXTRACT(year FROM date_of_birth)) stored', nullable: true }) + ageAtCreation?: AllowedAgesAtCreation; + +} +", +] +`; + +exports[`generated_columns_example entitySchema=true as functions from extensions: dump 1`] = ` +[ + "import { EntitySchema, PrimaryKeyProp } from '@mikro-orm/core'; + +export class AllowedAgesAtCreation { + [PrimaryKeyProp]?: 'age'; + age!: number; +} + +export const AllowedAgesAtCreationSchema = new EntitySchema({ + class: AllowedAgesAtCreation, + properties: { + age: { primary: true, type: 'number', columnType: 'smallint', autoincrement: false }, + }, +}); +", + "import { EntitySchema, Opt } from '@mikro-orm/core'; + +export class Users { + id!: number; + firstName!: string; + lastName!: string; + fullName!: string; + createdAt!: Date & Opt; + dateOfBirth!: string; + ageAtCreation?: AllowedAgesAtCreation; +} + +export const UsersSchema = new EntitySchema({ + class: Users, + properties: { + id: { primary: true, type: 'number' }, + firstName: { type: 'string', length: 100 }, + lastName: { type: 'string', length: 100 }, + fullName: { + type: 'string', + length: 200, + generated: () => "(((first_name)::text || ' '::text) || (last_name)::text) stored", + }, + createdAt: { type: 'Date', length: 6, defaultRaw: \`CURRENT_TIMESTAMP\` }, + dateOfBirth: { type: 'string', columnType: 'date' }, + ageAtCreation: { + kind: 'm:1', + entity: () => AllowedAgesAtCreation, + fieldName: 'age_at_creation', + deleteRule: 'cascade', + generated: () => "(EXTRACT(year FROM created_at) - EXTRACT(year FROM date_of_birth)) stored", + nullable: true, + }, + }, +}); +", +] +`; + +exports[`generated_columns_example entitySchema=true generates from db: dump 1`] = ` +[ + "import { EntitySchema, PrimaryKeyProp } from '@mikro-orm/core'; + +export class AllowedAgesAtCreation { + [PrimaryKeyProp]?: 'age'; + age!: number; +} + +export const AllowedAgesAtCreationSchema = new EntitySchema({ + class: AllowedAgesAtCreation, + properties: { + age: { primary: true, type: 'number', columnType: 'smallint', autoincrement: false }, + }, +}); +", + "import { EntitySchema, Opt } from '@mikro-orm/core'; + +export class Users { + id!: number; + firstName!: string; + lastName!: string; + fullName!: string; + createdAt!: Date & Opt; + dateOfBirth!: string; + ageAtCreation?: AllowedAgesAtCreation; +} + +export const UsersSchema = new EntitySchema({ + class: Users, + properties: { + id: { primary: true, type: 'number' }, + firstName: { type: 'string', length: 100 }, + lastName: { type: 'string', length: 100 }, + fullName: { + type: 'string', + length: 200, + generated: '(((first_name)::text || ' '::text) || (last_name)::text) stored', + }, + createdAt: { type: 'Date', length: 6, defaultRaw: \`CURRENT_TIMESTAMP\` }, + dateOfBirth: { type: 'string', columnType: 'date' }, + ageAtCreation: { + kind: 'm:1', + entity: () => AllowedAgesAtCreation, + fieldName: 'age_at_creation', + deleteRule: 'cascade', + generated: '(EXTRACT(year FROM created_at) - EXTRACT(year FROM date_of_birth)) stored', + nullable: true, + }, + }, +}); +", +] +`;