Skip to content

Commit

Permalink
feat(entity-generator): detect more ManyToMany relations
Browse files Browse the repository at this point in the history
Auto increment columns in pivot entities are now detected.

In addition, by default, pivot tables are allowed to contain additional props,
including relations, but only if those props would not hinder the ORM's ability
to freely insert and remove records from the collection.

In other words, those additional props need to be optional,
and have defaults that are either null or non-unique.

This can be adjusted with the two new settings
onlyPurePivotTables and readOnlyPivotTables.
  • Loading branch information
boenrobot committed Nov 29, 2023
1 parent 5c4b3a2 commit 635efd8
Show file tree
Hide file tree
Showing 9 changed files with 652 additions and 79 deletions.
2 changes: 2 additions & 0 deletions docs/docs/entity-generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ Available options:
| `esmImport: boolean` | By default, import statements for entities without extensions are used. If set to `true`, uses ESM style import for imported entities, i.e. adds a `.js` suffix as extension. |
| `scalarTypeInDecorator: boolean` | If `true`, include the `type` option in scalar property decorators. This information is discovered at runtime, but the process of discovery can be skipped by including this option in the decorator. If using `EntitySchema`, this type information is always included. |
| `scalarPropertiesForRelations: 'never' \| 'always' \| 'smart'` | <ul><li> `'never'` (default) - Do not generate any scalar properties for columns covered by foreign key relations. This effectively forces the application to always provide the entire relation, or (if all columns in the relation are nullable) omit the entire relation.</li><li> `'always'` - Generate all scalar properties for all columns covered by foreign key relations. This enables the application to deal with code that disables foreign key checks.</li><li> `'smart'` - Only generate scalar properties for foreign key relations, where doing so is necessary to enable the management of rows where a composite foreign key is not enforced due to some columns being set to NULL. This enables the application to deal with all rows that could possibly be part of a table, even when foreign key checks are always enabled.</li></ul> |
| `onlyPurePivotTables: boolean` | By default, M:N relations are allowed to use pivot tables containing additional columns. If set to `true`, M:N relations will not be generated for such pivot tables. |
| `readOnlyPivotTables: boolean` | By default, M:N relations are only generated if the collection would be writable, i.e. any additional columns need to be optional and have non-unique default values. If set to `true`, also generate M:N relations even if the collection would be read only (meaning the only way to write to it is by using the pivot entity directly). Such collections will include the `persist: false` option. This setting is effectively meaningless if `onlyPurePivotTables` is set to `true`. |

Example configuration:

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,8 @@ export interface GenerateOptions {
esmImport?: boolean;
scalarTypeInDecorator?: boolean;
scalarPropertiesForRelations?: 'always' | 'never' | 'smart';
onlyPurePivotTables?: boolean;
readOnlyPivotTables?: boolean;
}

