feat: Add support for array properties DML (#7836)

This commit is contained in:
Adrien de Peretti
2024-06-25 20:51:14 +02:00
committed by GitHub
parent 39038ddb0a
commit dc307c658d
11 changed files with 765 additions and 677 deletions

View File

@@ -17,6 +17,7 @@ export type KnownDataTypes =
| "number"
| "bigNumber"
| "dateTime"
| "array"
| "json"
| "id"

View File

@@ -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: [],
})
})
})

View File

@@ -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<string, unknown>
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",

View File

@@ -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
*/

View File

@@ -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<KnownDataTypes, "enum" | "id">]: 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<KnownDataTypes, "enum" | "id">]: 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<any>,
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<any>) {
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<string, boolean> = {}
/**
* Defines a DML entity schema field as a Mikro ORM property
*/
function defineProperty(
MikroORMEntity: EntityConstructor<any>,
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<any>,
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<any>,
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<any>,
relationship: RelationshipMetadata,
{ relatedModelName }: { relatedModelName: string },
cascades: EntityCascades<string[]>
) {
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<any>,
relationship: RelationshipMetadata,
{ relatedModelName }: { relatedModelName: string },
cascades: EntityCascades<string[]>
) {
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<any>,
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, PropertyType<any> | RelationshipType<any>>
>,
{ 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<any>,
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, PropertyType<any> | RelationshipType<any>>
>,
{
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<any>,
relationship: RelationshipMetadata,
cascades: EntityCascades<string[]>
) {
/**
* 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)
}
})

View File

@@ -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<any>,
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)
})
}

View File

@@ -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<any>,
field: PropertyMetadata
) {
if (!field.dataType.options?.searchable) {
return
}
Searchable()(MikroORMEntity.prototype, field.fieldName)
}

View File

@@ -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<KnownDataTypes, "enum" | "id">]: 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<KnownDataTypes, "enum" | "id">]: 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<any>,
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<any>,
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)
}

View File

@@ -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<string, boolean>
}
/**
* Defines has one relationship on the Mikro ORM entity.
*/
export function defineHasOneRelationship(
MikroORMEntity: EntityConstructor<any>,
relationship: RelationshipMetadata,
{ relatedModelName }: { relatedModelName: string },
cascades: EntityCascades<string[]>
) {
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<any>,
relationship: RelationshipMetadata,
{ relatedModelName }: { relatedModelName: string },
cascades: EntityCascades<string[]>
) {
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<any>,
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, PropertyType<any> | RelationshipType<any>>
>,
{ 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<any>,
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, PropertyType<any> | RelationshipType<any>>
>,
{
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<any>,
relationship: RelationshipMetadata,
cascades: EntityCascades<string[]>,
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
}
}

View File

@@ -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<any>) {
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,
}
}

View File

@@ -0,0 +1,10 @@
import { BaseProperty } from "./base"
/**
* The ArrayProperty is used to define an array property
*/
export class ArrayProperty extends BaseProperty<string[]> {
protected dataType = {
name: "array",
} as const
}