Skip to content

Commit

Permalink
feat(core): add discovery hooks onMetadata and afterDiscovered (#…
Browse files Browse the repository at this point in the history
…4799)

### `onMetadata` hook

Sometimes you might want to alter some behavior of the ORM on metadata
level. You can use the `onMetadata` hook to modify the metadata. Let's
say you want to use your entities with different drivers, and you want
to use some driver specific feature. Using the `onMetadata` hook, you
can modify the metadata dynamically to fit the drivers requirements.

The hook will be executed before the internal process of filling
defaults, so you can think of it as modifying the property options in
your entity definitions, they will be respected e.g. when inferring the
column type.

> The hook can be async, but it will be awaited only if you use the
async `MikroORM.init()` method, not with the `MikroORM.initSync()`.

```ts
import { EntityMetadata, MikroORM, Platform } from '@mikro-orm/sqlite';

const orm = await MikroORM.init({
  // ...
  discovery: {
    onMetadata(meta: EntityMetadata, platform: Platform) {
      // sqlite driver does not support schemas
      delete meta.schema;
    },
  },
});
```

Alternatively, you can also use the `afterDiscovered` hook, which is
fired after the discovery process ends. You can access all the metadata
there, and add or remove them as you wish.

```ts
import { EntityMetadata, MikroORM, Platform } from '@mikro-orm/sqlite';

const orm = await MikroORM.init({
  // ...
  discovery: {
    afterDiscovered(storage: MetadataStorage) {
      // ignore FooBar entity in schema generator
      storage.reset('FooBar');
    },
  },
});
```
  • Loading branch information