export interface IEntityGenerator {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/utils/Configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export class Configuration<D extends IDatabaseDriver = IDatabaseDriver> {
identifiedReferences: false,
scalarTypeInDecorator: false,
scalarPropertiesForRelations: 'never',
onlyPurePivotTables: false,
readOnlyPivotTables: false,
},
metadataCache: {
pretty: false,
Expand Down Expand Up @@ -579,6 +581,8 @@ export interface MikroORMOptions<D extends IDatabaseDriver = IDatabaseDriver> ex
esmImport?: boolean;
scalarTypeInDecorator?: boolean;
scalarPropertiesForRelations?: 'always' | 'never' | 'smart';
onlyPurePivotTables?: boolean;
readOnlyPivotTables?: boolean;
};
metadataCache: {
enabled?: boolean;
Expand Down
126 changes: 98 additions & 28 deletions packages/entity-generator/src/EntityGenerator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { ensureDir, writeFile } from 'fs-extra';
import {
type EntityMetadata,
type EntityProperty,
Expand All @@ -17,8 +16,9 @@ import {
type EntityManager,
type SchemaHelper,
} from '@mikro-orm/knex';
import { SourceFile } from './SourceFile';
import { ensureDir, writeFile } from 'fs-extra';
import { EntitySchemaSourceFile } from './EntitySchemaSourceFile';
import { SourceFile } from './SourceFile';

export class EntityGenerator {

Expand Down Expand Up @@ -97,7 +97,7 @@ export class EntityGenerator {

metadata = metadata.filter(table => !options.skipTables || !options.skipTables.includes(table.tableName));

this.detectManyToManyRelations(metadata);
this.detectManyToManyRelations(metadata, options.onlyPurePivotTables!, options.readOnlyPivotTables!);

if (options.bidirectionalRelations) {
this.generateBidirectionalRelations(metadata);
Expand All @@ -110,7 +110,7 @@ export class EntityGenerator {
return metadata;
}

private detectManyToManyRelations(metadata: EntityMetadata[]): void {
private detectManyToManyRelations(metadata: EntityMetadata[], onlyPurePivotTables: boolean, readOnlyPivotTables: boolean): void {
for (const meta of metadata) {
const isReferenced = metadata.some(m => {
return m.tableName !== meta.tableName && m.relations.some(r => {
Expand All @@ -122,39 +122,108 @@ export class EntityGenerator {
this.referencedEntities.add(meta);
}


// Entities with non-composite PKs are never pivot tables. Skip.
if (!meta.compositePK) {
continue;
}

// Entities where there are not exactly 2 PK relations that are both ManyToOne are never pivot tables. Skip.
const pkRelations = meta.relations.filter(rel => rel.primary);
if (
meta.compositePK && // needs to have composite PK
meta.relations.length === 2 && // there are exactly two relation properties
!meta.relations.some(rel => !rel.primary || rel.kind !== ReferenceKind.MANY_TO_ONE) && // all relations are m:1 and PKs
(
// all properties are relations...
meta.relations.length === meta.props.length
// ... or at least all fields involved are only the fields of the relations
|| (new Set<string>(meta.props.flatMap(prop => prop.fieldNames)).size === (new Set<string>(meta.relations.flatMap(rel => rel.fieldNames)).size))
)
pkRelations.length !== 2 ||
pkRelations.some(rel => rel.kind !== ReferenceKind.MANY_TO_ONE)
) {
meta.pivotTable = true;
const owner = metadata.find(m => m.className === meta.relations[0].type);
continue;
}

if (!owner) {
const pkRelationFields = new Set<string>(pkRelations.flatMap(rel => rel.fieldNames));
const nonPkFields = Array.from(new Set<string>(meta.props.flatMap(prop => prop.fieldNames))).filter(fieldName => !pkRelationFields.has(fieldName));

let fixedOrderColumn: string | undefined;
let isReadOnly = false;

// If there are any fields other than the ones in the two PK relations, table may or may not be a pivot one.
// Check further and skip on disqualification.
if (nonPkFields.length > 0) {
// Additional columns have been disabled with the setting.
// Skip table even it otherwise would have qualified as a pivot table.
if (onlyPurePivotTables) {
continue;
}

const name = this.namingStrategy.columnNameToProperty(meta.tableName.replace(new RegExp('^' + owner.tableName + '_'), ''));
const ownerProp = {
name,
kind: ReferenceKind.MANY_TO_MANY,
pivotTable: meta.tableName,
type: meta.relations[1].type,
joinColumns: meta.relations[0].fieldNames,
inverseJoinColumns: meta.relations[1].fieldNames,
} as EntityProperty;
const pkRelationNames = pkRelations.map(rel => rel.name);
let otherProps = meta.props
.filter(prop => !pkRelationNames.includes(prop.name) &&
prop.persist !== false && // Skip checking non-persist props
prop.fieldNames.some(fieldName => nonPkFields.includes(fieldName)),
);

if (this.referencedEntities.has(meta)) {
ownerProp.pivotEntity = meta.className;
// Deal with the auto increment column first. That is the column used for fixed ordering, if present.
const autoIncrementProp = otherProps.find(prop => prop.autoincrement && prop.fieldNames.length === 1);
if (autoIncrementProp) {
otherProps = otherProps.filter(prop => prop !== autoIncrementProp);
fixedOrderColumn = autoIncrementProp.fieldNames[0];

Check warning on line 166 in packages/entity-generator/src/EntityGenerator.ts

View check run for this annotation

Codecov / codecov/patch

packages/entity-generator/src/EntityGenerator.ts#L165-L166

Added lines #L165 - L166 were not covered by tests
}
owner.addProperty(ownerProp);

isReadOnly = otherProps.some(prop => {
// If the prop is non-nullable and unique, it will trivially end up causing issues.
// Mark as read only.
if (!prop.nullable && prop.unique) {
return true;

Check warning on line 173 in packages/entity-generator/src/EntityGenerator.ts

View check run for this annotation

Codecov / codecov/patch

packages/entity-generator/src/EntityGenerator.ts#L173

Added line #L173 was not covered by tests
}

// Scalar props need to also have a default or be generated ones.
if (prop.kind === ReferenceKind.SCALAR) {
return !(typeof prop.defaultRaw !== 'undefined' || prop.generated);
}

// Non-scalar props need to also be optional.
// Even if they have a default, we've already checked that not explicitly setting the property
// means the default is either NULL, or a non-unique non-null value, making it safe to write to pivot entity.
return !prop.optional;

Check warning on line 184 in packages/entity-generator/src/EntityGenerator.ts

View check run for this annotation

Codecov / codecov/patch

packages/entity-generator/src/EntityGenerator.ts#L184

Added line #L184 was not covered by tests
});

if (isReadOnly && !readOnlyPivotTables) {
continue;

Check warning on line 188 in packages/entity-generator/src/EntityGenerator.ts

View check run for this annotation

Codecov / codecov/patch

packages/entity-generator/src/EntityGenerator.ts#L188

Added line #L188 was not covered by tests
}

// If this now proven pivot entity has persistent props other than the fixed order column,
// output it, by considering it as a referenced one.
if (otherProps.length > 0) {
this.referencedEntities.add(meta);
}
}

meta.pivotTable = true;
const owner = metadata.find(m => m.className === meta.relations[0].type);

if (!owner) {
continue;

Check warning on line 202 in packages/entity-generator/src/EntityGenerator.ts

View check run for this annotation

Codecov / codecov/patch

packages/entity-generator/src/EntityGenerator.ts#L202

Added line #L202 was not covered by tests
}

const name = this.namingStrategy.columnNameToProperty(meta.tableName.replace(new RegExp('^' + owner.tableName + '_'), ''));
const ownerProp = {
name,
kind: ReferenceKind.MANY_TO_MANY,
pivotTable: meta.tableName,
type: meta.relations[1].type,
joinColumns: meta.relations[0].fieldNames,
inverseJoinColumns: meta.relations[1].fieldNames,
} as EntityProperty;

if (this.referencedEntities.has(meta)) {
ownerProp.pivotEntity = meta.className;
}
if (fixedOrderColumn) {
ownerProp.fixedOrder = true;
ownerProp.fixedOrderColumn = fixedOrderColumn;

Check warning on line 220 in packages/entity-generator/src/EntityGenerator.ts

View check run for this annotation

Codecov / codecov/patch

packages/entity-generator/src/EntityGenerator.ts#L219-L220

Added lines #L219 - L220 were not covered by tests
}
if (isReadOnly) {
ownerProp.persist = false;

Check warning on line 223 in packages/entity-generator/src/EntityGenerator.ts

View check run for this annotation

Codecov / codecov/patch

packages/entity-generator/src/EntityGenerator.ts#L223

Added line #L223 was not covered by tests
}

owner.addProperty(ownerProp);
}
}

Expand All @@ -169,6 +238,7 @@ export class EntityGenerator {
referencedTableName: meta.tableName,
referencedColumnNames: Utils.flatten(targetMeta.getPrimaryProps().map(pk => pk.fieldNames)),
mappedBy: prop.name,
persist: prop.persist,
} as EntityProperty;

if (prop.kind === ReferenceKind.MANY_TO_ONE) {
Expand Down
7 changes: 7 additions & 0 deletions packages/entity-generator/src/SourceFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,13 @@ export class SourceFile {
} else {
options.inverseJoinColumns = `[${prop.inverseJoinColumns.map(this.quote).join(', ')}]`;
}

if (prop.fixedOrder) {
options.fixedOrder = true;

Check warning on line 370 in packages/entity-generator/src/SourceFile.ts

View check run for this annotation

Codecov / codecov/patch

packages/entity-generator/src/SourceFile.ts#L370

Added line #L370 was not covered by tests
if (prop.fixedOrderColumn && prop.fixedOrderColumn !== this.namingStrategy.referenceColumnName()) {
options.fixedOrderColumn = this.quote(prop.fixedOrderColumn);

Check warning on line 372 in packages/entity-generator/src/SourceFile.ts

View check run for this annotation

Codecov / codecov/patch

packages/entity-generator/src/SourceFile.ts#L372

Added line #L372 was not covered by tests
}
}
}

protected getOneToManyDecoratorOptions(options: Dictionary, prop: EntityProperty) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ export class FooBar2 {
blob2?: Buffer;
array?: string;
objectProperty?: any;
fooParam2 = new Collection<FooBaz2>(this);
fooBarInverse?: Ref<Test2>;
barInverse = new Collection<FooParam2>(this);
barsInverse = new Collection<Test2>(this);
Expand Down Expand Up @@ -457,13 +458,22 @@ export const FooBar2Schema = new EntitySchema({
blob2: { type: 'Buffer', length: 65535, nullable: true },
array: { type: 'string', columnType: 'text', nullable: true },
objectProperty: { type: 'any', columnType: 'json', nullable: true },
fooParam2: {
kind: 'm:n',
entity: () => FooBaz2,
pivotTable: 'foo_param2',
pivotEntity: () => FooParam2,
joinColumn: 'bar_id',
inverseJoinColumn: 'baz_id',
},
fooBarInverse: { kind: '1:1', entity: () => Test2, ref: true, mappedBy: 'fooBar' },
barInverse: { kind: '1:m', entity: () => FooParam2, mappedBy: 'bar' },
barsInverse: { kind: 'm:n', entity: () => Test2, mappedBy: 'bars' },
},
});
",
"import { Collection, EntitySchema, OptionalProps } from '@mikro-orm/core';
import { FooBar2 } from './FooBar2';
import { FooParam2 } from './FooParam2';

export class FooBaz2 {
Expand All @@ -472,6 +482,7 @@ export class FooBaz2 {
name!: string;
version!: Date;
bazInverse = new Collection<FooParam2>(this);
fooParam2Inverse = new Collection<FooBar2>(this);
}

export const FooBaz2Schema = new EntitySchema({
Expand All @@ -481,6 +492,7 @@ export const FooBaz2Schema = new EntitySchema({
name: { type: 'string', length: 255 },
version: { type: 'Date', length: 3, defaultRaw: \`current_timestamp(3)\` },
bazInverse: { kind: '1:m', entity: () => FooParam2, mappedBy: 'baz' },
fooParam2Inverse: { kind: 'm:n', entity: () => FooBar2, mappedBy: 'fooParam2' },
},
});
",
Expand Down Expand Up @@ -1032,8 +1044,9 @@ export class Dummy2 {

}
",
"import { Entity, OneToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
"import { Collection, Entity, ManyToMany, OneToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
import { FooBaz2 } from './FooBaz2';
import { FooParam2 } from './FooParam2';

@Entity()
export class FooBar2 {
Expand Down Expand Up @@ -1070,6 +1083,9 @@ export class FooBar2 {
@Property({ columnType: 'json', nullable: true })
objectProperty?: any;

@ManyToMany({ entity: () => FooBaz2, pivotTable: 'foo_param2', pivotEntity: () => FooParam2, joinColumn: 'bar_id', inverseJoinColumn: 'baz_id' })
fooParam2 = new Collection<FooBaz2>(this);

}
",
"import { Entity, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
Expand Down Expand Up @@ -1632,6 +1648,9 @@ export class FooBar2 {
@Property({ columnType: 'json', nullable: true })
objectProperty?: any;

@ManyToMany({ entity: () => FooBaz2, pivotTable: 'foo_param2', pivotEntity: () => FooParam2, joinColumn: 'bar_id', inverseJoinColumn: 'baz_id' })
fooParam2 = new Collection<FooBaz2>(this);

@OneToOne({ entity: () => Test2, mappedBy: 'fooBar' })
fooBarInverse?: Test2;

Expand All @@ -1643,7 +1662,8 @@ export class FooBar2 {

}
",
"import { Collection, Entity, OneToMany, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
"import { Collection, Entity, ManyToMany, OneToMany, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
import { FooBar2 } from './FooBar2';
import { FooParam2 } from './FooParam2';

@Entity()
Expand All @@ -1663,6 +1683,9 @@ export class FooBaz2 {
@OneToMany({ entity: () => FooParam2, mappedBy: 'baz' })
bazInverse = new Collection<FooParam2>(this);

@ManyToMany({ entity: () => FooBar2, mappedBy: 'fooParam2' })
fooParam2Inverse = new Collection<FooBar2>(this);

}
",
"import { Entity, ManyToOne, OptionalProps, PrimaryKeyProp, Property } from '@mikro-orm/core';
Expand Down Expand Up @@ -2230,6 +2253,9 @@ export class FooBar2 {
@Property({ columnType: 'json', nullable: true })
objectProperty?: any;

@ManyToMany({ entity: () => FooBaz2, pivotTable: 'foo_param2', pivotEntity: () => FooParam2, joinColumn: 'bar_id', inverseJoinColumn: 'baz_id' })
fooParam2 = new Collection<FooBaz2>(this);

@OneToOne({ entity: () => Test2, ref: true, mappedBy: 'fooBar' })
fooBarInverse?: Ref<Test2>;

Expand All @@ -2241,7 +2267,8 @@ export class FooBar2 {

}
",
"import { Collection, Entity, OneToMany, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
"import { Collection, Entity, ManyToMany, OneToMany, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
import { FooBar2 } from './FooBar2';
import { FooParam2 } from './FooParam2';

@Entity()
Expand All @@ -2261,6 +2288,9 @@ export class FooBaz2 {
@OneToMany({ entity: () => FooParam2, mappedBy: 'baz' })
bazInverse = new Collection<FooParam2>(this);

@ManyToMany({ entity: () => FooBar2, mappedBy: 'fooParam2' })
fooParam2Inverse = new Collection<FooBar2>(this);

}
",
"import { Entity, ManyToOne, OptionalProps, PrimaryKeyProp, Property, Ref } from '@mikro-orm/core';
Expand Down Expand Up @@ -2740,8 +2770,9 @@ export class Dummy2 {

}
",
"import { Entity, OneToOne, OptionalProps, PrimaryKey, Property, Ref } from '@mikro-orm/core';
"import { Collection, Entity, ManyToMany, OneToOne, OptionalProps, PrimaryKey, Property, Ref } from '@mikro-orm/core';
import { FooBaz2 } from './FooBaz2.js';
import { FooParam2 } from './FooParam2.js';

@Entity()
export class FooBar2 {
Expand Down Expand Up @@ -2778,6 +2809,9 @@ export class FooBar2 {
@Property({ columnType: 'json', nullable: true })
objectProperty?: any;

@ManyToMany({ entity: () => FooBaz2, pivotTable: 'foo_param2', pivotEntity: () => FooParam2, joinColumn: 'bar_id', inverseJoinColumn: 'baz_id' })
fooParam2 = new Collection<FooBaz2>(this);

}
",
"import { Entity, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
Expand Down Expand Up @@ -3303,8 +3337,9 @@ export class Dummy2 {

}
",
"import { Entity, OneToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
"import { Collection, Entity, ManyToMany, OneToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
import { FooBaz2 } from './FooBaz2';
import { FooParam2 } from './FooParam2';

@Entity()
export class FooBar2 {
Expand Down Expand Up @@ -3341,6 +3376,9 @@ export class FooBar2 {
@Property({ columnType: 'json', nullable: true })
objectProperty?: any;

@ManyToMany({ entity: () => FooBaz2, pivotTable: 'foo_param2', pivotEntity: () => FooParam2, joinColumn: 'bar_id', inverseJoinColumn: 'baz_id' })
fooParam2 = new Collection<FooBaz2>(this);

}
",
"import { Entity, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
Expand Down
Loading

0 comments on commit 635efd8

Please sign in to comment.