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
This commit is contained in:
Harminder Virk
2024-06-11 21:00:36 +05:30
committed by GitHub
parent 0e731dbad0
commit 5f348d88f4
6 changed files with 630 additions and 21 deletions

View File

@@ -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<InstanceType<Infer<typeof user>>>().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<InstanceType<Infer<typeof user>>>().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<InstanceType<Infer<typeof user>>>().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",
},
})
})
})

View File

@@ -3,11 +3,5 @@ import { RelationshipType, SchemaType } from "./types"
export class DmlEntity<
Schema extends Record<string, SchemaType<any> | RelationshipType<any>>
> {
#name: string
#schema: Schema
constructor(name: string, schema: Schema) {
this.#name = name
this.#schema = schema
}
constructor(public name: string, public schema: Schema) {}
}

View File

@@ -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<const Values extends unknown>(values: Values[]) {
return new EnumSchema<Values>(values)
}
hasOne<T>(entityBuilder: T, options?: Record<string, any>) {
return new HasOne<T>(entityBuilder, options || {})
}
@@ -44,6 +50,10 @@ export class EntityBuilder {
return new HasMany<T>(entityBuilder, options || {})
}
hasOneThroughMany<T>(entityBuilder: T, options?: Record<string, any>) {
return new HasOneThroughMany<T>(entityBuilder, options || {})
}
manyToMany<T>(entityBuilder: T, options?: Record<string, any>) {
return new ManyToMany<T>(entityBuilder, options || {})
}

View File

@@ -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<any>,
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<any>,
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<Record<string, SchemaType<any> | RelationshipType<any>>>
>(entity: T): Infer<T> {
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<T>
}

View File

@@ -0,0 +1,6 @@
import { BaseRelationship } from "./base"
import { RelationshipMetadata } from "../types"
export class HasOneThroughMany<T> extends BaseRelationship<T> {
protected relationshipType: RelationshipMetadata["type"] = "hasOneThroughMany"
}

View File

@@ -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<string, any>
}
@@ -66,7 +65,7 @@ export type RelationshipType<T> = {
* entities on the fly, we need a way to represent a type-safe
* constructor and its instance properties.
*/
export interface MikroORMEntity<Props> extends Function {
export interface EntityConstructor<Props> extends Function {
new (): Props
}
@@ -74,7 +73,7 @@ export interface MikroORMEntity<Props> extends Function {
* Helper to infer the schema type of a DmlEntity
*/
export type Infer<T> = T extends DmlEntity<infer Schema>
? MikroORMEntity<{
? EntityConstructor<{
[K in keyof Schema]: Schema[K]["$dataType"] extends () => infer R
? Infer<R>
: Schema[K]["$dataType"]