diff --git a/docs/docs/entity-generator.md b/docs/docs/entity-generator.md index c7bc34453aa9..a243afad6ee9 100644 --- a/docs/docs/entity-generator.md +++ b/docs/docs/entity-generator.md @@ -43,5 +43,4 @@ $ 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 diff --git a/packages/entity-generator/src/EntityGenerator.ts b/packages/entity-generator/src/EntityGenerator.ts index a8b008210b3b..0854b1c7ec55 100644 --- a/packages/entity-generator/src/EntityGenerator.ts +++ b/packages/entity-generator/src/EntityGenerator.ts @@ -1,6 +1,7 @@ import { ensureDir, writeFile } from 'fs-extra'; -import { Utils } from '@mikro-orm/core'; -import type { DatabaseTable, EntityManager } from '@mikro-orm/knex'; +import type { EntityProperty } from '@mikro-orm/core'; +import { ReferenceType, Utils } from '@mikro-orm/core'; +import type { EntityManager } from '@mikro-orm/knex'; import { DatabaseSchema } from '@mikro-orm/knex'; import { SourceFile } from './SourceFile'; @@ -19,9 +20,39 @@ export class EntityGenerator { async generate(options: { baseDir?: string; save?: boolean; schema?: string } = {}): Promise { const baseDir = Utils.normalizePath(options.baseDir || this.config.get('baseDir') + '/generated-entities'); const schema = await DatabaseSchema.create(this.connection, this.platform, this.config); - schema.getTables() + + const metadata = schema.getTables() .filter(table => !options.schema || table.schema === options.schema) - .forEach(table => this.createEntity(table)); + .map(table => table.getEntityDeclaration(this.namingStrategy, this.helper)); + + // detect M:N relations + for (const meta of metadata) { + if ( + meta.compositePK && // needs to have composite PK + meta.primaryKeys.length === meta.relations.length && // all relations are PKs + meta.relations.length === 2 && // there are exactly two relation properties + meta.relations.length === meta.props.length && // all properties are relations + meta.relations.every(prop => prop.reference === ReferenceType.MANY_TO_ONE) // all relations are m:1 + ) { + meta.pivotTable = true; + const owner = metadata.find(m => m.className === meta.relations[0].type)!; + const name = this.namingStrategy.columnNameToProperty(meta.tableName.replace(new RegExp('^' + owner.tableName + '_'), '')); + owner.addProperty({ + name, + reference: ReferenceType.MANY_TO_MANY, + pivotTable: meta.tableName, + type: meta.relations[1].type, + joinColumns: meta.relations[0].fieldNames, + inverseJoinColumns: meta.relations[1].fieldNames, + } as EntityProperty); + } + } + + for (const meta of metadata) { + if (!meta.pivotTable) { + this.sources.push(new SourceFile(meta, this.namingStrategy, this.platform)); + } + } if (options.save) { await ensureDir(baseDir); @@ -31,9 +62,4 @@ export class EntityGenerator { return this.sources.map(file => file.generate()); } - createEntity(table: DatabaseTable): void { - const meta = table.getEntityDeclaration(this.namingStrategy, this.helper); - this.sources.push(new SourceFile(meta, this.namingStrategy, this.platform)); - } - } diff --git a/packages/entity-generator/src/SourceFile.ts b/packages/entity-generator/src/SourceFile.ts index 874363b1b0fd..21383e4de26a 100644 --- a/packages/entity-generator/src/SourceFile.ts +++ b/packages/entity-generator/src/SourceFile.ts @@ -84,13 +84,18 @@ export class SourceFile { } private getPropertyDefinition(prop: EntityProperty, padLeft: number): string { - // string defaults are usually things like SQL functions - // string defaults can also be enums, for that useDefault should be true. + const padding = ' '.repeat(padLeft); + + if (prop.reference === ReferenceType.MANY_TO_MANY) { + this.coreImports.add('Collection'); + return `${padding}${prop.name} = new Collection<${prop.type}>(this);\n`; + } + + // string defaults are usually things like SQL functions, but can be also enums, for that `useDefault` should be true const isEnumOrNonStringDefault = prop.enum || typeof prop.default !== 'string'; const useDefault = prop.default != null && isEnumOrNonStringDefault; const optional = prop.nullable ? '?' : (useDefault ? '' : '!'); const ret = `${prop.name}${optional}: ${prop.type}`; - const padding = ' '.repeat(padLeft); if (!useDefault) { return `${padding + ret};\n`; @@ -122,7 +127,9 @@ export class SourceFile { let decorator = this.getDecoratorType(prop); this.coreImports.add(decorator.substring(1)); - if (prop.reference !== ReferenceType.SCALAR) { + if (prop.reference === ReferenceType.MANY_TO_MANY) { + this.getManyToManyDecoratorOptions(options, prop); + } else if (prop.reference !== ReferenceType.SCALAR) { this.getForeignKeyDecoratorOptions(options, prop); } else { this.getScalarPropertyDecoratorOptions(options, prop); @@ -237,6 +244,27 @@ export class SourceFile { } } + private getManyToManyDecoratorOptions(options: Dictionary, prop: EntityProperty) { + this.entityImports.add(prop.type); + options.entity = `() => ${prop.type}`; + + if (prop.pivotTable !== this.namingStrategy.joinTableName(this.meta.collection, prop.type, prop.name)) { + options.pivotTable = this.quote(prop.pivotTable); + } + + if (prop.joinColumns.length === 1) { + options.joinColumn = this.quote(prop.joinColumns[0]); + } else { + options.joinColumns = `[${prop.joinColumns.map(this.quote).join(', ')}]`; + } + + if (prop.inverseJoinColumns.length === 1) { + options.inverseJoinColumn = this.quote(prop.inverseJoinColumns[0]); + } else { + options.inverseJoinColumns = `[${prop.inverseJoinColumns.map(this.quote).join(', ')}]`; + } + } + private getForeignKeyDecoratorOptions(options: Dictionary, prop: EntityProperty) { const parts = prop.referencedTableName.split('.', 2); const className = this.namingStrategy.getClassName(parts.length > 1 ? parts[1] : parts[0], '_'); @@ -274,6 +302,10 @@ export class SourceFile { return '@ManyToOne'; } + if (prop.reference === ReferenceType.MANY_TO_MANY) { + return '@ManyToMany'; + } + if (prop.primary) { return '@PrimaryKey'; } diff --git a/packages/knex/src/AbstractSqlDriver.ts b/packages/knex/src/AbstractSqlDriver.ts index 18058f966ce1..045bfcaa5bed 100644 --- a/packages/knex/src/AbstractSqlDriver.ts +++ b/packages/knex/src/AbstractSqlDriver.ts @@ -409,11 +409,11 @@ export abstract class AbstractSqlDriver, O extends AnyEntity>(coll: Collection, options?: DriverMethodOptions): Promise { const wrapped = coll.owner.__helper!; const meta = wrapped.__meta; - const pks = wrapped.getPrimaryKeys(true); + const pks = wrapped.getPrimaryKeys(true)!; const snap = coll.getSnapshot(); const includes = (arr: T[], item: T) => !!arr.find(i => Utils.equals(i, item)); - const snapshot = snap ? snap.map(item => item.__helper!.getPrimaryKeys(true)) : []; - const current = coll.getItems(false).map(item => item.__helper!.getPrimaryKeys(true)); + const snapshot = snap ? snap.map(item => item.__helper!.getPrimaryKeys(true)!) : []; + const current = coll.getItems(false).map(item => item.__helper!.getPrimaryKeys(true)!); const deleteDiff = snap ? snapshot.filter(item => !includes(current, item)) : true; const insertDiff = current.filter(item => !includes(snapshot, item)); const target = snapshot.filter(item => includes(current, item)).concat(...insertDiff); @@ -438,15 +438,17 @@ export abstract class AbstractSqlDriver(qb)); } + /* istanbul ignore next */ const ownerSchema = wrapped.getSchema() === '*' ? this.config.get('schema') : wrapped.getSchema(); const pivotMeta = this.metadata.find(coll.property.pivotTable)!; if (pivotMeta.schema === '*') { + /* istanbul ignore next */ options ??= {}; options.schema = ownerSchema; } - return this.rethrow(this.updateCollectionDiff(meta, coll.property, pks as any, deleteDiff as any, insertDiff as any, options)); + return this.rethrow(this.updateCollectionDiff(meta, coll.property, pks, deleteDiff, insertDiff, options)); } async loadFromPivotTable(prop: EntityProperty, owners: Primary[][], where: FilterQuery = {} as FilterQuery, orderBy?: QueryOrderMap[], ctx?: Transaction, options?: FindOptions): Promise> { diff --git a/tests/features/entity-generator/__snapshots__/EntityGenerator.test.ts.snap b/tests/features/entity-generator/__snapshots__/EntityGenerator.test.ts.snap index 7cf2bd1fff33..fa10c0a34700 100644 --- a/tests/features/entity-generator/__snapshots__/EntityGenerator.test.ts.snap +++ b/tests/features/entity-generator/__snapshots__/EntityGenerator.test.ts.snap @@ -84,7 +84,7 @@ export class Address2 { } ", - "import { Entity, Index, ManyToOne, PrimaryKey, Property, Unique } from '@mikro-orm/core'; + "import { Collection, Entity, Index, ManyToMany, ManyToOne, PrimaryKey, Property, Unique } from '@mikro-orm/core'; import { Book2 } from './Book2'; @Entity() @@ -137,33 +137,11 @@ export class Author2 { @ManyToOne({ entity: () => Author2, onUpdateIntegrity: 'cascade', onDelete: 'set null', nullable: true }) favouriteAuthor?: Author2; -} -", - "import { Entity, ManyToOne } from '@mikro-orm/core'; -import { Author2 } from './Author2'; - -@Entity({ tableName: 'author2_following' }) -export class Author2Following { - - @ManyToOne({ entity: () => Author2, fieldName: 'author2_1_id', onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - author21!: Author2; - - @ManyToOne({ entity: () => Author2, fieldName: 'author2_2_id', onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - author22!: Author2; - -} -", - "import { Entity, ManyToOne } from '@mikro-orm/core'; -import { Author2 } from './Author2'; - -@Entity() -export class AuthorToFriend { - - @ManyToOne({ entity: () => Author2, fieldName: 'author2_1_id', onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - author21!: Author2; + @ManyToMany({ entity: () => Author2, joinColumn: 'author2_1_id', inverseJoinColumn: 'author2_2_id' }) + following = new Collection(this); - @ManyToOne({ entity: () => Author2, fieldName: 'author2_2_id', onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - author22!: Author2; + @ManyToMany({ entity: () => Author2, pivotTable: 'author_to_friend', joinColumn: 'author2_1_id', inverseJoinColumn: 'author2_2_id' }) + authorToFriend = new Collection(this); } ", @@ -208,8 +186,9 @@ export enum BaseUser2Type { OWNER = 'owner', } ", - "import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; + "import { Collection, Entity, ManyToMany, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Author2 } from './Author2'; +import { BookTag2 } from './BookTag2'; import { Publisher2 } from './Publisher2'; @Entity() @@ -245,6 +224,9 @@ export class Book2 { @Property({ length: 255, nullable: true, default: 'lol' }) foo?: string; + @ManyToMany({ entity: () => BookTag2, pivotTable: 'book_to_tag_unordered', joinColumn: 'book2_uuid_pk', inverseJoinColumn: 'book_tag2_id' }) + bookToTagUnordered = new Collection(this); + } ", "import { Entity, ManyToOne, PrimaryKey } from '@mikro-orm/core'; @@ -276,21 +258,6 @@ export class BookTag2 { @Property({ length: 50 }) name!: string; -} -", - "import { Entity, ManyToOne } from '@mikro-orm/core'; -import { Book2 } from './Book2'; -import { BookTag2 } from './BookTag2'; - -@Entity() -export class BookToTagUnordered { - - @ManyToOne({ entity: () => Book2, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - book2!: Book2; - - @ManyToOne({ entity: () => BookTag2, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - bookTag2!: BookTag2; - } ", "import { Entity, Index, PrimaryKey, Property } from '@mikro-orm/core'; @@ -515,7 +482,7 @@ export class Sandwich { } ", - "import { Entity, ManyToOne, OneToOne, PrimaryKey, Property } from '@mikro-orm/core'; + "import { Collection, Entity, ManyToMany, ManyToOne, OneToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Book2 } from './Book2'; import { FooBar2 } from './FooBar2'; @@ -543,25 +510,14 @@ export class Test2 { @Property({ fieldName: 'foo___baz', nullable: true }) fooBaz?: number; -} -", - "import { Entity, ManyToOne } from '@mikro-orm/core'; -import { FooBar2 } from './FooBar2'; -import { Test2 } from './Test2'; - -@Entity({ tableName: 'test2_bars' }) -export class Test2Bars { - - @ManyToOne({ entity: () => Test2, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - test2!: Test2; - - @ManyToOne({ entity: () => FooBar2, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - fooBar2!: FooBar2; + @ManyToMany({ entity: () => FooBar2, joinColumn: 'test2_id', inverseJoinColumn: 'foo_bar2_id' }) + bars = new Collection(this); } ", - "import { Entity, OneToOne, PrimaryKey, Property } from '@mikro-orm/core'; + "import { Collection, Entity, ManyToMany, OneToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Car2 } from './Car2'; +import { Sandwich } from './Sandwich'; @Entity() export class User2 { @@ -578,35 +534,11 @@ export class User2 { @OneToOne({ entity: () => Car2, onUpdateIntegrity: 'cascade', onDelete: 'set null', nullable: true }) favouriteCar?: Car2; -} -", - "import { Entity, ManyToOne } from '@mikro-orm/core'; -import { Car2 } from './Car2'; -import { User2 } from './User2'; - -@Entity({ tableName: 'user2_cars' }) -export class User2Cars { + @ManyToMany({ entity: () => Car2, joinColumns: ['user2_first_name', 'user2_last_name'], inverseJoinColumns: ['car2_name', 'car2_year'] }) + cars = new Collection(this); - @ManyToOne({ entity: () => User2, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - user2!: User2; - - @ManyToOne({ entity: () => Car2, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - car2!: Car2; - -} -", - "import { Entity, ManyToOne } from '@mikro-orm/core'; -import { Sandwich } from './Sandwich'; -import { User2 } from './User2'; - -@Entity({ tableName: 'user2_sandwiches' }) -export class User2Sandwiches { - - @ManyToOne({ entity: () => User2, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - user2!: User2; - - @ManyToOne({ entity: () => Sandwich, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - sandwich!: Sandwich; + @ManyToMany({ entity: () => Sandwich, joinColumns: ['user2_first_name', 'user2_last_name'], inverseJoinColumn: 'sandwich_id' }) + sandwiches = new Collection(this); } ", @@ -629,7 +561,7 @@ export class Address2 { } ", - "import { Entity, Index, ManyToOne, PrimaryKey, Property, Unique } from '@mikro-orm/core'; + "import { Collection, Entity, Index, ManyToMany, ManyToOne, PrimaryKey, Property, Unique } from '@mikro-orm/core'; import { Book2 } from './Book2'; @Entity() @@ -682,38 +614,17 @@ export class Author2 { @ManyToOne({ entity: () => Author2, onUpdateIntegrity: 'cascade', onDelete: 'set null', nullable: true }) favouriteAuthor?: Author2; -} -", - "import { Entity, ManyToOne } from '@mikro-orm/core'; -import { Author2 } from './Author2'; - -@Entity({ tableName: 'author2_following' }) -export class Author2Following { + @ManyToMany({ entity: () => Author2, joinColumn: 'author2_1_id', inverseJoinColumn: 'author2_2_id' }) + following = new Collection(this); - @ManyToOne({ entity: () => Author2, fieldName: 'author2_1_id', onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - author21!: Author2; - - @ManyToOne({ entity: () => Author2, fieldName: 'author2_2_id', onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - author22!: Author2; + @ManyToMany({ entity: () => Author2, pivotTable: 'author_to_friend', joinColumn: 'author2_1_id', inverseJoinColumn: 'author2_2_id' }) + authorToFriend = new Collection(this); } ", - "import { Entity, ManyToOne } from '@mikro-orm/core'; -import { Author2 } from './Author2'; - -@Entity() -export class AuthorToFriend { - - @ManyToOne({ entity: () => Author2, fieldName: 'author2_1_id', onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - author21!: Author2; - - @ManyToOne({ entity: () => Author2, fieldName: 'author2_2_id', onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - author22!: Author2; - -} -", - "import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; + "import { Collection, Entity, ManyToMany, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Author2 } from './Author2'; +import { BookTag2 } from './BookTag2'; import { Publisher2 } from './Publisher2'; @Entity() @@ -749,6 +660,9 @@ export class Book2 { @Property({ length: 255, nullable: true, default: 'lol' }) foo?: string; + @ManyToMany({ entity: () => BookTag2, pivotTable: 'book_to_tag_unordered', joinColumn: 'book2_uuid_pk', inverseJoinColumn: 'book_tag2_id' }) + bookToTagUnordered = new Collection(this); + } ", "import { Entity, ManyToOne, PrimaryKey } from '@mikro-orm/core'; @@ -780,21 +694,6 @@ export class BookTag2 { @Property({ length: 50 }) name!: string; -} -", - "import { Entity, ManyToOne } from '@mikro-orm/core'; -import { Book2 } from './Book2'; -import { BookTag2 } from './BookTag2'; - -@Entity() -export class BookToTagUnordered { - - @ManyToOne({ entity: () => Book2, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - book2!: Book2; - - @ManyToOne({ entity: () => BookTag2, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - bookTag2!: BookTag2; - } ", "import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; @@ -989,8 +888,9 @@ export class Publisher2Tests { } ", - "import { Entity, ManyToOne, OneToOne, PrimaryKey, Property } from '@mikro-orm/core'; + "import { Collection, Entity, ManyToMany, ManyToOne, OneToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Book2 } from './Book2'; +import { FooBar2 } from './FooBar2'; @Entity() export class Test2 { @@ -1013,20 +913,8 @@ export class Test2 { @Property({ columnType: 'polygon', nullable: true }) path?: unknown; -} -", - "import { Entity, ManyToOne } from '@mikro-orm/core'; -import { FooBar2 } from './FooBar2'; -import { Test2 } from './Test2'; - -@Entity({ tableName: 'test2_bars' }) -export class Test2Bars { - - @ManyToOne({ entity: () => Test2, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - test2!: Test2; - - @ManyToOne({ entity: () => FooBar2, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - fooBar2!: FooBar2; + @ManyToMany({ entity: () => FooBar2, joinColumn: 'test2_id', inverseJoinColumn: 'foo_bar2_id' }) + bars = new Collection(this); } ", diff --git a/tests/features/multiple-schemas/__snapshots__/multiple-schemas.postgres.test.ts.snap b/tests/features/multiple-schemas/__snapshots__/multiple-schemas.postgres.test.ts.snap index fe4b13dd70b8..65c87d71a674 100644 --- a/tests/features/multiple-schemas/__snapshots__/multiple-schemas.postgres.test.ts.snap +++ b/tests/features/multiple-schemas/__snapshots__/multiple-schemas.postgres.test.ts.snap @@ -2,8 +2,9 @@ exports[`multiple connected schemas in postgres generate entities for given schema only 1`] = ` Array [ - "import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; + "import { Collection, Entity, ManyToMany, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Author } from './Author'; +import { BookTag } from './BookTag'; @Entity({ schema: 'n2' }) export class Book { @@ -20,6 +21,9 @@ export class Book { @ManyToOne({ entity: () => Book, onUpdateIntegrity: 'cascade', onDelete: 'set null', nullable: true }) basedOn?: Book; + @ManyToMany({ entity: () => BookTag, joinColumn: 'book_id', inverseJoinColumn: 'book_tag_id' }) + tags = new Collection(this); + } ", "import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; @@ -33,21 +37,6 @@ export class BookTag { @Property({ length: 255, nullable: true }) name?: string; -} -", - "import { Entity, ManyToOne } from '@mikro-orm/core'; -import { Book } from './Book'; -import { BookTag } from './BookTag'; - -@Entity({ schema: 'n2' }) -export class BookTags { - - @ManyToOne({ entity: () => Book, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - book!: Book; - - @ManyToOne({ entity: () => BookTag, onUpdateIntegrity: 'cascade', onDelete: 'cascade', primary: true }) - bookTag!: BookTag; - } ", ]