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.
They are also now properly emitted.

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 Dec 1, 2023
1 parent 5c4b3a2 commit a56ed1e
Show file tree
Hide file tree
Showing 13 changed files with 4,796 additions and 87 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];
}
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;
}

// 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;
});

if (isReadOnly && !readOnlyPivotTables) {
continue;
}

// 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;
}
if (isReadOnly) {
ownerProp.persist = false;
}

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
12 changes: 12 additions & 0 deletions packages/entity-generator/src/SourceFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,11 @@ export class SourceFile {
assign('precision');
assign('scale');
}
if (prop.autoincrement &&
(!prop.primary || !['number', 'bigint'].includes(mappedType2.compareAsType()) || this.meta.getPrimaryProps().length > 1)
) {
options.autoincrement = true;
}
}

protected getManyToManyDecoratorOptions(options: Dictionary, prop: EntityProperty) {
Expand Down Expand Up @@ -365,6 +370,13 @@ export class SourceFile {
} else {
options.inverseJoinColumns = `[${prop.inverseJoinColumns.map(this.quote).join(', ')}]`;
}

if (prop.fixedOrder) {
options.fixedOrder = true;
if (prop.fixedOrderColumn && prop.fixedOrderColumn !== this.namingStrategy.referenceColumnName()) {
options.fixedOrderColumn = this.quote(prop.fixedOrderColumn);
}
}
}

protected getOneToManyDecoratorOptions(options: Dictionary, prop: EntityProperty) {
Expand Down
1 change: 1 addition & 0 deletions packages/knex/src/schema/DatabaseTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,7 @@ export class DatabaseTable {
defaultRaw: this.getPropertyDefaultValue(schemaHelper, column, type, true),
nullable: column.nullable,
primary: column.primary && persist,
autoincrement: column.autoincrement,
fieldName: column.name,
length: column.length,
precision: column.precision,
Expand Down
Loading

0 comments on commit a56ed1e

Please sign in to comment.