From 5f348d88f4a6cdb8b5af3b2aac3dec2be785edcb Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 11 Jun 2024 21:00:36 +0530 Subject: [PATCH] Initial implementation of DML to Mikro ORM entity generation (#7667) * feat: initial implementation of DML to Mikro ORM entity generation CORE-2279 * test: fix breaking tests * refactor: cleanup code for defining properties and relationships * feat: finish initial implementation of relationships --- .../src/dml/__tests__/entity_builder.spec.ts | 413 +++++++++++++++++- packages/core/utils/src/dml/entity.ts | 8 +- packages/core/utils/src/dml/entity_builder.ts | 10 + .../dml/helpers/create_mikro_orm_entity.ts | 207 +++++++++ .../src/dml/relations/has_one_through_many.ts | 6 + packages/core/utils/src/dml/types.ts | 7 +- 6 files changed, 630 insertions(+), 21 deletions(-) create mode 100644 packages/core/utils/src/dml/helpers/create_mikro_orm_entity.ts create mode 100644 packages/core/utils/src/dml/relations/has_one_through_many.ts diff --git a/packages/core/utils/src/dml/__tests__/entity_builder.spec.ts b/packages/core/utils/src/dml/__tests__/entity_builder.spec.ts index 8de832eeb0..44def05e21 100644 --- a/packages/core/utils/src/dml/__tests__/entity_builder.spec.ts +++ b/packages/core/utils/src/dml/__tests__/entity_builder.spec.ts @@ -1,8 +1,14 @@ import { expectTypeOf } from "expect-type" -import { Infer, MikroORMEntity } from "../types" +import { MetadataStorage } from "@mikro-orm/core" import { EntityBuilder } from "../entity_builder" +import { createMikrORMEntity } from "../helpers/create_mikro_orm_entity" +import { EntityConstructor } from "../types" describe("Entity builder", () => { + beforeEach(() => { + MetadataStorage.clear() + }) + test("define an entity", () => { const model = new EntityBuilder() const user = model.define("user", { @@ -11,14 +17,112 @@ describe("Entity builder", () => { email: model.text(), }) - expectTypeOf>>().toMatchTypeOf<{ + const User = createMikrORMEntity(user) + expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string email: string }>() + + const metaData = MetadataStorage.getMetadataFromDecorator(User) + expect(metaData.className).toEqual("User") + expect(metaData.path).toEqual("User") + expect(metaData.properties).toEqual({ + id: { + reference: "scalar", + type: "number", + columnType: "integer", + name: "id", + nullable: false, + getter: false, + setter: false, + }, + username: { + reference: "scalar", + type: "string", + columnType: "text", + name: "username", + nullable: false, + getter: false, + setter: false, + }, + email: { + reference: "scalar", + type: "string", + columnType: "text", + name: "email", + nullable: false, + getter: false, + setter: false, + }, + }) }) - test("define an entity with relationships", () => { + test("define an entity with enum property", () => { + const model = new EntityBuilder() + const user = model.define("user", { + id: model.number(), + username: model.text(), + email: model.text(), + role: model.enum(["moderator", "admin", "guest"]), + }) + + const User = createMikrORMEntity(user) + expectTypeOf(new User()).toMatchTypeOf<{ + id: number + username: string + email: string + role: "moderator" | "admin" | "guest" + }>() + + const metaData = MetadataStorage.getMetadataFromDecorator(User) + + expect(metaData.className).toEqual("User") + expect(metaData.path).toEqual("User") + expect(metaData.properties).toEqual({ + id: { + reference: "scalar", + type: "number", + columnType: "integer", + name: "id", + nullable: false, + getter: false, + setter: false, + }, + username: { + reference: "scalar", + type: "string", + columnType: "text", + name: "username", + nullable: false, + getter: false, + setter: false, + }, + email: { + reference: "scalar", + type: "string", + columnType: "text", + name: "email", + nullable: false, + getter: false, + setter: false, + }, + role: { + reference: "scalar", + enum: true, + items: expect.any(Function), + nullable: false, + name: "role", + }, + }) + expect(metaData.properties["role"].items()).toEqual([ + "moderator", + "admin", + "guest", + ]) + }) + + test("define hasMany relationship", () => { const model = new EntityBuilder() const email = model.define("email", { email: model.text(), @@ -31,18 +135,51 @@ describe("Entity builder", () => { emails: model.hasMany(() => email), }) - expectTypeOf>>().toEqualTypeOf<{ + const User = createMikrORMEntity(user) + expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string - emails: MikroORMEntity<{ email: string; isVerified: boolean }> + emails: EntityConstructor<{ email: string; isVerified: boolean }> }>() + + const metaData = MetadataStorage.getMetadataFromDecorator(User) + expect(metaData.className).toEqual("User") + expect(metaData.path).toEqual("User") + expect(metaData.properties).toEqual({ + id: { + reference: "scalar", + type: "number", + columnType: "integer", + name: "id", + nullable: false, + getter: false, + setter: false, + }, + username: { + reference: "scalar", + type: "string", + columnType: "text", + name: "username", + nullable: false, + getter: false, + setter: false, + }, + emails: { + reference: "1:m", + name: "emails", + entity: "Email", + cascade: ["persist"], + mappedBy: expect.any(Function), + orphanRemoval: true, + }, + }) }) - test("define an entity with recursive relationships", () => { + test("define hasMany and hasOneThroughMany relationships", () => { const model = new EntityBuilder() const order = model.define("order", { amount: model.number(), - user: model.hasOne(() => user), + user: model.hasOneThroughMany(() => user), }) const user = model.define("user", { @@ -51,16 +188,272 @@ describe("Entity builder", () => { orders: model.hasMany(() => order), }) - expectTypeOf>>().toMatchTypeOf<{ + const User = createMikrORMEntity(user) + const Order = createMikrORMEntity(order) + expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string - orders: MikroORMEntity<{ + orders: EntityConstructor<{ amount: number - user: MikroORMEntity<{ + user: EntityConstructor<{ id: number username: string }> }> }>() + + const metaData = MetadataStorage.getMetadataFromDecorator(User) + + expect(metaData.className).toEqual("User") + expect(metaData.path).toEqual("User") + expect(metaData.properties).toEqual({ + id: { + reference: "scalar", + type: "number", + columnType: "integer", + name: "id", + nullable: false, + getter: false, + setter: false, + }, + username: { + reference: "scalar", + type: "string", + columnType: "text", + name: "username", + nullable: false, + getter: false, + setter: false, + }, + orders: { + reference: "1:m", + name: "orders", + cascade: ["persist"], + mappedBy: expect.any(Function), + orphanRemoval: true, + entity: "Order", + }, + }) + + const orderMetaDdata = MetadataStorage.getMetadataFromDecorator(Order) + expect(orderMetaDdata.className).toEqual("Order") + expect(orderMetaDdata.path).toEqual("Order") + expect(orderMetaDdata.properties).toEqual({ + amount: { + reference: "scalar", + type: "number", + columnType: "integer", + name: "amount", + nullable: false, + getter: false, + setter: false, + }, + user: { + reference: "m:1", + name: "user", + entity: "User", + persist: false, + }, + user_id: { + columnType: "text", + entity: "User", + fieldName: "user_id", + mapToPk: true, + name: "user_id", + nullable: false, + onDelete: "cascade", + reference: "m:1", + }, + }) + }) + + test("define hasOne relationship on both sides", () => { + const model = new EntityBuilder() + const user = model.define("user", { + id: model.number(), + email: model.text(), + profile: model.hasOne(() => profile), + }) + + const profile = model.define("profile", { + id: model.number(), + user_id: model.number(), + user: model.hasOne(() => user), + }) + + const User = createMikrORMEntity(user) + const Profile = createMikrORMEntity(profile) + + expectTypeOf(new User()).toMatchTypeOf<{ + id: number + email: string + profile: EntityConstructor<{ + id: number + user_id: number + user: EntityConstructor<{ + id: number + email: string + }> + }> + }>() + + const metaData = MetadataStorage.getMetadataFromDecorator(User) + + expect(metaData.className).toEqual("User") + expect(metaData.path).toEqual("User") + expect(metaData.properties).toEqual({ + id: { + reference: "scalar", + type: "number", + columnType: "integer", + name: "id", + nullable: false, + getter: false, + setter: false, + }, + email: { + reference: "scalar", + type: "string", + columnType: "text", + name: "email", + nullable: false, + getter: false, + setter: false, + }, + profile: { + name: "profile", + cascade: ["persist"], + entity: "Profile", + nullable: false, + onDelete: "cascade", + owner: true, + reference: "1:1", + }, + }) + + const profileMetadata = MetadataStorage.getMetadataFromDecorator(Profile) + expect(profileMetadata.className).toEqual("Profile") + expect(profileMetadata.path).toEqual("Profile") + expect(profileMetadata.properties).toEqual({ + id: { + reference: "scalar", + type: "number", + columnType: "integer", + name: "id", + nullable: false, + getter: false, + setter: false, + }, + user: { + cascade: ["persist"], + entity: "User", + name: "user", + nullable: false, + onDelete: "cascade", + owner: true, + reference: "1:1", + }, + user_id: { + columnType: "integer", + getter: false, + name: "user_id", + nullable: false, + reference: "scalar", + setter: false, + type: "number", + }, + }) + }) + + test("define manyToMany relationships", () => { + const model = new EntityBuilder() + const user = model.define("user", { + id: model.number(), + email: model.text(), + books: model.manyToMany(() => book), + }) + + const book = model.define("book", { + id: model.number(), + title: model.text(), + authors: model.manyToMany(() => user), + }) + + const User = createMikrORMEntity(user) + const Book = createMikrORMEntity(book) + + expectTypeOf(new User()).toMatchTypeOf<{ + id: number + email: string + books: EntityConstructor<{ + id: number + title: string + authors: EntityConstructor<{ + id: number + email: string + }> + }> + }>() + + const metaData = MetadataStorage.getMetadataFromDecorator(User) + + expect(metaData.className).toEqual("User") + expect(metaData.path).toEqual("User") + expect(metaData.properties).toEqual({ + id: { + reference: "scalar", + type: "number", + columnType: "integer", + name: "id", + nullable: false, + getter: false, + setter: false, + }, + email: { + reference: "scalar", + type: "string", + columnType: "text", + name: "email", + nullable: false, + getter: false, + setter: false, + }, + books: { + name: "books", + entity: "Book", + mappedBy: expect.any(Function), + reference: "m:n", + }, + }) + + const bookMetaData = MetadataStorage.getMetadataFromDecorator(Book) + expect(bookMetaData.className).toEqual("Book") + expect(bookMetaData.path).toEqual("Book") + expect(bookMetaData.properties).toEqual({ + id: { + reference: "scalar", + type: "number", + columnType: "integer", + name: "id", + nullable: false, + getter: false, + setter: false, + }, + authors: { + entity: "User", + name: "authors", + reference: "m:n", + mappedBy: expect.any(Function), + }, + title: { + columnType: "text", + getter: false, + name: "title", + nullable: false, + reference: "scalar", + setter: false, + type: "string", + }, + }) }) }) diff --git a/packages/core/utils/src/dml/entity.ts b/packages/core/utils/src/dml/entity.ts index 6957bffa36..fb793bbadd 100644 --- a/packages/core/utils/src/dml/entity.ts +++ b/packages/core/utils/src/dml/entity.ts @@ -3,11 +3,5 @@ import { RelationshipType, SchemaType } from "./types" export class DmlEntity< Schema extends Record | RelationshipType> > { - #name: string - #schema: Schema - - constructor(name: string, schema: Schema) { - this.#name = name - this.#schema = schema - } + constructor(public name: string, public schema: Schema) {} } diff --git a/packages/core/utils/src/dml/entity_builder.ts b/packages/core/utils/src/dml/entity_builder.ts index ad11ae5597..06364b2a39 100644 --- a/packages/core/utils/src/dml/entity_builder.ts +++ b/packages/core/utils/src/dml/entity_builder.ts @@ -8,6 +8,8 @@ import { BooleanSchema } from "./schema/boolean" import { DateTimeSchema } from "./schema/date_time" import { ManyToMany } from "./relations/many_to_many" import { RelationshipType, SchemaType } from "./types" +import { EnumSchema } from "./schema/enum" +import { HasOneThroughMany } from "./relations/has_one_through_many" export class EntityBuilder { define< @@ -36,6 +38,10 @@ export class EntityBuilder { return new JSONSchema() } + enum(values: Values[]) { + return new EnumSchema(values) + } + hasOne(entityBuilder: T, options?: Record) { return new HasOne(entityBuilder, options || {}) } @@ -44,6 +50,10 @@ export class EntityBuilder { return new HasMany(entityBuilder, options || {}) } + hasOneThroughMany(entityBuilder: T, options?: Record) { + return new HasOneThroughMany(entityBuilder, options || {}) + } + manyToMany(entityBuilder: T, options?: Record) { return new ManyToMany(entityBuilder, options || {}) } diff --git a/packages/core/utils/src/dml/helpers/create_mikro_orm_entity.ts b/packages/core/utils/src/dml/helpers/create_mikro_orm_entity.ts new file mode 100644 index 0000000000..156d5e825f --- /dev/null +++ b/packages/core/utils/src/dml/helpers/create_mikro_orm_entity.ts @@ -0,0 +1,207 @@ +import { + Entity, + OneToMany, + Property, + OneToOne, + ManyToMany, + Enum, + Cascade, + ManyToOne, +} from "@mikro-orm/core" +import { DmlEntity } from "../entity" +import { camelToSnakeCase, pluralize } from "../../common" +import { upperCaseFirst } from "../../common/upper-case-first" +import type { + Infer, + SchemaType, + KnownDataTypes, + RelationshipType, + SchemaMetadata, + EntityConstructor, + RelationshipMetadata, +} from "../types" + +/** + * DML entity data types to PostgreSQL data types via + * Mikro ORM + */ +const COLUMN_TYPES: { + [K in KnownDataTypes]: string +} = { + boolean: "boolean", + dateTime: "timestamptz", + number: "integer", + string: "text", + json: "jsonb", + enum: "enum", // ignore for now +} + +/** + * DML entity data types to Mikro ORM property + * types + */ +const PROPERTY_TYPES: { + [K in KnownDataTypes]: string +} = { + boolean: "boolean", + dateTime: "date", + number: "number", + string: "string", + json: "any", + enum: "enum", // ignore for now +} + +/** + * Defines a DML entity schema field as a Mikro ORM property + */ +function defineProperty( + MikroORMEntity: EntityConstructor, + field: SchemaMetadata +) { + /** + * Defining an enum property + */ + if (field.dataType.name === "enum") { + Enum({ + items: () => field.dataType.options!.choices, + nullable: field.nullable, + })(MikroORMEntity.prototype, field.fieldName) + return + } + + const columnType = COLUMN_TYPES[field.dataType.name] + const propertyType = PROPERTY_TYPES[field.dataType.name] + + /** + * @todo: Add support for default value + */ + Property({ columnType, type: propertyType, nullable: field.nullable })( + MikroORMEntity.prototype, + field.fieldName + ) +} + +/** + * Defines a DML entity schema field as a Mikro ORM relationship + */ +function defineRelationship( + MikroORMEntity: EntityConstructor, + relationship: RelationshipMetadata +) { + /** + * Defining relationships + */ + const relatedEntity = + typeof relationship.entity === "function" + ? (relationship.entity() as unknown) + : undefined + + /** + * Since we don't type-check relationships, we should validate + * them at runtime + */ + if (!relatedEntity) { + throw new Error( + `Invalid relationship reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to define the relationship using a factory function` + ) + } + + /** + * Ensure the return value is a DML entity instance + */ + if (!(relatedEntity instanceof DmlEntity)) { + throw new Error( + `Invalid relationship reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to return a DML entity from the relationship callback` + ) + } + + const relatedModelName = upperCaseFirst(relatedEntity.name) + + /** + * Defining relationships + */ + switch (relationship.type) { + case "hasOne": + OneToOne({ + entity: relatedModelName, + cascade: [Cascade.PERSIST], // accept via options + owner: true, // accept via options + onDelete: "cascade", // accept via options + nullable: false, // accept via options + })(MikroORMEntity.prototype, relationship.name) + break + case "hasMany": + OneToMany({ + entity: relatedModelName, + cascade: [Cascade.PERSIST], // accept via options + orphanRemoval: true, + mappedBy: (related: any) => + related[camelToSnakeCase(MikroORMEntity.name)], // optionally via options, + })(MikroORMEntity.prototype, relationship.name) + break + case "hasOneThroughMany": + ManyToOne({ + entity: relatedModelName, + columnType: "text", // infer from primary key data-type + mapToPk: true, + nullable: false, // accept via options + fieldName: camelToSnakeCase(`${relationship.name}Id`), + onDelete: "cascade", // accept via options + })(MikroORMEntity.prototype, camelToSnakeCase(`${relationship.name}Id`)) + + ManyToOne({ + entity: relatedModelName, + persist: false, + })(MikroORMEntity.prototype, relationship.name) + break + case "manyToMany": + ManyToMany({ + entity: relatedModelName, + mappedBy: (related: any) => + related[camelToSnakeCase(MikroORMEntity.name)], // optionally via options, + })(MikroORMEntity.prototype, relationship.name) + break + } +} + +/** + * A helper function to define a Mikro ORM entity from a + * DML entity. + * @todo: Handle soft deleted indexes and filters + * @todo: Finalize if custom pivot entities are needed + */ +export function createMikrORMEntity< + T extends DmlEntity | RelationshipType>> +>(entity: T): Infer { + class MikroORMEntity {} + + const className = upperCaseFirst(entity.name) + const tableName = pluralize(camelToSnakeCase(className)) + + /** + * Assigning name to the class constructor, so that it matches + * the DML entity name + */ + Object.defineProperty(MikroORMEntity, "name", { + get: function () { + return className + }, + }) + + /** + * Processing schema fields + */ + Object.keys(entity.schema).forEach((property) => { + const field = entity.schema[property].parse(property) + if ("fieldName" in field) { + defineProperty(MikroORMEntity, field) + } else { + defineRelationship(MikroORMEntity, field) + } + }) + + /** + * Converting class to a MikroORM entity + */ + return Entity({ tableName })(MikroORMEntity) as Infer +} diff --git a/packages/core/utils/src/dml/relations/has_one_through_many.ts b/packages/core/utils/src/dml/relations/has_one_through_many.ts new file mode 100644 index 0000000000..87be0a882d --- /dev/null +++ b/packages/core/utils/src/dml/relations/has_one_through_many.ts @@ -0,0 +1,6 @@ +import { BaseRelationship } from "./base" +import { RelationshipMetadata } from "../types" + +export class HasOneThroughMany extends BaseRelationship { + protected relationshipType: RelationshipMetadata["type"] = "hasOneThroughMany" +} diff --git a/packages/core/utils/src/dml/types.ts b/packages/core/utils/src/dml/types.ts index 1bdf78e52c..33c4f37de7 100644 --- a/packages/core/utils/src/dml/types.ts +++ b/packages/core/utils/src/dml/types.ts @@ -10,7 +10,6 @@ export type KnownDataTypes = | "number" | "dateTime" | "json" - | "any" /** * The meta-data returned by the relationship parse @@ -18,7 +17,7 @@ export type KnownDataTypes = */ export type RelationshipMetadata = { name: string - type: "hasOne" | "hasMany" | "manyToMany" + type: "hasOne" | "hasMany" | "hasOneThroughMany" | "manyToMany" entity: unknown options: Record } @@ -66,7 +65,7 @@ export type RelationshipType = { * entities on the fly, we need a way to represent a type-safe * constructor and its instance properties. */ -export interface MikroORMEntity extends Function { +export interface EntityConstructor extends Function { new (): Props } @@ -74,7 +73,7 @@ export interface MikroORMEntity extends Function { * Helper to infer the schema type of a DmlEntity */ export type Infer = T extends DmlEntity - ? MikroORMEntity<{ + ? EntityConstructor<{ [K in keyof Schema]: Schema[K]["$dataType"] extends () => infer R ? Infer : Schema[K]["$dataType"]