diff --git a/packages/core/types/src/dml/index.ts b/packages/core/types/src/dml/index.ts index 0032a6a792..133e748b6a 100644 --- a/packages/core/types/src/dml/index.ts +++ b/packages/core/types/src/dml/index.ts @@ -17,6 +17,7 @@ export type KnownDataTypes = | "number" | "bigNumber" | "dateTime" + | "array" | "json" | "id" diff --git a/packages/core/utils/src/dml/__tests__/array-property.spec.ts b/packages/core/utils/src/dml/__tests__/array-property.spec.ts new file mode 100644 index 0000000000..72b963bddb --- /dev/null +++ b/packages/core/utils/src/dml/__tests__/array-property.spec.ts @@ -0,0 +1,19 @@ +import { expectTypeOf } from "expect-type" +import { ArrayProperty } from "../properties/array" + +describe("Array property", () => { + test("should create an array property type", () => { + const property = new ArrayProperty() + + expectTypeOf(property["$dataType"]).toEqualTypeOf<[]>() + expect(property.parse("codes")).toEqual({ + fieldName: "codes", + dataType: { + name: "array", + }, + nullable: false, + indexes: [], + relationships: [], + }) + }) +}) 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 9e1a9b09d7..5978d847e6 100644 --- a/packages/core/utils/src/dml/__tests__/entity-builder.spec.ts +++ b/packages/core/utils/src/dml/__tests__/entity-builder.spec.ts @@ -1,5 +1,5 @@ import { EntityConstructor } from "@medusajs/types" -import { MetadataStorage } from "@mikro-orm/core" +import { ArrayType, MetadataStorage } from "@mikro-orm/core" import { expectTypeOf } from "expect-type" import { DmlEntity } from "../entity" import { model } from "../entity-builder" @@ -35,6 +35,7 @@ describe("Entity builder", () => { username: model.text(), email: model.text(), spend_limit: model.bigNumber(), + phones: model.array(), }) expect(user.name).toEqual("user") @@ -48,6 +49,7 @@ describe("Entity builder", () => { email: string spend_limit: number raw_spend_limit: Record + phones: string[] created_at: Date updated_at: Date deleted_at: Date | null @@ -124,6 +126,14 @@ describe("Entity builder", () => { setter: false, type: "any", }, + phones: { + getter: false, + name: "phones", + nullable: false, + reference: "scalar", + setter: false, + type: ArrayType, + }, updated_at: { reference: "scalar", type: "date", diff --git a/packages/core/utils/src/dml/entity-builder.ts b/packages/core/utils/src/dml/entity-builder.ts index 88a60be8b3..471e91b499 100644 --- a/packages/core/utils/src/dml/entity-builder.ts +++ b/packages/core/utils/src/dml/entity-builder.ts @@ -18,6 +18,7 @@ import { BelongsTo } from "./relations/belongs-to" import { HasMany } from "./relations/has-many" import { HasOne } from "./relations/has-one" import { ManyToMany } from "./relations/many-to-many" +import { ArrayProperty } from "./properties/array" /** * The implicit properties added by EntityBuilder in every schema @@ -105,6 +106,13 @@ export class EntityBuilder { return new BigNumberProperty() } + /** + * Define an array column + */ + array() { + return new ArrayProperty() + } + /** * Define a timestampz column */ 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 index dcc117a419..de3e0e589a 100644 --- a/packages/core/utils/src/dml/helpers/create-mikro-orm-entity.ts +++ b/packages/core/utils/src/dml/helpers/create-mikro-orm-entity.ts @@ -1,113 +1,12 @@ -import type { - EntityCascades, - EntityConstructor, - Infer, - KnownDataTypes, - PropertyMetadata, - PropertyType, - RelationshipMetadata, - RelationshipType, -} from "@medusajs/types" -import { - BeforeCreate, - Entity, - Enum, - Filter, - ManyToMany, - ManyToOne, - OneToMany, - OneToOne, - OnInit, - PrimaryKey, - Property, -} from "@mikro-orm/core" -import { - camelToSnakeCase, - createPsqlIndexStatementHelper, - generateEntityId, - isDefined, - pluralize, - toCamelCase, -} from "../../common" -import { upperCaseFirst } from "../../common/upper-case-first" -import { - MikroOrmBigNumberProperty, - mikroOrmSoftDeletableFilterOptions, - Searchable, -} from "../../dal" +import type { EntityConstructor, Infer } from "@medusajs/types" +import { Entity, Filter } from "@mikro-orm/core" +import { mikroOrmSoftDeletableFilterOptions } from "../../dal" import { DmlEntity } from "../entity" -import { HasMany } from "../relations/has-many" -import { HasOne } from "../relations/has-one" -import { ManyToMany as DmlManyToMany } from "../relations/many-to-many" - -/** - * DML entity data types to PostgreSQL data types via - * Mikro ORM. - * - * We remove "enum" type from here, because we use a dedicated - * mikro orm decorator for that - */ -const COLUMN_TYPES: { - [K in Exclude]: string -} = { - boolean: "boolean", - dateTime: "timestamptz", - number: "integer", - bigNumber: "numeric", - text: "text", - json: "jsonb", -} - -/** - * DML entity data types to Mikro ORM property - * types. - * - * We remove "enum" type from here, because we use a dedicated - * mikro orm decorator for that - */ -const PROPERTY_TYPES: { - [K in Exclude]: string -} = { - boolean: "boolean", - dateTime: "date", - number: "number", - bigNumber: "number", - text: "string", - json: "any", -} - -/** - * Properties that needs special treatment based upon their name. - * We can safely rely on these names because they are never - * provided by the end-user. Instead we output them - * implicitly via the DML. - */ -const SPECIAL_PROPERTIES: { - [propertyName: string]: ( - MikroORMEntity: EntityConstructor, - field: PropertyMetadata - ) => void -} = { - created_at: (MikroORMEntity, field) => { - Property({ - columnType: "timestamptz", - type: "date", - nullable: false, - defaultRaw: "now()", - onCreate: () => new Date(), - })(MikroORMEntity.prototype, field.fieldName) - }, - updated_at: (MikroORMEntity, field) => { - Property({ - columnType: "timestamptz", - type: "date", - nullable: false, - defaultRaw: "now()", - onCreate: () => new Date(), - onUpdate: () => new Date(), - })(MikroORMEntity.prototype, field.fieldName) - }, -} +import { defineProperty } from "./entity-builder/define-property" +import { applyIndexes } from "./entity-builder/apply-indexes" +import { applySearchable } from "./entity-builder/apply-searchable" +import { defineRelationship } from "./entity-builder/define-relationship" +import { parseEntityName } from "./entity-builder/parse-entity-name" /** * Factory function to create the mikro orm entity builder. The return @@ -115,33 +14,6 @@ const SPECIAL_PROPERTIES: { * to Mikro ORM entities. */ export function createMikrORMEntity() { - /** - * Parses entity name and returns model and table name from - * it - */ - function parseEntityName(entity: DmlEntity) { - const parsedEntity = entity.parse() - - /** - * Table name is going to be the snake case version of the entity name. - * Here we should preserve PG schema (if defined). - * - * For example: "platform.user" should stay as "platform.user" - */ - const tableName = camelToSnakeCase(parsedEntity.tableName) - - /** - * Entity name is going to be the camelCase version of the - * name defined by the user - */ - const [pgSchema, ...rest] = tableName.split(".") - return { - tableName, - modelName: upperCaseFirst(toCamelCase(parsedEntity.name)), - pgSchema: rest.length ? pgSchema : undefined, - } - } - /** * The following property is used to track many to many relationship * between two entities. It is needed because we have to mark one @@ -158,545 +30,6 @@ export function createMikrORMEntity() { // TODO: if we use the util toMikroOrmEntities then a new builder will be used each time, lets think about this. Currently if means that with many to many we need to use the same builder const MANY_TO_MANY_TRACKED_REALTIONS: Record = {} - /** - * Defines a DML entity schema field as a Mikro ORM property - */ - function defineProperty( - MikroORMEntity: EntityConstructor, - field: PropertyMetadata - ) { - /** - * Here we initialize nullable properties with a null value - */ - if (field.nullable) { - Object.defineProperty(MikroORMEntity.prototype, field.fieldName, { - value: null, - configurable: true, - enumerable: true, - writable: true, - }) - } - - if (SPECIAL_PROPERTIES[field.fieldName]) { - SPECIAL_PROPERTIES[field.fieldName](MikroORMEntity, field) - return - } - - /** - * Defining an big number property - * A big number property always comes with a raw_{{ fieldName }} column - * where the config of the bigNumber is set. - * The `raw_` field is generated during DML schema generation as a json - * dataType. - */ - if (field.dataType.name === "bigNumber") { - MikroOrmBigNumberProperty({ - nullable: field.nullable, - /** - * MikroORM does not ignore undefined values for default when generating - * the database schema SQL. Conditionally add it here to prevent undefined - * from being set as default value in SQL. - */ - ...(isDefined(field.defaultValue) && { default: field.defaultValue }), - })(MikroORMEntity.prototype, field.fieldName) - - return - } - - /** - * Defining an enum property - */ - if (field.dataType.name === "enum") { - Enum({ - items: () => field.dataType.options!.choices, - nullable: field.nullable, - /** - * MikroORM does not ignore undefined values for default when generating - * the database schema SQL. Conditionally add it here to prevent undefined - * from being set as default value in SQL. - */ - ...(isDefined(field.defaultValue) && { default: field.defaultValue }), - })(MikroORMEntity.prototype, field.fieldName) - return - } - - /** - * Defining an id property - */ - if (field.dataType.name === "id") { - const IdDecorator = field.dataType.options?.primaryKey - ? PrimaryKey({ - columnType: "text", - type: "string", - nullable: false, - }) - : Property({ - columnType: "text", - type: "string", - nullable: false, - }) - - IdDecorator(MikroORMEntity.prototype, field.fieldName) - - /** - * Hook to generate entity within the code - */ - MikroORMEntity.prototype.generateId = function () { - this[field.fieldName] = generateEntityId( - this[field.fieldName], - field.dataType.options?.prefix - ) - } - - /** - * Execute hook via lifecycle decorators - */ - BeforeCreate()(MikroORMEntity.prototype, "generateId") - OnInit()(MikroORMEntity.prototype, "generateId") - - return - } - - /** - * Define rest of properties - */ - const columnType = COLUMN_TYPES[field.dataType.name] - const propertyType = PROPERTY_TYPES[field.dataType.name] - - /** - * Defining a primary key property - */ - if (field.dataType.options?.primaryKey) { - PrimaryKey({ - columnType, - type: propertyType, - nullable: false, - })(MikroORMEntity.prototype, field.fieldName) - - return - } - - Property({ - columnType, - type: propertyType, - nullable: field.nullable, - /** - * MikroORM does not ignore undefined values for default when generating - * the database schema SQL. Conditionally add it here to prevent undefined - * from being set as default value in SQL. - */ - ...(isDefined(field.defaultValue) && { default: field.defaultValue }), - })(MikroORMEntity.prototype, field.fieldName) - } - - /** - * Prepares indexes for a given field - */ - function applyIndexes( - MikroORMEntity: EntityConstructor, - tableName: string, - field: PropertyMetadata - ) { - field.indexes.forEach((index) => { - const providerEntityIdIndexStatement = createPsqlIndexStatementHelper({ - tableName, - columns: [field.fieldName], - unique: index.type === "unique", - where: "deleted_at IS NULL", - }) - - providerEntityIdIndexStatement.MikroORMIndex()(MikroORMEntity) - }) - } - - /** - * Apply the searchable decorator to the property marked as searchable to enable the free text search - */ - function applySearchable( - MikroORMEntity: EntityConstructor, - field: PropertyMetadata - ) { - if (!field.dataType.options?.searchable) { - return - } - - Searchable()(MikroORMEntity.prototype, field.fieldName) - } - - /** - * Defines has one relationship on the Mikro ORM entity. - */ - function defineHasOneRelationship( - MikroORMEntity: EntityConstructor, - relationship: RelationshipMetadata, - { relatedModelName }: { relatedModelName: string }, - cascades: EntityCascades - ) { - const shouldRemoveRelated = !!cascades.delete?.includes(relationship.name) - - OneToOne({ - entity: relatedModelName, - nullable: relationship.nullable, - mappedBy: relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name), - cascade: shouldRemoveRelated - ? (["perist", "soft-remove"] as any) - : undefined, - })(MikroORMEntity.prototype, relationship.name) - } - - /** - * Defines has many relationship on the Mikro ORM entity - */ - function defineHasManyRelationship( - MikroORMEntity: EntityConstructor, - relationship: RelationshipMetadata, - { relatedModelName }: { relatedModelName: string }, - cascades: EntityCascades - ) { - const shouldRemoveRelated = !!cascades.delete?.includes(relationship.name) - - OneToMany({ - entity: relatedModelName, - orphanRemoval: true, - mappedBy: relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name), - cascade: shouldRemoveRelated - ? (["perist", "soft-remove"] as any) - : undefined, - })(MikroORMEntity.prototype, relationship.name) - } - - /** - * Defines belongs to relationship on the Mikro ORM entity. The belongsTo - * relationship inspects the related entity for the other side of - * the relationship and then uses one of the following Mikro ORM - * relationship. - * - * - OneToOne: When the other side uses "hasOne" with "owner: true" - * - ManyToOne: When the other side uses "hasMany" - */ - function defineBelongsToRelationship( - MikroORMEntity: EntityConstructor, - relationship: RelationshipMetadata, - relatedEntity: DmlEntity< - Record | RelationshipType> - >, - { relatedModelName }: { relatedModelName: string } - ) { - const mappedBy = - relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name) - const { schema: relationSchema, cascades: relationCascades } = - relatedEntity.parse() - - const otherSideRelation = relationSchema[mappedBy] - - /** - * In DML the relationships are cascaded from parent to child. A belongsTo - * relationship is always a child, therefore we look at the parent and - * define a onDelete: cascade when we are included in the delete - * list of parent cascade. - */ - const shouldCascade = relationCascades.delete?.includes(mappedBy) - - /** - * Ensure the mapped by is defined as relationship on the other side - */ - if (!otherSideRelation) { - throw new Error( - `Missing property "${mappedBy}" on "${relatedModelName}" entity. Make sure to define it as a relationship` - ) - } - - function applyForeignKeyAssignationHooks(foreignKeyName: string) { - const hookName = `assignRelationFromForeignKeyValue${foreignKeyName}` - /** - * Hook to handle foreign key assignation - */ - MikroORMEntity.prototype[hookName] = function () { - this[relationship.name] ??= this[foreignKeyName] - this[foreignKeyName] ??= this[relationship.name]?.id - } - - /** - * Execute hook via lifecycle decorators - */ - BeforeCreate()(MikroORMEntity.prototype, hookName) - OnInit()(MikroORMEntity.prototype, hookName) - } - - /** - * Otherside is a has many. Hence we should defined a ManyToOne - */ - if ( - HasMany.isHasMany(otherSideRelation) || - DmlManyToMany.isManyToMany(otherSideRelation) - ) { - const foreignKeyName = camelToSnakeCase(`${relationship.name}Id`) - - ManyToOne({ - entity: relatedModelName, - columnType: "text", - mapToPk: true, - fieldName: camelToSnakeCase(`${relationship.name}Id`), - nullable: relationship.nullable, - onDelete: shouldCascade ? "cascade" : undefined, - })(MikroORMEntity.prototype, camelToSnakeCase(`${relationship.name}Id`)) - - if (DmlManyToMany.isManyToMany(otherSideRelation)) { - Property({ - type: relatedModelName, - persist: false, - nullable: relationship.nullable, - })(MikroORMEntity.prototype, relationship.name) - } else { - // HasMany case - ManyToOne({ - entity: relatedModelName, - persist: false, - nullable: relationship.nullable, - })(MikroORMEntity.prototype, relationship.name) - } - - applyForeignKeyAssignationHooks(foreignKeyName) - return - } - - /** - * Otherside is a has one. Hence we should defined a OneToOne - */ - if (HasOne.isHasOne(otherSideRelation)) { - const foreignKeyName = camelToSnakeCase(`${relationship.name}Id`) - - OneToOne({ - entity: relatedModelName, - nullable: relationship.nullable, - mappedBy: mappedBy, - owner: true, - onDelete: shouldCascade ? "cascade" : undefined, - })(MikroORMEntity.prototype, relationship.name) - - if (relationship.nullable) { - Object.defineProperty(MikroORMEntity.prototype, foreignKeyName, { - value: null, - configurable: true, - enumerable: true, - writable: true, - }) - } - - Property({ - type: "string", - columnType: "text", - nullable: relationship.nullable, - })(MikroORMEntity.prototype, foreignKeyName) - - applyForeignKeyAssignationHooks(foreignKeyName) - return - } - - /** - * Other side is some unsupported data-type - */ - throw new Error( - `Invalid relationship reference for "${mappedBy}" on "${relatedModelName}" entity. Make sure to define a hasOne or hasMany relationship` - ) - } - - /** - * Defines a many to many relationship on the Mikro ORM entity - */ - function defineManyToManyRelationship( - MikroORMEntity: EntityConstructor, - relationship: RelationshipMetadata, - relatedEntity: DmlEntity< - Record | RelationshipType> - >, - { - relatedModelName, - pgSchema, - }: { relatedModelName: string; pgSchema: string | undefined } - ) { - let mappedBy = relationship.mappedBy - let inversedBy: undefined | string - let pivotEntityName: undefined | string - let pivotTableName: undefined | string - - /** - * Validating other side of relationship when mapped by is defined - */ - if (mappedBy) { - const otherSideRelation = relatedEntity.parse().schema[mappedBy] - if (!otherSideRelation) { - throw new Error( - `Missing property "${mappedBy}" on "${relatedModelName}" entity. Make sure to define it as a relationship` - ) - } - - if (!DmlManyToMany.isManyToMany(otherSideRelation)) { - throw new Error( - `Invalid relationship reference for "${mappedBy}" on "${relatedModelName}" entity. Make sure to define a manyToMany relationship` - ) - } - - /** - * Check if the other side has defined a mapped by and if that - * mapping is already tracked as the owner. - * - * - If yes, we will inverse our mapped by - * - Otherwise, we will track ourselves as the owner. - */ - if ( - otherSideRelation.parse(mappedBy).mappedBy && - MANY_TO_MANY_TRACKED_REALTIONS[`${relatedModelName}.${mappedBy}`] - ) { - inversedBy = mappedBy - mappedBy = undefined - } else { - MANY_TO_MANY_TRACKED_REALTIONS[ - `${MikroORMEntity.name}.${relationship.name}` - ] = true - } - } - - /** - * Validating pivot entity when it is defined and computing - * its name - */ - if (relationship.options.pivotEntity) { - if (typeof relationship.options.pivotEntity !== "function") { - throw new Error( - `Invalid pivotEntity reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to define the pivotEntity using a factory function` - ) - } - - const pivotEntity = relationship.options.pivotEntity() - if (!DmlEntity.isDmlEntity(pivotEntity)) { - throw new Error( - `Invalid pivotEntity reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to return a DML entity from the pivotEntity callback` - ) - } - - pivotEntityName = parseEntityName(pivotEntity).modelName - } - - if (!pivotEntityName) { - /** - * Pivot table name is created as follows (when not explicitly provided) - * - * - Combining both the entity's names. - * - Sorting them by alphabetical order - * - Converting them from camelCase to snake_case. - * - And finally pluralizing the second entity name. - */ - pivotTableName = - relationship.options.pivotTable ?? - [MikroORMEntity.name.toLowerCase(), relatedModelName.toLowerCase()] - .sort() - .map((token, index) => { - if (index === 1) { - return pluralize(camelToSnakeCase(token)) - } - return camelToSnakeCase(token) - }) - .join("_") - } - - ManyToMany({ - entity: relatedModelName, - ...(pivotTableName - ? { - pivotTable: pgSchema - ? `${pgSchema}.${pivotTableName}` - : pivotTableName, - } - : {}), - ...(pivotEntityName ? { pivotEntity: pivotEntityName } : {}), - ...(mappedBy ? { mappedBy: mappedBy as any } : {}), - ...(inversedBy ? { inversedBy: inversedBy as any } : {}), - })(MikroORMEntity.prototype, relationship.name) - } - - /** - * Defines a DML entity schema field as a Mikro ORM relationship - */ - function defineRelationship( - MikroORMEntity: EntityConstructor, - relationship: RelationshipMetadata, - cascades: EntityCascades - ) { - /** - * We expect the relationship.entity to be a function that - * lazily returns the related entity - */ - 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 (!DmlEntity.isDmlEntity(relatedEntity)) { - throw new Error( - `Invalid relationship reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to return a DML entity from the relationship callback` - ) - } - - const { modelName, tableName, pgSchema } = parseEntityName(relatedEntity) - const relatedEntityInfo = { - relatedModelName: modelName, - relatedTableName: tableName, - pgSchema, - } - - /** - * Defining relationships - */ - switch (relationship.type) { - case "hasOne": - defineHasOneRelationship( - MikroORMEntity, - relationship, - relatedEntityInfo, - cascades - ) - break - case "hasMany": - defineHasManyRelationship( - MikroORMEntity, - relationship, - relatedEntityInfo, - cascades - ) - break - case "belongsTo": - defineBelongsToRelationship( - MikroORMEntity, - relationship, - relatedEntity, - relatedEntityInfo - ) - break - case "manyToMany": - defineManyToManyRelationship( - MikroORMEntity, - relationship, - relatedEntity, - relatedEntityInfo - ) - break - } - } - /** * A helper function to define a Mikro ORM entity from a * DML entity. @@ -717,6 +50,10 @@ export function createMikrORMEntity() { }, }) + const context = { + MANY_TO_MANY_TRACKED_REALTIONS, + } + /** * Processing schema fields */ @@ -728,7 +65,7 @@ export function createMikrORMEntity() { applyIndexes(MikroORMEntity, tableName, field) applySearchable(MikroORMEntity, field) } else { - defineRelationship(MikroORMEntity, field, cascades) + defineRelationship(MikroORMEntity, field, cascades, context) } }) diff --git a/packages/core/utils/src/dml/helpers/entity-builder/apply-indexes.ts b/packages/core/utils/src/dml/helpers/entity-builder/apply-indexes.ts new file mode 100644 index 0000000000..15c268a2f3 --- /dev/null +++ b/packages/core/utils/src/dml/helpers/entity-builder/apply-indexes.ts @@ -0,0 +1,22 @@ +import { EntityConstructor, PropertyMetadata } from "@medusajs/types" +import { createPsqlIndexStatementHelper } from "../../../common" + +/** + * Prepares indexes for a given field + */ +export function applyIndexes( + MikroORMEntity: EntityConstructor, + tableName: string, + field: PropertyMetadata +) { + field.indexes.forEach((index) => { + const providerEntityIdIndexStatement = createPsqlIndexStatementHelper({ + tableName, + columns: [field.fieldName], + unique: index.type === "unique", + where: "deleted_at IS NULL", + }) + + providerEntityIdIndexStatement.MikroORMIndex()(MikroORMEntity) + }) +} diff --git a/packages/core/utils/src/dml/helpers/entity-builder/apply-searchable.ts b/packages/core/utils/src/dml/helpers/entity-builder/apply-searchable.ts new file mode 100644 index 0000000000..6bcdbed0b9 --- /dev/null +++ b/packages/core/utils/src/dml/helpers/entity-builder/apply-searchable.ts @@ -0,0 +1,16 @@ +import { EntityConstructor, PropertyMetadata } from "@medusajs/types" +import { Searchable } from "../../../dal" + +/** + * Apply the searchable decorator to the property marked as searchable to enable the free text search + */ +export function applySearchable( + MikroORMEntity: EntityConstructor, + field: PropertyMetadata +) { + if (!field.dataType.options?.searchable) { + return + } + + Searchable()(MikroORMEntity.prototype, field.fieldName) +} diff --git a/packages/core/utils/src/dml/helpers/entity-builder/define-property.ts b/packages/core/utils/src/dml/helpers/entity-builder/define-property.ts new file mode 100644 index 0000000000..d4bd456d9f --- /dev/null +++ b/packages/core/utils/src/dml/helpers/entity-builder/define-property.ts @@ -0,0 +1,233 @@ +import { + EntityConstructor, + KnownDataTypes, + PropertyMetadata, +} from "@medusajs/types" +import { MikroOrmBigNumberProperty } from "../../../dal" +import { generateEntityId, isDefined } from "../../../common" +import { + ArrayType, + BeforeCreate, + Enum, + OnInit, + PrimaryKey, + Property, +} from "@mikro-orm/core" + +/** + * DML entity data types to PostgreSQL data types via + * Mikro ORM. + * + * We remove "enum" type from here, because we use a dedicated + * mikro orm decorator for that + */ +const COLUMN_TYPES: { + [K in Exclude]: string +} = { + boolean: "boolean", + dateTime: "timestamptz", + number: "integer", + bigNumber: "numeric", + text: "text", + json: "jsonb", + array: "array", +} + +/** + * DML entity data types to Mikro ORM property + * types. + * + * We remove "enum" type from here, because we use a dedicated + * mikro orm decorator for that + */ +const PROPERTY_TYPES: { + [K in Exclude]: string +} = { + boolean: "boolean", + dateTime: "date", + number: "number", + bigNumber: "number", + text: "string", + json: "any", + array: "string[]", +} + +/** + * Properties that needs special treatment based upon their name. + * We can safely rely on these names because they are never + * provided by the end-user. Instead we output them + * implicitly via the DML. + */ +const SPECIAL_PROPERTIES: { + [propertyName: string]: ( + MikroORMEntity: EntityConstructor, + field: PropertyMetadata + ) => void +} = { + created_at: (MikroORMEntity, field) => { + Property({ + columnType: "timestamptz", + type: "date", + nullable: false, + defaultRaw: "now()", + onCreate: () => new Date(), + })(MikroORMEntity.prototype, field.fieldName) + }, + updated_at: (MikroORMEntity, field) => { + Property({ + columnType: "timestamptz", + type: "date", + nullable: false, + defaultRaw: "now()", + onCreate: () => new Date(), + onUpdate: () => new Date(), + })(MikroORMEntity.prototype, field.fieldName) + }, +} + +/** + * Defines a DML entity schema field as a Mikro ORM property + */ +export function defineProperty( + MikroORMEntity: EntityConstructor, + field: PropertyMetadata +) { + /** + * Here we initialize nullable properties with a null value + */ + if (field.nullable) { + Object.defineProperty(MikroORMEntity.prototype, field.fieldName, { + value: null, + configurable: true, + enumerable: true, + writable: true, + }) + } + + if (SPECIAL_PROPERTIES[field.fieldName]) { + SPECIAL_PROPERTIES[field.fieldName](MikroORMEntity, field) + return + } + + /** + * Defining an big number property + * A big number property always comes with a raw_{{ fieldName }} column + * where the config of the bigNumber is set. + * The `raw_` field is generated during DML schema generation as a json + * dataType. + */ + if (field.dataType.name === "bigNumber") { + MikroOrmBigNumberProperty({ + nullable: field.nullable, + /** + * MikroORM does not ignore undefined values for default when generating + * the database schema SQL. Conditionally add it here to prevent undefined + * from being set as default value in SQL. + */ + ...(isDefined(field.defaultValue) && { default: field.defaultValue }), + })(MikroORMEntity.prototype, field.fieldName) + + return + } + + if (field.dataType.name === "array") { + Property({ + type: ArrayType, + nullable: field.nullable, + /** + * MikroORM does not ignore undefined values for default when generating + * the database schema SQL. Conditionally add it here to prevent undefined + * from being set as default value in SQL. + */ + ...(isDefined(field.defaultValue) && { default: field.defaultValue }), + })(MikroORMEntity.prototype, field.fieldName) + + return + } + + /** + * Defining an enum property + */ + if (field.dataType.name === "enum") { + Enum({ + items: () => field.dataType.options!.choices, + nullable: field.nullable, + /** + * MikroORM does not ignore undefined values for default when generating + * the database schema SQL. Conditionally add it here to prevent undefined + * from being set as default value in SQL. + */ + ...(isDefined(field.defaultValue) && { default: field.defaultValue }), + })(MikroORMEntity.prototype, field.fieldName) + + return + } + + /** + * Defining an id property + */ + if (field.dataType.name === "id") { + const IdDecorator = field.dataType.options?.primaryKey + ? PrimaryKey({ + columnType: "text", + type: "string", + nullable: false, + }) + : Property({ + columnType: "text", + type: "string", + nullable: false, + }) + + IdDecorator(MikroORMEntity.prototype, field.fieldName) + + /** + * Hook to generate entity within the code + */ + MikroORMEntity.prototype.generateId = function () { + this[field.fieldName] = generateEntityId( + this[field.fieldName], + field.dataType.options?.prefix + ) + } + + /** + * Execute hook via lifecycle decorators + */ + BeforeCreate()(MikroORMEntity.prototype, "generateId") + OnInit()(MikroORMEntity.prototype, "generateId") + + return + } + + /** + * Define rest of properties + */ + const columnType = COLUMN_TYPES[field.dataType.name] + const propertyType = PROPERTY_TYPES[field.dataType.name] + + /** + * Defining a primary key property + */ + if (field.dataType.options?.primaryKey) { + PrimaryKey({ + columnType, + type: propertyType, + nullable: false, + })(MikroORMEntity.prototype, field.fieldName) + + return + } + + Property({ + columnType, + type: propertyType, + nullable: field.nullable, + /** + * MikroORM does not ignore undefined values for default when generating + * the database schema SQL. Conditionally add it here to prevent undefined + * from being set as default value in SQL. + */ + ...(isDefined(field.defaultValue) && { default: field.defaultValue }), + })(MikroORMEntity.prototype, field.fieldName) +} diff --git a/packages/core/utils/src/dml/helpers/entity-builder/define-relationship.ts b/packages/core/utils/src/dml/helpers/entity-builder/define-relationship.ts new file mode 100644 index 0000000000..2669a730c5 --- /dev/null +++ b/packages/core/utils/src/dml/helpers/entity-builder/define-relationship.ts @@ -0,0 +1,403 @@ +import { + EntityCascades, + EntityConstructor, + PropertyType, + RelationshipMetadata, + RelationshipType, +} from "@medusajs/types" +import { DmlEntity } from "../../entity" +import { parseEntityName } from "./parse-entity-name" +import { + BeforeCreate, + ManyToMany, + ManyToOne, + OneToMany, + OneToOne, + OnInit, + Property, +} from "@mikro-orm/core" +import { camelToSnakeCase, pluralize } from "../../../common" +import { HasMany } from "../../relations/has-many" +import { HasOne } from "../../relations/has-one" +import { ManyToMany as DmlManyToMany } from "../../relations/many-to-many" + +type Context = { + MANY_TO_MANY_TRACKED_REALTIONS: Record +} + +/** + * Defines has one relationship on the Mikro ORM entity. + */ +export function defineHasOneRelationship( + MikroORMEntity: EntityConstructor, + relationship: RelationshipMetadata, + { relatedModelName }: { relatedModelName: string }, + cascades: EntityCascades +) { + const shouldRemoveRelated = !!cascades.delete?.includes(relationship.name) + + OneToOne({ + entity: relatedModelName, + nullable: relationship.nullable, + mappedBy: relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name), + cascade: shouldRemoveRelated + ? (["perist", "soft-remove"] as any) + : undefined, + })(MikroORMEntity.prototype, relationship.name) +} + +/** + * Defines has many relationship on the Mikro ORM entity + */ +export function defineHasManyRelationship( + MikroORMEntity: EntityConstructor, + relationship: RelationshipMetadata, + { relatedModelName }: { relatedModelName: string }, + cascades: EntityCascades +) { + const shouldRemoveRelated = !!cascades.delete?.includes(relationship.name) + + OneToMany({ + entity: relatedModelName, + orphanRemoval: true, + mappedBy: relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name), + cascade: shouldRemoveRelated + ? (["perist", "soft-remove"] as any) + : undefined, + })(MikroORMEntity.prototype, relationship.name) +} + +/** + * Defines belongs to relationship on the Mikro ORM entity. The belongsTo + * relationship inspects the related entity for the other side of + * the relationship and then uses one of the following Mikro ORM + * relationship. + * + * - OneToOne: When the other side uses "hasOne" with "owner: true" + * - ManyToOne: When the other side uses "hasMany" + */ +export function defineBelongsToRelationship( + MikroORMEntity: EntityConstructor, + relationship: RelationshipMetadata, + relatedEntity: DmlEntity< + Record | RelationshipType> + >, + { relatedModelName }: { relatedModelName: string } +) { + const mappedBy = + relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name) + const { schema: relationSchema, cascades: relationCascades } = + relatedEntity.parse() + + const otherSideRelation = relationSchema[mappedBy] + + /** + * In DML the relationships are cascaded from parent to child. A belongsTo + * relationship is always a child, therefore we look at the parent and + * define a onDelete: cascade when we are included in the delete + * list of parent cascade. + */ + const shouldCascade = relationCascades.delete?.includes(mappedBy) + + /** + * Ensure the mapped by is defined as relationship on the other side + */ + if (!otherSideRelation) { + throw new Error( + `Missing property "${mappedBy}" on "${relatedModelName}" entity. Make sure to define it as a relationship` + ) + } + + function applyForeignKeyAssignationHooks(foreignKeyName: string) { + const hookName = `assignRelationFromForeignKeyValue${foreignKeyName}` + /** + * Hook to handle foreign key assignation + */ + MikroORMEntity.prototype[hookName] = function () { + this[relationship.name] ??= this[foreignKeyName] + this[foreignKeyName] ??= this[relationship.name]?.id + } + + /** + * Execute hook via lifecycle decorators + */ + BeforeCreate()(MikroORMEntity.prototype, hookName) + OnInit()(MikroORMEntity.prototype, hookName) + } + + /** + * Otherside is a has many. Hence we should defined a ManyToOne + */ + if ( + HasMany.isHasMany(otherSideRelation) || + DmlManyToMany.isManyToMany(otherSideRelation) + ) { + const foreignKeyName = camelToSnakeCase(`${relationship.name}Id`) + + ManyToOne({ + entity: relatedModelName, + columnType: "text", + mapToPk: true, + fieldName: camelToSnakeCase(`${relationship.name}Id`), + nullable: relationship.nullable, + onDelete: shouldCascade ? "cascade" : undefined, + })(MikroORMEntity.prototype, camelToSnakeCase(`${relationship.name}Id`)) + + if (DmlManyToMany.isManyToMany(otherSideRelation)) { + Property({ + type: relatedModelName, + persist: false, + nullable: relationship.nullable, + })(MikroORMEntity.prototype, relationship.name) + } else { + // HasMany case + ManyToOne({ + entity: relatedModelName, + persist: false, + nullable: relationship.nullable, + })(MikroORMEntity.prototype, relationship.name) + } + + applyForeignKeyAssignationHooks(foreignKeyName) + return + } + + /** + * Otherside is a has one. Hence we should defined a OneToOne + */ + if (HasOne.isHasOne(otherSideRelation)) { + const foreignKeyName = camelToSnakeCase(`${relationship.name}Id`) + + OneToOne({ + entity: relatedModelName, + nullable: relationship.nullable, + mappedBy: mappedBy, + owner: true, + onDelete: shouldCascade ? "cascade" : undefined, + })(MikroORMEntity.prototype, relationship.name) + + if (relationship.nullable) { + Object.defineProperty(MikroORMEntity.prototype, foreignKeyName, { + value: null, + configurable: true, + enumerable: true, + writable: true, + }) + } + + Property({ + type: "string", + columnType: "text", + nullable: relationship.nullable, + })(MikroORMEntity.prototype, foreignKeyName) + + applyForeignKeyAssignationHooks(foreignKeyName) + return + } + + /** + * Other side is some unsupported data-type + */ + throw new Error( + `Invalid relationship reference for "${mappedBy}" on "${relatedModelName}" entity. Make sure to define a hasOne or hasMany relationship` + ) +} + +/** + * Defines a many to many relationship on the Mikro ORM entity + */ +export function defineManyToManyRelationship( + MikroORMEntity: EntityConstructor, + relationship: RelationshipMetadata, + relatedEntity: DmlEntity< + Record | RelationshipType> + >, + { + relatedModelName, + pgSchema, + }: { relatedModelName: string; pgSchema: string | undefined }, + { MANY_TO_MANY_TRACKED_REALTIONS }: Context +) { + let mappedBy = relationship.mappedBy + let inversedBy: undefined | string + let pivotEntityName: undefined | string + let pivotTableName: undefined | string + + /** + * Validating other side of relationship when mapped by is defined + */ + if (mappedBy) { + const otherSideRelation = relatedEntity.parse().schema[mappedBy] + if (!otherSideRelation) { + throw new Error( + `Missing property "${mappedBy}" on "${relatedModelName}" entity. Make sure to define it as a relationship` + ) + } + + if (!DmlManyToMany.isManyToMany(otherSideRelation)) { + throw new Error( + `Invalid relationship reference for "${mappedBy}" on "${relatedModelName}" entity. Make sure to define a manyToMany relationship` + ) + } + + /** + * Check if the other side has defined a mapped by and if that + * mapping is already tracked as the owner. + * + * - If yes, we will inverse our mapped by + * - Otherwise, we will track ourselves as the owner. + */ + if ( + otherSideRelation.parse(mappedBy).mappedBy && + MANY_TO_MANY_TRACKED_REALTIONS[`${relatedModelName}.${mappedBy}`] + ) { + inversedBy = mappedBy + mappedBy = undefined + } else { + MANY_TO_MANY_TRACKED_REALTIONS[ + `${MikroORMEntity.name}.${relationship.name}` + ] = true + } + } + + /** + * Validating pivot entity when it is defined and computing + * its name + */ + if (relationship.options.pivotEntity) { + if (typeof relationship.options.pivotEntity !== "function") { + throw new Error( + `Invalid pivotEntity reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to define the pivotEntity using a factory function` + ) + } + + const pivotEntity = relationship.options.pivotEntity() + if (!DmlEntity.isDmlEntity(pivotEntity)) { + throw new Error( + `Invalid pivotEntity reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to return a DML entity from the pivotEntity callback` + ) + } + + pivotEntityName = parseEntityName(pivotEntity).modelName + } + + if (!pivotEntityName) { + /** + * Pivot table name is created as follows (when not explicitly provided) + * + * - Combining both the entity's names. + * - Sorting them by alphabetical order + * - Converting them from camelCase to snake_case. + * - And finally pluralizing the second entity name. + */ + pivotTableName = + relationship.options.pivotTable ?? + [MikroORMEntity.name.toLowerCase(), relatedModelName.toLowerCase()] + .sort() + .map((token, index) => { + if (index === 1) { + return pluralize(camelToSnakeCase(token)) + } + return camelToSnakeCase(token) + }) + .join("_") + } + + ManyToMany({ + entity: relatedModelName, + ...(pivotTableName + ? { + pivotTable: pgSchema + ? `${pgSchema}.${pivotTableName}` + : pivotTableName, + } + : {}), + ...(pivotEntityName ? { pivotEntity: pivotEntityName } : {}), + ...(mappedBy ? { mappedBy: mappedBy as any } : {}), + ...(inversedBy ? { inversedBy: inversedBy as any } : {}), + })(MikroORMEntity.prototype, relationship.name) +} + +/** + * Defines a DML entity schema field as a Mikro ORM relationship + */ +export function defineRelationship( + MikroORMEntity: EntityConstructor, + relationship: RelationshipMetadata, + cascades: EntityCascades, + context: Context +) { + /** + * We expect the relationship.entity to be a function that + * lazily returns the related entity + */ + 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 (!DmlEntity.isDmlEntity(relatedEntity)) { + throw new Error( + `Invalid relationship reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to return a DML entity from the relationship callback` + ) + } + + const { modelName, tableName, pgSchema } = parseEntityName(relatedEntity) + const relatedEntityInfo = { + relatedModelName: modelName, + relatedTableName: tableName, + pgSchema, + } + + /** + * Defining relationships + */ + switch (relationship.type) { + case "hasOne": + defineHasOneRelationship( + MikroORMEntity, + relationship, + relatedEntityInfo, + cascades + ) + break + case "hasMany": + defineHasManyRelationship( + MikroORMEntity, + relationship, + relatedEntityInfo, + cascades + ) + break + case "belongsTo": + defineBelongsToRelationship( + MikroORMEntity, + relationship, + relatedEntity, + relatedEntityInfo + ) + break + case "manyToMany": + defineManyToManyRelationship( + MikroORMEntity, + relationship, + relatedEntity, + relatedEntityInfo, + context + ) + break + } +} diff --git a/packages/core/utils/src/dml/helpers/entity-builder/parse-entity-name.ts b/packages/core/utils/src/dml/helpers/entity-builder/parse-entity-name.ts new file mode 100644 index 0000000000..b34d3af52b --- /dev/null +++ b/packages/core/utils/src/dml/helpers/entity-builder/parse-entity-name.ts @@ -0,0 +1,29 @@ +import { DmlEntity } from "../../entity" +import { camelToSnakeCase, toCamelCase, upperCaseFirst } from "../../../common" + +/** + * Parses entity name and returns model and table name from + * it + */ +export function parseEntityName(entity: DmlEntity) { + const parsedEntity = entity.parse() + + /** + * Table name is going to be the snake case version of the entity name. + * Here we should preserve PG schema (if defined). + * + * For example: "platform.user" should stay as "platform.user" + */ + const tableName = camelToSnakeCase(parsedEntity.tableName) + + /** + * Entity name is going to be the camelCase version of the + * name defined by the user + */ + const [pgSchema, ...rest] = tableName.split(".") + return { + tableName, + modelName: upperCaseFirst(toCamelCase(parsedEntity.name)), + pgSchema: rest.length ? pgSchema : undefined, + } +} diff --git a/packages/core/utils/src/dml/properties/array.ts b/packages/core/utils/src/dml/properties/array.ts new file mode 100644 index 0000000000..a32d7b2ad9 --- /dev/null +++ b/packages/core/utils/src/dml/properties/array.ts @@ -0,0 +1,10 @@ +import { BaseProperty } from "./base" + +/** + * The ArrayProperty is used to define an array property + */ +export class ArrayProperty extends BaseProperty { + protected dataType = { + name: "array", + } as const +}