B4nan committed Nov 5, 2023
1 parent ec65001 commit 5f6c4f8
Show file tree
Hide file tree
Showing 7 changed files with 190 additions and 24 deletions.
76 changes: 57 additions & 19 deletions docs/docs/configuration.md
Expand Up @@ -56,25 +56,7 @@ MikroORM.init({
Read more about this in [Metadata Providers](metadata-providers.md) sections.

## Extensions

Since v5.6, the ORM extensions like `SchemaGenerator`, `Migrator` or `EntityGenerator` can be registered via the `extensions` config option. This will be the only supported way to have the shortcuts like `orm.migrator` available in v6, so we no longer need to dynamically require those dependencies or specify them as optional peer dependencies (both of those things cause issues with various bundling tools like Webpack, or those used in Remix or Next.js).

```ts
import { defineConfig } from '@mikro-orm/postgresql';
import { Migrator } from '@mikro-orm/migrations';
import { EntityGenerator } from '@mikro-orm/entity-generator';
import { SeedManager } from '@mikro-orm/seeder';

export default defineConfig({
dbName: 'test',
extensions: [Migrator, EntityGenerator, SeedManager],
});
```

> The `SchemaGenerator` (as well as `MongoSchemaGenerator`) is registered automatically as it does not require any 3rd party dependencies to be installed.
## Adjusting default type mapping
### Adjusting default type mapping

Since v5.2 we can alter how the ORM picks the default mapped type representation based on the inferred type of a property. One example is a mapping of `foo: string` to `varchar(255)`. If we wanted to change this default to a `text` type in postgres, we can use the `discover.getMappedType` callback:

Expand All @@ -95,6 +77,62 @@ const orm = await MikroORM.init({
});
```

### `onMetadata` hook

Sometimes you might want to alter some behavior of the ORM on metadata level. You can use the `onMetadata` hook to modify the metadata. Let's say you want to use your entities with different drivers, and you want to use some driver specific feature. Using the `onMetadata` hook, you can modify the metadata dynamically to fit the drivers requirements.

The hook will be executed before the internal process of filling defaults, so you can think of it as modifying the property options in your entity definitions, they will be respected e.g. when inferring the column type.

> The hook can be async, but it will be awaited only if you use the async `MikroORM.init()` method, not with the `MikroORM.initSync()`.
```ts
import { EntityMetadata, MikroORM, Platform } from '@mikro-orm/sqlite';

const orm = await MikroORM.init({
// ...
discovery: {
onMetadata(meta: EntityMetadata, platform: Platform) {
// sqlite driver does not support schemas
delete meta.schema;
},
},
});
```

Alternatively, you can also use the `afterDiscovered` hook, which is fired after the discovery process ends. You can access all the metadata there, and add or remove them as you wish.

```ts
import { EntityMetadata, MikroORM, Platform } from '@mikro-orm/sqlite';

const orm = await MikroORM.init({
// ...
discovery: {
afterDiscovered(storage: MetadataStorage) {
// ignore FooBar entity in schema generator
storage.reset('FooBar');
},
},
});
```

## Extensions

Since v5.6, the ORM extensions like `SchemaGenerator`, `Migrator` or `EntityGenerator` can be registered via the `extensions` config option. This will be the only supported way to have the shortcuts like `orm.migrator` available in v6, so we no longer need to dynamically require those dependencies or specify them as optional peer dependencies (both of those things cause issues with various bundling tools like Webpack, or those used in Remix or Next.js).

```ts
import { defineConfig } from '@mikro-orm/postgresql';
import { Migrator } from '@mikro-orm/migrations';
import { EntityGenerator } from '@mikro-orm/entity-generator';
import { SeedManager } from '@mikro-orm/seeder';

export default defineConfig({
dbName: 'test',
extensions: [Migrator, EntityGenerator, SeedManager],
});
```

> The `SchemaGenerator` (as well as `MongoSchemaGenerator`) is registered automatically as it does not require any 3rd party dependencies to be installed.
## Driver

To select driver, you can either use `type` option, or provide the driver class reference.
Expand Down
20 changes: 18 additions & 2 deletions packages/core/src/metadata/MetadataDiscovery.ts
Expand Up @@ -42,24 +42,40 @@ export class MetadataDiscovery {
const startTime = Date.now();
this.logger.log('discovery', `ORM entity discovery started, using ${colors.cyan(this.metadataProvider.constructor.name)}`);
await this.findEntities(preferTsNode);

for (const meta of this.discovered) {
await this.config.get('discovery').onMetadata?.(meta, this.platform);
}

this.processDiscoveredEntities(this.discovered);

const diff = Date.now() - startTime;
this.logger.log('discovery', `- entity discovery finished, found ${colors.green('' + this.discovered.length)} entities, took ${colors.green(`${diff} ms`)}`);

return this.mapDiscoveredEntities();
const storage = this.mapDiscoveredEntities();
await this.config.get('discovery').afterDiscovered?.(storage, this.platform);

return storage;
}

discoverSync(preferTsNode = true): MetadataStorage {
const startTime = Date.now();
this.logger.log('discovery', `ORM entity discovery started, using ${colors.cyan(this.metadataProvider.constructor.name)} in sync mode`);
this.findEntities(preferTsNode, true);

for (const meta of this.discovered) {
this.config.get('discovery').onMetadata?.(meta, this.platform);
}

this.processDiscoveredEntities(this.discovered);

const diff = Date.now() - startTime;
this.logger.log('discovery', `- entity discovery finished, found ${colors.green('' + this.discovered.length)} entities, took ${colors.green(`${diff} ms`)}`);

return this.mapDiscoveredEntities();
const storage = this.mapDiscoveredEntities();
this.config.get('discovery').afterDiscovered?.(storage, this.platform);

return storage;
}

private mapDiscoveredEntities(): MetadataStorage {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/metadata/MetadataStorage.ts
Expand Up @@ -99,4 +99,10 @@ export class MetadataStorage {
.forEach(meta => EntityHelper.decorate(meta, em));
}

* [Symbol.iterator](): IterableIterator<EntityMetadata> {
for (const meta of Object.values(this.metadata)) {
yield meta;
}
}

}
6 changes: 3 additions & 3 deletions packages/core/src/typings.ts
Expand Up @@ -417,13 +417,13 @@ export class EntityMetadata<T = any> {
Object.assign(this, meta);
}

addProperty(prop: EntityProperty<T>, sync = true) {
addProperty(prop: Partial<EntityProperty<T>>, sync = true) {
if (prop.pivotTable && !prop.pivotEntity) {
prop.pivotEntity = prop.pivotTable;
}

this.properties[prop.name] = prop;
this.propertyOrder.set(prop.name, this.props.length);
this.properties[prop.name!] = prop as EntityProperty<T>;
this.propertyOrder.set(prop.name!, this.props.length);

/* istanbul ignore next */
if (sync) {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/utils/Configuration.ts
Expand Up @@ -16,6 +16,7 @@ import type {
IPrimaryKey,
MaybePromise,
MigrationObject,
EntityMetadata,
} from '../typings';
import { ObjectHydrator } from '../hydration';
import { NullHighlighter } from '../utils/NullHighlighter';
Expand Down Expand Up @@ -501,6 +502,8 @@ export interface MikroORMOptions<D extends IDatabaseDriver = IDatabaseDriver> ex
inferDefaultValues?: boolean;
getMappedType?: (type: string, platform: Platform) => Type<unknown> | undefined;
checkDuplicateEntities?: boolean;
onMetadata?: (meta: EntityMetadata, platform: Platform) => MaybePromise<void>;
afterDiscovered?: (storage: MetadataStorage, platform: Platform) => MaybePromise<void>;
};
driver?: { new(config: Configuration): D };
driverOptions: Dictionary;
Expand Down
@@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`discovery hooks 1`] = `
"pragma foreign_keys = off;
create table \`person_2\` (\`id\` integer not null primary key autoincrement, \`name\` text not null, \`version\` integer not null default 1);
create table \`phone_2\` (\`id\` integer not null primary key autoincrement, \`number\` text not null, \`version\` integer not null default 1);
create table \`person_phone\` (\`person_id\` integer not null, \`phone_id\` integer not null, constraint \`person_phone_person_id_foreign\` foreign key(\`person_id\`) references \`person_2\`(\`id\`) on delete cascade on update cascade, constraint \`person_phone_phone_id_foreign\` foreign key(\`phone_id\`) references \`phone_2\`(\`id\`) on delete cascade on update cascade, primary key (\`person_id\`, \`phone_id\`));
create index \`person_phone_person_id_index\` on \`person_phone\` (\`person_id\`);
create index \`person_phone_phone_id_index\` on \`person_phone\` (\`phone_id\`);
pragma foreign_keys = on;
"
`;
87 changes: 87 additions & 0 deletions tests/features/multiple-schemas/discovery-hooks.test.ts
@@ -0,0 +1,87 @@
import { Collection, Entity, ManyToMany, MetadataStorage, PrimaryKey, Property, ReferenceKind } from '@mikro-orm/core';
import { MikroORM } from '@mikro-orm/better-sqlite';

@Entity({ schema: 'staff', tableName: 'person' })
class Person {

@PrimaryKey({ nullable: true })
id?: number;

@Property()
name!: string;

@ManyToMany({ entity: () => 'Phone', owner: true, pivotTable: 'tic.person_phone', joinColumn: 'person_id', inverseJoinColumn: 'phone_id' })
phones = new Collection<Phone>(this);

}

@Entity({ schema: 'tic', tableName: 'phone' })
class Phone {

@PrimaryKey({ nullable: true })
id?: number;

@Property()
number!: string;

@ManyToMany({ entity: () => 'Person', mappedBy: (e: Person) => e.phones })
people: Collection<Person> = new Collection<Person>(this);

}

@Entity()
class FooBar {

@PrimaryKey({ nullable: true })
id?: number;

}

let orm: MikroORM;

beforeAll(async () => {
orm = await MikroORM.init({
entities: [Person, Phone, FooBar],
dbName: ':memory:',
discovery: {
onMetadata(meta) {
// sqlite driver does not support schemas
delete meta.schema;

meta.tableName += '_2';

for (const prop of meta.relations) {
if (prop.kind === ReferenceKind.MANY_TO_MANY && prop.owner && prop.pivotTable.includes('.')) {
prop.pivotTable = prop.pivotTable.split('.')[1];
}
}

meta.addProperty({ name: 'version', version: true, type: 'integer' });
},
afterDiscovered(storage: MetadataStorage) {
storage.reset('FooBar');
},
},
});
await orm.schema.createSchema();
});

afterAll(async () => {
await orm.close(true);
});

test('discovery hooks', async () => {
await expect(orm.schema.getCreateSchemaSQL()).resolves.toMatchSnapshot();

const person = new Person();
person.name = 'John Wick';
const phone = new Phone();
phone.number = '666555444';
person.phones.add(phone);
await orm.em.persistAndFlush(person);

orm.em.clear();
const [personLoaded] = await orm.em.find(Person, {}, { populate: ['phones'] });
personLoaded.phones.remove(personLoaded.phones[0]);
await orm.em.flush();
});

0 comments on commit 5f6c4f8

Please sign in to comment.